Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/artyomzemlyak_looplab.json
T

1 line
3.2 MiB
Plaintext
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "artyomzemlyak/looplab", "files": {"looplab/core/config.py": {"4cbb0b0f6e76957966e122d97bbee426d021a84d": {"t": 1785746901.7484894, "n": 120791, "h": "0532202007dde718", "c": "\"\"\"Engine config: the schema (what settings exist). (I0, ADR-11)\n\nOne pydantic-settings class declaring every engine knob, its default, and its docs. The loader —\nhow a run file / CLI / env becomes a `Settings` — is `looplab.core.appconfig`, which also owns\nthe config-precedence order. A resolved, secret-masked snapshot is written next to each run for\nreproducibility. (No real secrets in P0, but the masking discipline is in place.)\n\"\"\"\nfrom __future__ import annotations\n\nimport re\nimport types\nimport typing\nfrom pathlib import Path\n\nfrom pydantic import Field, PrivateAttr, SecretStr, field_validator, model_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n# Single source of the LLM first-byte (response-headers) default — see core/llm.py.\nfrom looplab.core.llm import DEFAULT_HEADER_TIMEOUT_S\n\n# Default home for cross-run memory + the knowledge base (shared across every run). Both are ON by\n# default now (a user asked for it) so a fresh install accumulates + consults knowledge with no setup;\n# set the env var to \"\" to disable, or to a path to relocate. `~/.looplab/` keeps them out of any one\n# project + shared across projects.\n_LL_HOME = Path.home() / \".looplab\"\n\n# What one `llm_profiles` entry may contain. Deliberately closed: an unrecognized field is a typo the\n# operator would otherwise never see fail, and the map is a public artifact (config.snapshot.json,\n# HTTP, LOOPLAB_*), so it must never accrete free-form keys.\n_PROFILE_FIELDS = frozenset({\"model\", \"base_url\", \"temperature\", \"api_key_env\"})\n# A variable name that `runtime/sandbox.py::SECRET_ENV` will recognize as holding a secret and strip\n# from generated code's environment. Duplicated here, not imported: layering forbids `core` from\n# importing `runtime`. `tests/test_secret_env_pattern.py` holds the two in agreement.\n_SECRET_ENV_NAME = re.compile(\n r\"\\A(?=[A-Z][A-Z0-9_]{0,63}\\Z)(?=.*(KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL)).*\\Z\")\n\n\ndef _is_numeric_annotation(ann) -> bool:\n \"\"\"True when a field's annotation admits int/float but NOT bool.\n\n Used by `Settings._reject_bool_for_numeric_fields`: pydantic resolves annotations at class build,\n so `model_fields[...].annotation` is a real type object here even under\n `from __future__ import annotations`. Optionals are numeric when every non-None member is\n (`int | None` yes, `int | str` no), so a boolean can never slip in through a union arm.\"\"\"\n if ann is bool:\n return False # a bool field legitimately takes a bool\n if ann is int or ann is float:\n return True\n args = [a for a in typing.get_args(ann) if a is not type(None)]\n origin = typing.get_origin(ann)\n if args and origin in (typing.Union, types.UnionType):\n return all(_is_numeric_annotation(a) for a in args)\n return False\n\n# Autonomous roles that may be granted per-setting write access (see Settings.agent_control).\n# strategist = A7 meta-controller (run-wide); boss = run-chat operator-proxy (run-wide);\n# researcher = per-experiment proposer (per-node sizing, e.g. eval timeout for a heavy model).\nAGENT_ROLES = (\"strategist\", \"boss\", \"researcher\")\n\n# A foresight score is computed for every acted-on proposal. Keep this lower than the generic\n# verifier's emergency ceiling so a typo cannot multiply paid synchronous calls across a run.\nMAX_FORESIGHT_VERIFY_SAMPLES = 8\n\n\n# Layer-2 parallelism renamed two public settings without removing the durable legacy fields. Keep\n# the equivalence in one place: config-source precedence, governance migration, and live strategy\n# application must all agree which names describe the same authority/value.\nPARALLELISM_ALIASES: dict[str, str] = {\n \"max_parallel\": \"eval_parallel\",\n \"parallel_build\": \"llm_parallel\",\n}\n\n\ndef parallelism_aliases(setting: str) -> tuple[str, ...]:\n \"\"\"Return every spelling in ``setting``'s alias family, canonical name first.\"\"\"\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if setting in (legacy, canonical):\n return canonical, legacy\n return (setting,)\n\n\ndef canonicalize_parallelism_source(\n values: dict, *, promote_build_to_llm_parallel: bool = False,\n) -> dict:\n \"\"\"Promote legacy parallel keys *within one config-precedence source*.\n\n Applying this only after file/CLI/env were flattened would let a lower-priority canonical env\n value shadow a higher-priority legacy file value (or a file canonical value shadow a CLI legacy\n override), so callers with sub-layers normalize each layer independently.\n\n ``max_parallel``/``eval_parallel`` are true aliases (pure eval width, no extra semantics) and are\n always promoted. ``parallel_build``/``llm_parallel`` are NOT: a positive ``llm_parallel`` also flips\n on the finite shared LLM broker (``orchestrator._startup_llm_total``), while legacy ``parallel_build``\n means \"legacy build width, unbounded overlap, no shared total\". So promoting a legacy-only\n ``parallel_build`` layer (e.g. just ``LOOPLAB_PARALLEL_BUILD=1`` in env) would silently serialize\n every provider call — contradicting llm_broker.py's docstring, orchestrator's \"legacy-only preserves\n unbounded overlap\" note, the broker-neutral live-control path, and the configuration.md ``llm_parallel``\n row (\"falls back ... without enabling a shared total\"). Config/startup loads therefore keep broker\n opt-in tied to an EXPLICITLY-spelled canonical ``llm_parallel``; the orchestrator's own width fallback\n (``llm_parallel if not None else parallel_build``) still carries the legacy build width. Only the\n Strategist live-control path (``canonicalize_strategy_parallelism``) opts into\n ``promote_build_to_llm_parallel``, where ``parallel_build`` is a deliberate full alias of the\n canonical knob (see strategy.py ``_apply_strategy``).\n \"\"\"\n if not isinstance(values, dict):\n return values\n out = dict(values)\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if canonical == \"llm_parallel\" and not promote_build_to_llm_parallel:\n continue\n # None is the durable legacy mode for llm_parallel, not a missing value. A normal snapshot\n # contains llm_parallel:null plus parallel_build:1; promoting null to 1 would unexpectedly enable\n # the global one-call broker. Promote aliases only when the canonical key is absent, and pin\n # snapshot round-trip parity. Canonicalize inside each precedence layer, never across the\n # flattened result, so the alias migration cannot invert CLI > file > env precedence.\n if canonical not in out and out.get(legacy) is not None:\n out[canonical] = out[legacy]\n return out\n\n\ndef flatten_parallelism_layers(layers) -> dict:\n \"\"\"Flatten same-tier precedence layers (LOWEST priority first) and resolve the legacy pair.\n\n PRECEDENCE REPAIR for the non-promoted `parallel_build`/`llm_parallel` pair. Declining to promote\n in `canonicalize_parallelism_source` is right (a legacy-only layer must not flip on the shared\n broker), but it let a LOWER-priority canonical value survive a HIGHER-priority legacy override:\n file `llm_parallel: 2` + CLI `-s parallel_build=8` flattened to BOTH keys, and the orchestrator\n prefers `llm_parallel`, so the operator's explicit CLI width was silently discarded. When a higher\n layer names the legacy width over a lower layer's canonical one, mask with the LEGACY SENTINEL\n (`llm_parallel=None` is the durable \"legacy mode — build width from parallel_build, no shared\n total\"), so the higher layer wins with the semantics it asked for.\n\n The mask lives HERE, not in the per-layer canonicalizer, because only a flatten knows the layer\n ORDER. Stamping it per layer meant any layer naming `parallel_build` emitted `llm_parallel=None`\n even when NO layer in the tier set the canonical key — and since pydantic-settings ranks init\n kwargs above env, that sentinel then silently overrode a `LOOPLAB_LLM_PARALLEL` the operator had\n exported as a deliberate provider rate/cost ceiling. Precedence is per FIELD: a tier that never\n spelled `llm_parallel` has not set it, and it must fall through to env/.env/defaults — which is\n exactly what `build_settings`' docstring promises (\"the merged dict wins only where it actually\n sets a value\").\n \"\"\"\n merged: dict = {}\n legacy_at = canonical_at = -1\n for index, layer in enumerate(layers):\n if not isinstance(layer, dict):\n continue\n merged.update(layer)\n if layer.get(\"parallel_build\") is not None:\n legacy_at = index\n if \"llm_parallel\" in layer:\n canonical_at = index\n # Only when this tier DID set the canonical key, at a strictly lower layer than the legacy one.\n if canonical_at >= 0 and legacy_at > canonical_at:\n merged[\"llm_parallel\"] = None\n return merged\n\n# Settings whose semantics are committed by ``run_started`` and restored from the folded event log\n# on every re-entry. Changing one only in ``config.snapshot.json`` would mix incompatible evidence\n# (a different holdout split) or make the live producer disagree with replay's selection policy.\n# The HTTP config editor and the engine both consume this one contract; keep deliberately mutable\n# controls such as ``trust_gate`` out (that field has its own durable change event).\nRUN_START_PINNED_FIELDS = frozenset({\n \"card_driven_selection\",\n \"speculation_depth\",\n \"holdout_fraction\",\n \"holdout_select\",\n \"select_verifier\",\n \"select_verifier_samples\",\n \"verifier_ci_tie\",\n})\n\n\ndef run_start_pinned_settings(state) -> dict:\n \"\"\"Return the effective Settings names committed by a folded run-start record.\n\n Pre-pin legacy logs have no recorded holdout fraction, so their snapshot remains authoritative\n for both holdout knobs. Verifier fields, however, have always been re-pinned from the legacy-safe\n fold defaults whenever a valid ``run_started`` identity exists.\n \"\"\"\n if not getattr(state, \"run_id\", \"\"):\n return {}\n values = {\n # Layer 3 changes which queue owns macro-action selection. Once a run starts, replay and\n # resume must keep the same owner even if the snapshot or ambient default changes later.\n \"card_driven_selection\": bool(getattr(state, \"card_driven_selection\", False)),\n # Layer 5 overlaps Card production with evaluation only when this pinned treatment is\n # positive. Old logs omit it and therefore retain the exact serial build/eval spine.\n \"speculation_depth\": int(getattr(state, \"speculation_depth\", 0)),\n \"select_verifier\": bool(getattr(state, \"select_verifier_tiebreak\", False)),\n \"select_verifier_samples\": int(getattr(state, \"select_verifier_samples\", 3)),\n \"verifier_ci_tie\": bool(getattr(state, \"verifier_ci_tie\", False)),\n }\n holdout_fraction = getattr(state, \"holdout_fraction\", None)\n if holdout_fraction is not None:\n values.update({\n \"holdout_fraction\": holdout_fraction,\n \"holdout_select\": bool(getattr(state, \"holdout_select\", False)),\n })\n if not values.keys() <= RUN_START_PINNED_FIELDS:\n raise RuntimeError(\"folded run-start pinned settings contract drifted\")\n return values\n\n# --- Run profiles (config-first presets) -------------------------------------------------------\n# A `profile` is a NAMED BUNDLE of setting defaults — nothing more. It only fills fields the user\n# did NOT set explicitly (via file / --set / LOOPLAB_* env / init-kwargs), so any explicit knob\n# always wins (see `_apply_profile`). Most engine intelligence lives behind individual flags. The\n# PART IV/V cross-run + concept-graph machinery (concept_pivot, graded_novelty, cross_run_concepts /\n# _advisory / _structured_claims / _curation / _read_tools, fingerprint_universal, foresight_verify)\n# now ships ON in the product `Settings` default. That is an explicit experimental product choice,\n# not a claim that a frozen A/B or workload-wide cost/quality gate has cleared: the bundle can steer\n# prompts and candidate generation, change graded-novelty admission / pre-execution choices, and add\n# paid LLM work when its client and memory prerequisites are present. The bare-library `EngineOptions`\n# default stays lean (see tests/test_options_divergence.py — product side is the aggressive one) so a\n# toy `Engine(...)` in a test doesn't fire cross-run LLM work unasked. The trust/confirm/ablate QUALITY\n# bundle still ships OFF and is turned on by `thorough`. Every value here is reachable by hand — the\n# profile is a convenience, never a hidden mode.\n# keep this rationale behavioral and evidence-based; do not relabel read-only storage\n# access or proposal-only governance as a no-op for agent behavior, cost, or proposal admission.\n#\n# `thorough` deliberately touches only QUALITY/TRUST machinery, not spend: it does NOT raise\n# max_nodes / eval_parallel (those are cost knobs the user owns). It enables multi-seed confirmation\n# (demote seed-lucky leaders), the reward-hack / leakage / critic monitors AND flips them from\n# advisory to *gating* (`trust_gate=\"gate\"` — a\n# flagged win can no longer be selected as best), ablation-driven refinement, and the prompt cues\n# (complexity / budget / failure-reflection / watchdog-reflection). Agentic novelty and the experimental\n# Part IV/V bundle are already product defaults, not profile changes. Effect is large; marginal cost is a\n# few extra evals.\nPROFILES: dict[str, dict] = {\n \"default\": {}, # explicit no-op alias for \"ship-as-is\" (== omitting profile)\n \"fast\": {}, # today's lean defaults, named for symmetry with `thorough`\n \"thorough\": {\n \"confirm_top_k\": 3,\n \"confirm_seeds\": 3,\n # novelty_gate intentionally NOT enabled here: duplicate-detection is the agentic Researcher's\n # job (it reads past experiments + find_analogous_across_runs and DECIDES), not an algorithmic\n # param-distance / embedding auto-reject that mis-fired (it nudged a good fresh idea onto a\n # twice-dead lineage — live node 61). Search stays as tools; the LLM judges novelty.\n \"operator_bandit\": True, # P4: adaptive operator mix over folded yields (GreedyTree)\n \"reward_hack_detect\": True,\n \"code_leakage_detect\": True,\n \"critic_check\": True,\n \"trust_gate\": \"gate\", # detectors stop gating a flagged node from WINNING (not just audit)\n \"ablate_every\": 3,\n \"complexity_cue\": True,\n \"budget_aware\": True,\n \"failure_reflection\": True,\n \"watchdog_reflection\": True, # feed recent live-watchdog (train-monitor/ASHA) flags to proposals\n \"reflection_priors\": True, # no-op unless memory_dir is set (cross-run priors)\n },\n}\n\n\n# The shipped agent-governance matrix (who may change which setting at runtime). Defined as a module\n# constant so the Engine's direct-construction default matches this shipped default exactly (the\n# EngineOptions \"Engine() == shipped defaults\" invariant) instead of diverging to an all-locked map.\nDEFAULT_AGENT_CONTROL: dict[str, list[str]] = {\n \"timeout\": [\"researcher\", \"strategist\"],\n # Layer-2 canonical names are the presented/default governance surface. `_agent_may` falls back\n # to an old snapshot's legacy grant only when its canonical counterpart is absent, so removing\n # duplicate defaults preserves resume authority while an explicit canonical revocation still wins.\n \"eval_parallel\": [\"strategist\"],\n \"llm_parallel\": [\"strategist\"],\n # Strategy-only pseudo-setting: the closed per-lane allocation inside the canonical LLM total.\n # It is not a launch Settings field; operators pin it through `set_strategy` and the Strategist\n # may reallocate it only while this grant is present.\n \"llm_lane_limits\": [\"strategist\"],\n \"max_nodes\": [\"strategist\", \"boss\"],\n \"max_eval_seconds\": [\"strategist\", \"boss\"],\n \"policy\": [\"strategist\"],\n # The policy NAME and its PARAMS are gated independently in `_apply_strategy`; grant both by default\n # so the Strategist that may switch to (say) MCTS may also apply the `c`/`eta` it decided — else its\n # tuned params are silently dropped and the recorded `active_strategy` diverges from the live engine.\n \"policy_params\": [\"strategist\"],\n \"n_seeds\": [\"strategist\"],\n \"ablate_every\": [\"strategist\"],\n \"merge_mode\": [\"strategist\"],\n \"complexity_cue\": [\"strategist\"],\n \"ablate_code_blocks\": [\"strategist\"],\n \"prefer_sweep\": [\"strategist\"],\n \"novelty_stance\": [\"strategist\"],\n \"developer\": [\"strategist\"],\n \"fidelity\": [\"strategist\", \"researcher\"],\n}\n\n\ndef default_agent_control() -> dict[str, list[str]]:\n \"\"\"A FRESH deep-ish copy of the shipped governance matrix (each role list copied so a caller can\n mutate its own map without touching the module constant). The SINGLE constructor of the default,\n called by both `Settings.agent_control`'s default_factory AND the Engine's direct-construction\n default — so \"Engine() == shipped defaults\" can't drift if the copy depth ever needs to change\n (e.g. nested values requiring copy.deepcopy), instead of two verbatim copy expressions.\"\"\"\n return {k: list(v) for k, v in DEFAULT_AGENT_CONTROL.items()}\n\n\n# The closed set of `developer_backend` values: \"default\" (the in-house Developer) plus the external\n# coding-agent keys `agents/cli_agent.py::PRESETS` implements. It lives HERE, in the bottom layer,\n# because `Settings` validation needs it on every construction and `core` imports nothing above\n# itself — validating against the agents registry directly made this the ONE upward import out of\n# core in the tree, so an import-time error anywhere in agents/cli_agent.py broke ALL config\n# loading (doc 25 XP-04).\n#\n# The authority is not lost, it is inverted: `agents/cli_agent.py` asserts its PRESETS match this\n# set, and `tests/test_developer_backend_registry.py` checks BOTH directions. Adding a preset\n# without adding it here is a red test, not a backend that silently downgrades to the default.\nDEVELOPER_BACKENDS: tuple[str, ...] = (\"default\", \"aider\", \"continue\", \"goose\", \"opencode\")\n\n\ndef _parser_names() -> tuple[str, ...]:\n \"\"\"The structured-output parser vocabulary, read from the registry that actually resolves it.\n\n Deferred (not a module-level import) to keep `config` import-light, and sorted so the refusal\n message is stable rather than dependent on registry insertion order.\"\"\"\n from looplab.core.parse import _ORDER\n\n return tuple(sorted(_ORDER))\n\n\nclass Settings(BaseSettings):\n \"\"\"The engine settings schema (every knob a run accepts).\n\n Timeout family — six distinct knobs, each owned by a different subsystem:\n - `timeout`: per-eval wall-clock budget for ONE experiment's evaluation (engine/eval).\n - `max_eval_timeout`: hard ceiling for a governed per-node Researcher timeout override.\n - `llm_timeout`: LLM request idle timeout — inter-token stall limit in stream mode\n (core.llm OpenAICompatibleClient).\n - `llm_header_timeout`: LLM first-byte (response-headers) window for stream attempts (core.llm).\n - `agent_time_budget_s`: wall-clock ceiling across a tool-using agent loop's turns (agents).\n - `dep_install_timeout`: per-package budget for auto-installing a missing dep (env self-prep).\n\n Config-source precedence is owned by `looplab.core.appconfig` (the loader) — see its module\n docstring for the one canonical order.\n \"\"\"\n # Config precedence: see appconfig.py — the loader owns the full order. The `.env` is read\n # so `looplab run`/`looplab ui` pick up LOOPLAB_LLM_BASE_URL etc. without exporting them by hand\n # (keys MUST carry the LOOPLAB_ prefix, same as the env vars). utf-8-sig tolerates a BOM from\n # Windows editors. The test suite disables this (tests/conftest.py) so a dev's real .env in the\n # repo root can't leak into assertions.\n model_config = SettingsConfigDict(\n env_prefix=\"LOOPLAB_\",\n env_file=\".env\",\n env_file_encoding=\"utf-8-sig\",\n extra=\"ignore\",\n allow_inf_nan=False,\n )\n\n @classmethod\n def settings_customise_sources(\n cls, settings_cls, init_settings, env_settings, dotenv_settings,\n file_secret_settings):\n \"\"\"Preserve source precedence across canonical/legacy parallelism names.\n\n Pydantic ranks init > env > dotenv correctly, but it ranks field names rather than semantic\n alias families. Promoting a legacy value inside each source before the merge makes an old\n explicit snapshot/file value beat a lower-priority canonical env value. ``appconfig`` also\n promotes its file/typed/--set sub-layers before combining them into the init source.\n \"\"\"\n def _with_parallel_aliases(source):\n def _read():\n values = canonicalize_parallelism_source(source())\n # Credential components are a record, not independently mergeable settings. If a\n # winning source supplies only one half, insert an explicit None for its peer so a\n # lower-priority source can never complete an attacker-controlled mixed pair.\n pair = {\"llm_api_key\", \"llm_api_key_base_url\"}\n present = pair.intersection(values)\n if present and present != pair:\n values = dict(values)\n values[next(iter(pair - present))] = None\n return values\n return _read\n\n return (\n _with_parallel_aliases(init_settings),\n _with_parallel_aliases(env_settings),\n _with_parallel_aliases(dotenv_settings),\n _with_parallel_aliases(file_secret_settings),\n )\n\n # Run profile (config-first preset, see PROFILES above). \"default\"/\"fast\" = today's lean\n # defaults; \"thorough\" turns the built quality/trust machinery ON in one word. A profile only\n # fills fields the user did NOT set — every knob it touches stays individually overridable.\n profile: str = \"default\"\n\n # Bounds (review C/⚪): a non-positive node/seed budget or timeout is never valid. Upper bounds are\n # equally load-bearing — the UI /start path writes these straight into the engine's env, and an\n # unbounded `eval_parallel` (or legacy `max_parallel`; e.g. 100000 from a crafted preflight) would\n # fan out that many sandboxes → resource exhaustion. Reject at config time, not mid-run. Ceilings\n # are generous; tests top out near 50.\n # Pydantic's lax mode coerces a JSON boolean into any numeric field (`true` -> 1), so a UI/API type\n # error used to silently collapse a run budget to ONE node instead of returning 422. Rejected for\n # EVERY numeric field by `_reject_bool_for_numeric_fields` below, not just the ones bounded here.\n n_seeds: int = Field(default=3, ge=1, le=1024)\n max_nodes: int = Field(default=8, ge=1, le=1_000_000)\n # legacy evaluation-width input. `eval_parallel` below is canonical and wins when set;\n # keep this field for old env/config/snapshots and direct Engine call sites. A width > 1 admits\n # concurrent evals, whose resource reservations may be CPU-only or span one/more GPUs — there is no\n # one-node/one-GPU promise. `max_parallel=0` is launch-time AUTO (detected GPU count, at least 1).\n max_parallel: int = Field(default=1, ge=0, le=1024)\n # legacy build-width input. `llm_parallel` below is canonical and also controls the\n # shared provider-call total when positive. A width > 1 uses fresh pooled researcher/developer pairs;\n # it says nothing about the later eval's CPU/GPU reservation. Startup `0` derives build fan-out from\n # resolved eval width; a live Strategist/operator zero settles to 1. Without role_factory it clamps to 1.\n parallel_build: int = Field(default=1, ge=0, le=64)\n # Hypothesis-card Kanban re-architecture (docs/23, Layer 2). Two INDEPENDENT concurrency axes,\n # decoupled from each other so cost (LLM work) can be tuned separately from throughput (GPU evals).\n # These are the CANONICAL names going forward; `max_parallel`/`parallel_build` above are kept as thin\n # back-compat aliases (env vars / snapshots / ~100 Engine(...) call sites depend on the exact names,\n # so they cannot be renamed — CLAUDE.md). Resolution (orchestrator.__init__): a NEW name, when set,\n # WINS over its legacy alias; None => fall back to the legacy field => byte-identical to today.\n # `eval_parallel`: concurrent EVALS (the GPU/experiment consumer). None -> max_parallel; 0 = AUTO\n # (one experiment per detected GPU). This is the in-Run eval-concurrency ceiling; the resource\n # scheduler bin-packs declared footprints separately. Sibling local GPU-owning Runs serialize\n # through the conservative host-pool lease; external execution needs its own admission boundary.\n # `llm_parallel`: total concurrent LLM provider calls + BUILD fan-out. None -> legacy\n # parallel_build semantics without a shared total; 0 = startup AUTO (= resolved eval_parallel\n # for build fan-out, historical research overlap unbounded). A positive canonical value enables\n # the named-lane broker the Strategist can reallocate at cadence.\n eval_parallel: int | None = Field(default=None, ge=0, le=1024)\n llm_parallel: int | None = Field(default=None, ge=0, le=64)\n # Training-log monitor (I-series watchdog family): a per-eval background observer that tails the live\n # training log while a (often multi-hour) declared stage runs in a worker thread. ON in the product\n # surface (Settings). Its alert event is fold-ignored and cannot directly change lifecycle/champion;\n # with watchdog_reflection on, that alert is nevertheless advice in a later Researcher\n # prompt and can change future proposals. It only fires on the command-eval path with an LLM client;\n # bare-library EngineOptions stays OFF. See test_options_divergence.\n # `_interval_s` is the BASE tick cadence (the effective one adapts to the per-experiment budget).\n train_monitor: bool = True\n train_monitor_interval_s: float = Field(default=600.0, gt=0)\n # Phase 3 — INTERVENTION (opt-in, separate from observation): let the monitor tree-kill a training the\n # LLM judges 'broken' (diverged / silent CPU fallback / not learning) EARLY, instead of burning the\n # whole budget. Off by default — the observer only WATCHES unless this is on. A kill only fires on a\n # 'broken' verdict (a plateau is 'watch', never killed) with confidence >= the threshold; the node then\n # fails normally (reason='monitor_broken'), so replay reconstructs it from that one terminal event.\n train_monitor_kill: bool = False\n train_monitor_kill_confidence: float = Field(default=0.8, ge=0.0, le=1.0)\n # ASHA live-curve watchdog (sibling of the training monitor): reads the latest INTERMEDIATE value of\n # the objective metric off the live log (reusing the eval's OWN metric reader). Finished-endpoint rank\n # remains advisory. An opt-in KILL additionally requires an operator-declared metric.resource_key and\n # enough sibling observations at exactly that resource; absent comparable evidence cannot stop a run.\n # Records a fold-ignored `asha_rank` diagnostic + trace span; the library default is OFF (off == today).\n asha_live: bool = True\n # Opt-in INTERVENTION (separate from the advisory signal): let the watchdog tree-kill a node whose\n # intermediate metric stays below the SAME-RESOURCE bar past a short grace window. Off by default —\n # it only surfaces endpoint rank unless this is on. A kill fails normally (asha_underperforming).\n asha_live_kill: bool = False\n # The bar sits at `asha_live_quantile` along a WORST->BEST ordering of the finished siblings' finals:\n # 0.5 = the median (a node worse than the median peer flags); a SMALLER value is more conservative —\n # it lowers the bar toward the WORST finished peer, so only a node worse than nearly all peers flags\n # (0.0 = worse than the worst). `min_siblings` is the floor of finished peers required before it ranks\n # at all, so it never acts on too little evidence.\n asha_live_quantile: float = Field(default=0.5, ge=0.0, le=1.0)\n asha_live_min_siblings: int = Field(default=3, ge=1)\n timeout: float = Field(default=30.0, gt=0)\n # Hard ceiling for the Researcher's governed per-node `Idea.eval_timeout`. One hour preserves the\n # existing heavy/neural-net authoring example while staying well below the sandbox's defensive\n # 24-hour subprocess ceiling. This is operator-owned run config: agents may request less or more,\n # but the accepted action is always clamped here after the `agent_control.timeout` permission gate.\n max_eval_timeout: float = Field(default=3600.0, gt=0, le=24 * 3600.0)\n # Intra-node sweep: a sweep node runs a whole grid in one process, so it gets this multiple of\n # `timeout` as its wall-clock budget (solution.py path; RepoTasks use their per-profile timeout).\n sweep_timeout_mult: float = Field(default=8.0, ge=1)\n # Eval STALL watchdog CAP (seconds): a stage that is completely SILENT on stdout/stderr for this\n # long — while still alive and BELOW its wall-clock deadline — is tree-killed early with a STALLED\n # marker (a hung dataloader/deadlock dies in minutes instead of burning a multi-hour timeout). The\n # per-stage window is `min(this, the stage's own timeout)`, so a short scorer's deadline fires first.\n # `0` DISABLES the watchdog entirely (only the hard deadline applies) — for a legitimately quiet\n # non-Python stage (block-buffered stdout, a script that logs only to its own file). Threaded into\n # the eval and surfaced to the Developer so its code can emit periodic progress to stay alive.\n eval_stall_timeout_s: float = Field(default=1800.0, ge=0)\n # Sandbox tier (ADR-13): \"trusted_local\" (subprocess, no Docker) for the CLI;\n # \"untrusted\" (Docker --network none, shared-kernel runtime) for hosted/multi-tenant UI;\n # \"hostile\" (untrusted + a true-isolation OCI runtime, gVisor `runsc` by default / Kata) for\n # running untrusted code. See runtime/sandbox.py::make_sandbox for the tier selection.\n trust_mode: str = \"trusted_local\"\n # Image for the untrusted command-eval tier (RepoTask, Phase 4): the framework's deps\n # should be baked in (the container runs --network none, so a pip setup can't fetch).\n docker_image: str = \"python:3.12-slim\"\n # Resource caps for the untrusted/hostile Docker tier (passed to `docker run --memory/--cpus`).\n # The default 4g bound protects other tenants from an OOM, but a model-training MLE-bench eval\n # routinely needs more — raise it (e.g. \"16g\") for such runs. \"\" disables the cap (unbounded).\n # Ignored by the trusted_local (subprocess) tier.\n sandbox_memory: str = \"4g\"\n sandbox_cpus: str = \"\"\n # Best-effort host-OOM guard for the TRUSTED-LOCAL (subprocess) tier. When set (e.g. \"8g\"), each\n # eval child gets an RLIMIT_AS address-space cap so a runaway trainer hits MemoryError instead of\n # OOM-killing the whole host (and the engine). POSIX only; a no-op on Windows. Default \"\" = OFF:\n # RLIMIT_AS bounds VIRTUAL memory, and CUDA/torch reserve tens of GB of virtual without using it,\n # so a default cap would spuriously kill GPU evals — leave it off for those and use the Docker\n # tier's real --memory cgroup bound (sandbox_memory) instead. Ideal for CPU/numpy/sklearn runs.\n sandbox_memory_local: str = \"\"\n # Best-effort disk-fill guard for the TRUSTED-LOCAL tier (P1-5): an RLIMIT_FSIZE cap on the size of\n # any single file an eval child writes (e.g. \"2g\"), so a runaway that writes an unbounded file gets\n # SIGXFSZ instead of filling the host disk. POSIX only. Default \"\" = OFF (large model checkpoints\n # need big files); set it for tasks that write only small artifacts.\n sandbox_fsize_local: str = \"\"\n # Search policy (ADR-2): \"greedy\" | \"evolutionary\" | \"mcts\" | \"asha\" | \"bohb\".\n # `bohb` is a registered policy (`search/policy.py::_REGISTRY` aliases it to the ASHA factory,\n # which BOHB reuses as its racing half) and is advertised by `appconfig.render_template` and the\n # `bohb` template kind; this comment used to omit it and undersell the valid set. The value is\n # not validated at config time, but `make_policy` raises on an unknown name — loud-late rather\n # than silent, unlike `developer_backend` above.\n policy: str = \"greedy\"\n # Ablation-driven refinement (I7): every N improves, ablate the best to find the\n # highest-impact parameter and refine it. 0 = off. (GreedyTree only.)\n ablate_every: int = 0\n # A0a (MLE-STAR): when ablating, treat each generated pipeline *code block* (not just a\n # numeric param) as an ablation unit — neutralize a block, measure the metric delta, refine\n # the highest-impact block. Off => the classic numeric-param ablation. Config-first knob.\n ablate_code_blocks: bool = False\n # A0b: real merge/ensembling. \"mean\" = legacy mean-param merge; \"ensemble\" = the Developer\n # writes a code-recombination ensemble over the two parents' solutions (verified: agent-proposed\n # ensembling 37.9%->43.9%). \"auto\" (default) = ensemble whenever the Developer actually\n # generates code (LLM/agent backends; declared via `is_code_generating`), mean for the\n # templated/toy developers where a code ensemble is meaningless — the evidence says code\n # recombination is the strongest single merge (removing it costs ~9 pp), so it is the default\n # wherever it can work. Falls back to mean when the Developer can't ensemble.\n merge_mode: str = \"auto\"\n # A0d (AIRA): inject a dynamic complexity hint into the draft/improve prompt keyed on the\n # node's child count (few children -> keep minimal; many -> escalate to ensembling/HPO).\n complexity_cue: bool = False\n # I1 (CAAFE-style) LLM feature-engineering: instruct the proposer/developer to add\n # semantically-meaningful engineered features (ratios, interactions, aggregations) as code. The\n # existing cross-validation eval + best-selection is the CV GATE — a feature set that doesn't\n # improve CV is never selected (feature engineering is non-universal, so this gate is mandatory).\n # Tabular tasks (those exposing column data). Off by default.\n feature_engineering: bool = False\n # A5: surface the REMAINING eval-compute budget into the proposal prompt so the Researcher can\n # reason over how much compute is left (explore broad while flush, exploit/cheapen when low).\n # No-op unless a max_eval_seconds budget is set.\n budget_aware: bool = False\n # A4 (LATS-style): feed a summary of the most recent FAILED branches (operator + error reason)\n # back into the proposal prompt so the proposer reflects on and avoids repeating them. ON by\n # default: it is SELECTIVE by construction (injects only when recent failures exist — the\n # evidenced form of reflection; reflecting at every step shows no gains, arXiv:2506.12928).\n failure_reflection: bool = True\n # Feed the LIVE-WATCHDOG observations (train-monitor health verdicts + ASHA intermediate-rank flags)\n # of recent experiments back into the proposal prompt, so the Researcher avoids re-proposing a config\n # whose TRAINING was already observed to be weak — even when the watchdog kills are off (default) and\n # the node ran to completion (those diagnostics are fold-ignored, so failure_reflection never sees\n # them). ON by default and SELECTIVE like failure_reflection (injects only when a recent flag exists).\n watchdog_reflection: bool = True\n # C3 deep test-driven repair: hand the Developer the failure taxonomy + a structured \"reproduce\n # then fix\" directive on debug, not just the raw stderr tail. ON by default (product surface); the\n # conservative library default in EngineOptions stays off, per that module's contract.\n deep_repair: bool = True\n # === Inline crash repair ==============================================================\n # Hybrid in-node crash repair: when an LLM-generated node CRASHES at runtime (mechanical errors\n # — bad import, removed kwarg, typo), the agent triages it and may repair the code IN PLACE within\n # the same eval (cheap; does NOT consume max_nodes and does NOT add a node to the search tree).\n # Semantic failures still flow to a new idea/debug node via the policy. ON by default; set False\n # to restore the prior behavior (every crash waits for a budgeted inter-node debug node).\n inline_repair: bool = True\n # Max in-place repair attempts per node before it fails normally. Default 0 = UNLIMITED: the same\n # agent keeps fixing + re-evaluating IN THE SAME node until it produces a clean metric — no new\n # node just to retry. Runaway is prevented not by a count but by the anti-stuck guard\n # (`inline_repair_stuck_repeat`): when the SAME error signature repeats with no progress, or the\n # agent's crash-triage says \"abandon\", the node fails. Set a positive N to cap the retries instead.\n inline_repair_attempts: int = Field(default=0, ge=0)\n # Anti-stuck: abandon in-node repair when the SAME error recurs this many times in a row (no\n # progress). Keeps \"unlimited\" repair from looping forever on an unfixable error.\n inline_repair_stuck_repeat: int = Field(default=4, ge=2)\n # Which failure reasons (_failure_reason: crash|timeout|oom|setup|no_metric|drift) are eligible for\n # inline repair. Default: mechanical crashes, timeouts AND OOM-kills — a timeout/OOM means the code\n # was too slow / too memory-hungry for the budget (or the pod's cgroup limit), not that the idea is\n # wrong, so the agent repairs it by reducing compute/memory (fewer estimators/epochs/folds/seeds, a\n # lighter model, a smaller batch, subsampling) instead of letting the node die unrecovered. Env\n # override expects a JSON array.\n inline_repair_reasons: tuple[str, ...] = (\"crash\", \"timeout\", \"oom\")\n # Cost ceiling for in-node repair of a MULTI-STAGE eval: the max number of FULL pipeline re-runs\n # (i.e. re-trains from the first stage) the inline-repair loop may do before abandoning to the\n # inter-node debug operator. A late-stage failure (a broken `score`/eval script that didn't touch\n # `train`) REUSES the completed train checkpoint and re-runs only from the failed stage — that is\n # CHEAP and NOT counted here; only a repair that changes an EARLIER stage's code (train.py/loss.py/…)\n # forces a full re-train, which each costs the whole training time. Without this bound, a repair that\n # keeps changing training code could burn many full trains (the anti-stuck guard is error-signature\n # based, not cost based). 0 = unlimited (legacy behavior). Single-command evals ignore it.\n inline_repair_retrain_cap: int = Field(default=2, ge=0)\n # Environment self-prep: when a solution crashes purely because a KNOWN library isn't installed\n # (ModuleNotFoundError), the engine pip-installs it into the eval interpreter and re-runs — so a\n # torch/XGBoost/CatBoost idea (e.g. a GRU model) actually runs on a fresh box instead of being\n # thrown away as `idea_rejected`. Trusted_local tier only (the untrusted/hostile Docker tiers run\n # --network none). A typo'd or local helper module is NOT installed (that's a code bug to repair).\n auto_install_deps: bool = True\n # Per-package wall-clock budget for an auto-install (seconds). Generous: large wheels like torch\n # can take minutes to download/build.\n dep_install_timeout: float = Field(default=900.0, gt=0)\n # --- Agent governance: who may change a setting at runtime (per-setting allow-list of roles) ---\n # Roles: \"strategist\" (A7 meta-controller; run-wide), \"boss\" (run-chat operator-proxy; run-wide),\n # \"researcher\" (per-experiment proposer; PER-NODE — e.g. set a longer eval timeout for a neural-net\n # idea). A setting ABSENT from this map is LOCKED — no agent may change it, only a human via the\n # snapshot/UI. The sole conditional exception is Strategy-only `card_scoring`: Card mode grants\n # it implicitly without changing the default flag-off snapshot, while an explicit [] revokes it.\n # The gate is ENFORCED at every AGENT seam via `_agent_may` — the strategist's whole\n # control surface (policy/operators/novelty_stance/prefer_sweep/developer/fidelity/timeout/\n # eval_parallel/llm_parallel) is checked in `_apply_strategy`, so removing a role from a knob locks it,\n # not just greys out the UI pill. The boss/max_* grants document boss-relevant knobs, but a\n # `budget_extend` is a HUMAN control intent (the boss action-builder can only emit `add_nodes`), so\n # it is applied as-is — the human always wins via the UI/snapshot. Defaults are conservative: resource/\n # search-shape knobs the agents already reason about; provider infra (llm_model/base_url/api_key,\n # docker_image) stays locked. The UI shows S/B/R pills per setting. `timeout` granted to the\n # researcher IS the \"auto\" per-node mode — the researcher sizes each experiment's wall-clock\n # (Idea.eval_timeout); the config `timeout` is the fallback for nodes it doesn't size. Env override\n # expects a JSON object. (`fidelity`/`novelty_stance`/`prefer_sweep`/`developer` are governance keys\n # for the strategist's per-node dials — not 1:1 Settings fields, but gated the same way.) The default\n # lives in the module-level DEFAULT_AGENT_CONTROL so the Engine's direct-construction default matches.\n agent_control: dict[str, list[str]] = Field(default_factory=default_agent_control)\n # C1 (Agentless localization): for repo tasks, rank the source files most relevant to the most\n # recent failure (traceback + identifiers) and surface them in the proposal/repair prompt so the\n # Developer edits the right place. Off by default; repo tasks only.\n localize_faults: bool = False\n # A2 surrogate-guided proposal (BO-lite): fit a k-NN surrogate over (params->metric) and propose\n # by acquisition instead of random/hill-climb. Numeric-bounds tasks only; bootstraps via the\n # base Researcher. Off by default. `surrogate_explore` = UCB-style exploration weight.\n surrogate_proposer: bool = False\n surrogate_explore: float = 0.1\n # E2 researcher panel: generate K candidate ideas per proposal and keep the one ranked best by a\n # cheap empirical surrogate over the (params->metric) history (NOT an LLM-judge). 1 = off.\n researcher_panel: int = 1\n # A1 ASHA / successive-halving: reduction factor (keep top 1/eta per rung) and rung budget.\n asha_eta: int = Field(default=3, ge=2)\n # Rung-0 width for ASHA/BOHB (the wide base of cheap drafts). 0 = use n_seeds (default,\n # preserving prior behavior); set >0 to seed a wider/narrower base independent of n_seeds.\n asha_rung_nodes: int = Field(default=0, ge=0)\n # A6 proxy/predictive scoring: cheaply rank a candidate's potential from early-stage signals\n # and skip a full eval for the doomed bottom fraction. 0.0 = off (no candidate skipped).\n proxy_scoring: bool = False\n proxy_kill_fraction: float = Field(default=0.0, ge=0.0, le=0.9)\n # === Novelty / dedup ==================================================================\n # E1 novelty/dedup gate: reject near-duplicate proposals (normalized param-space distance below\n # `novelty_epsilon`) by deterministically nudging them off the duplicate — stops the search\n # wasting evals re-trying the same idea. Audit event `novelty_rejected`. Off by default.\n # Novelty/dedup MODE selector: how a fresh proposal is checked against already-tried experiments.\n # \"off\" — no explicit gate; the agentic Researcher's own read-the-history judgment is trusted.\n # \"algo\" — the deterministic gate: numeric param-distance (`novelty_epsilon`) + optional embedding\n # similarity (`novelty_semantic`). Cheap, no LLM call, but can't explain itself.\n # \"llm\" — an LLM adjudicates (reads the real experiments via tools) whether the idea is a\n # near-duplicate and, if so, asks the Researcher once more for something different. One\n # extra LLM call per proposal — highest quality, follows the \"let the LLM decide\" line.\n novelty_mode: str = \"llm\"\n # Legacy sub-toggles, honored by the \"algo\" mode (and back-compat: novelty_gate=True forces \"algo\"):\n novelty_gate: bool = False\n novelty_epsilon: float = 0.05\n # T5 semantic novelty (active whenever the deterministic gate runs — novelty_mode=algo,\n # novelty_gate=true which is the legacy alias for algo, OR the Strategist novelty stance is\n # \"explore\"; a no-op only under novelty_mode=llm/off with a non-explore stance): reject a proposal\n # whose idea TEXT embeds within\n # `novelty_semantic_threshold` cosine of an existing node's. OFF BY DEFAULT: novelty is the agentic\n # Researcher's call, made by READING the actual prior experiments (read_experiment /\n # find_analogous_across_runs / list_experiments), NOT an embedding-similarity auto-reject that can't\n # explain itself and mis-fires on paraphrases. Semantic/param search stays available as TOOLS in the\n # Researcher's context; the LLM decides whether an idea is a true duplicate.\n novelty_semantic: bool = False\n novelty_semantic_threshold: float = Field(default=0.92, ge=0.5, le=1.0)\n # T10: debug-lineage depth bound for every policy — how many error-feedback repairs a failing\n # lineage gets before it is abandoned. The old default (1) abandoned lineages after ONE repair;\n # multi-turn/deeper debugging is a verified lever (AIRA2 ReAct debug +5.5 percentile points).\n debug_depth: int = Field(default=2, ge=1)\n # P4 operator bandit (GreedyTree): replace the fixed merge/ablate cadences with a\n # deterministic UCB over per-operator yield (Δmetric per eval-second, folded from the run).\n # Off by default (the cadences are well-tested and the bandit has no direct published\n # ablation); `thorough` turns it on.\n operator_bandit: bool = False\n # P1 hypothesis ledger: ask the Researcher to state the one-line hypothesis each experiment tests,\n # register deep-research directions as hypotheses, and track them to a verdict on the board. ON by\n # default. The board is shown to the Researcher and can be foresight-ordered, so it can\n # steer later proposals even though it never directly re-ranks already-evaluated nodes. Set False\n # to drop the prompt nudge + registration.\n track_hypotheses: bool = True\n # E4/M2/M3 reflection-memory -> priors (gradient-free cross-run meta-learning): at run end distill\n # the winner into `<memory_dir>/meta_notes.jsonl` AND structured lessons (incl. NEGATIVE results:\n # tested/abandoned hypotheses + failure themes) into `lessons.jsonl` with a task fingerprint; at run\n # start, inject exact-task notes + fingerprint-matched lessons from SIMILAR tasks into the proposal\n # prompt. ON by default — but a NO-OP until `memory_dir` is set (that's where cross-run memory lives).\n reflection_priors: bool = True\n # M6 comparative lessons (MARS \"comparative reflective memory\", doc 13 §7 item 2): distill\n # credit-assigned lessons from PAIRS of solutions — which SPECIFIC change made a child beat\n # (or regress from) its parent, and what fixed a failure — on top of the one-shot run-end\n # reflection. At most one extra LLM call per distillation (all pairs batched); offline a\n # deterministic param-diff credit stands in. Gated on reflection_priors + memory_dir like the\n # rest of cross-run memory — and since memory_dir has a default (~/.looplab/memory), this is\n # ACTIVE out of the box; clear memory_dir to disable cross-run memory wholesale.\n comparative_lessons: bool = True\n # M6 mid-run cadences (doc 13 §7 item 5 — the AgentRxiv live-share pattern): every N created\n # nodes, WRITE comparative lessons to the shared cross-run store during the run (lessons_every\n # — one batched LLM call per firing) and RE-READ the store so lessons distilled by CONCURRENT\n # runs reach this run's proposals (lessons_refresh_every; file re-read, no LLM call, skipped\n # when the store is unchanged). ON by default (every 4 nodes) and, like comparative_lessons\n # above, live out of the box because memory_dir defaults on; 0 = off — run-end write /\n # run-start read only, the pre-M6 behavior.\n lessons_every: int = Field(default=4, ge=0)\n lessons_refresh_every: int = Field(default=4, ge=0)\n # B3 output redaction: mask credentials (known key shapes + high-entropy tokens) in the\n # stdout/stderr tail before it is persisted to the event log / spans / UI — a leaked secret in a\n # print()/traceback must not land in the durable log. Off by default; recommend on for untrusted.\n redact_output: bool = False\n # B5 reward-hacking detector: a host-side monitor that flags suspicious wins (grader/answer-key\n # access, runtime writes to frozen files, suspiciously-perfect metrics) as a `reward_hack_suspected`\n # audit event in the Trust panel. Off by default. Whether a flag CHANGES selection is governed by\n # `trust_gate` below (default: audit-only, never changes selection).\n reward_hack_detect: bool = False\n # Trust enforcement (T2): what a reward-hack / data-leakage flag actually DOES to selection.\n # \"audit\" (default) = surface it in the Trust panel, never change selection (today's behavior);\n # \"gate\" = a flagged node can no longer be selected as BEST **and isn't bred/confirmed from**\n # (breed_excluded — see replay._apply_trust_gate / models.breedable_nodes), but stays\n # FEASIBLE so it still counts for diversity/audit — closes the \"a hacked/leaky node can\n # win OR seed its lineage\" hole;\n # \"block\" = additionally mark it fully INFEASIBLE (removed from feasible_nodes entirely).\n # Deliberately gates only high-precision CHEATING/LEAKAGE signals. Broad `critic:*` findings stay\n # advisory; `critic:hardcoded_metric` is the one narrow high-precision critic exception\n # and gates because it requires a literal metric with no computed assignment anywhere.\n trust_gate: str = \"audit\"\n # I3 data-centric: static code-leakage scan of each evaluated solution (fit-before-split,\n # fit-on-test) surfaced into the Trust panel alongside reward-hack flags. Off by default.\n code_leakage_detect: bool = False\n # 4.4 sandbox instrumentation (needs reward_hack_detect): after each eval, flag a RUNTIME write\n # to a protected/frozen file (an answer key or grader tampered mid-run) — behavioral evidence a\n # static code scan misses. ON by default; only fires when the reward-hack detector is on.\n workdir_audit: bool = True\n # D8 decoupled research Verifier: check every Deep-Research memo's CLAIMS against their cited\n # evidence (node ids / urls) before recording — synthesis is the documented weak link (Kosmos\n # 57.9% accurate). Deterministic layer always; an LLM rubric pass when the DeepResearcher has a\n # client. The verdict cannot change this run's node/champion, but finalize uses aligned supported\n # verdicts as the evidence gate for persisted D8 cross-run claims; unavailable/misaligned stays\n # unverified. ON by default (no-op with no memo/claims).\n research_verify: bool = True\n # C4 independent critic: an execution-free critic of each solution (stub / hardcoded-metric /\n # params-ignored; on host-graded tasks the metric checks become a submission-output check)\n # surfaced in the Trust panel. Broad findings are advisory; `critic:hardcoded_metric` can gate under\n # trust_gate=gate/block. Off by default.\n critic_check: bool = False\n # A7 Strategist (NEW, user-requested): optional LLM/rule meta-controller that picks the search\n # policy/allocator + operator mix + fidelity (+ Developer backend) per situation. Config-first:\n # every choice the Strategist makes is also a direct knob. Defaults to \"agent\" so the agent adapts\n # the search policy/operators/fidelity per situation; set \"off\" for static config-driven search.\n # \"agent\" = a tool-using Strategist that READS the run/data/sibling-runs/KB/memory (B1-guarded)\n # before deciding, instead of the single-shot \"llm\" call over aggregate stats.\n strategist_backend: str = \"agent\" # \"off\" | \"rule\" | \"llm\" | \"agent\" — default AGENTIC: the\n # Strategist READS the run/data/sibling-runs/KB/memory with tools before deciding, not a single-shot\n # call over aggregate stats. \"llm\" = the old non-agentic single-shot; \"rule\"/\"off\" = no LLM.\n strategist_every: int = Field(default=3, ge=1) # consult cadence (created nodes)\n # PART V (F1): concept CLASSIFIER re-tag + consolidation cadence, DECOUPLED from strategist_every. The\n # LLM concept map is heavier and slower-moving than a strategy consult, so it refreshes on its own\n # (sparser) interval. Researcher-authored idea.concepts still fold immediately at node_created — this\n # only paces the classifier-evidence + consolidation refresh (and the concept-coverage pivot snapshot).\n concept_retag_every: int = Field(default=30, ge=1)\n # === Confirmation & holdout ===========================================================\n # Multi-seed confirmation (I12, ADR-15): confirm the top-k under N seeds before\n # finishing. 0 disables (default). Only meaningful when eval has variance.\n confirm_top_k: int = 0\n confirm_seeds: int = 0\n # D1 seed-holdout discipline: the FIRST seed the confirm phase uses. Search evals run with the\n # implicit seed 0 (LOOPLAB_EVAL_SEED unset -> \"0\"), so a base of 1 keeps every confirm split\n # DISJOINT from anything the search optimized against — the confirm metric is then a\n # generalization signal, not a re-measurement (AIRA: selecting on a signal the search saw\n # overfits by 9-13 pp). Set 0 to restore the legacy overlapping seeds 0..N-1.\n confirm_seed_base: int = Field(default=1, ge=0)\n # D1 holdout-gated promotion (B6, Arbor-style): for host-graded tasks, reserve this fraction of\n # the held-out labels as a FINAL holdout partition the search never sees — every search/confirm\n # eval is scored on the remaining rows only, and at finish the val-top-k are re-scored on the\n # holdout partition (free: the predictions already exist). 0.0 = off (legacy full-label scoring).\n holdout_fraction: float = Field(default=0.25, ge=0.0, le=0.9)\n # Champion selection prefers the HOLDOUT metric among the nodes that have one (the val-top-k).\n # Recorded in run_started so replay applies the same rule; old logs fold to False (legacy pick).\n # The per-node val-holdout `generalization_gap` folds into the Trust panel either way.\n holdout_select: bool = True\n # How many val-leaders get a holdout evaluation at finish (host-graded tasks).\n holdout_top_k: int = Field(default=3, ge=1)\n # R1-c / Part IV — calibrated §12-verifier tie-break in best-selection. When a fresh node's robust\n # metric EXACTLY TIES an already-eligible node, the engine runs the §12 verifier (grounded on each\n # node's realized result, `selection_criteria`) and records a soundness score per node. The tie-break\n # then applies across the WHOLE selection path: the mean pick breaks a robust_metric tie by the score,\n # and — when `holdout_select` is on — the holdout-slot ranking prefers the sounder node so it isn't\n # denied a holdout eval, and the final holdout override breaks a holdout_metric tie by it too. Strictly\n # a TIE-BREAK — it can NEVER move a node ahead of a strictly-better metric (§21.7 advisory-never-\n # overrides). Best-effort: verification is LAZY (only fires on an actual tie) and bounded per cadence,\n # so a large/late tie stays on deterministic metric+id order unless one atomic treatment scores every\n # member. Recorded in run_started so\n # replay applies the same rule; old logs fold to False (legacy pick). Opt-in (default off); needs a\n # reachable LLM client. Enable only after validating calibration offline with `verifier.calibrate`.\n # See trust/verifier.py + core/fitness.py.\n select_verifier: bool = False\n # R1-d (§21.19): widen the verifier tie-break from EXACT-metric to a conservative STATISTICAL tie.\n # The band is capped by both the metric leader's SE and the pooled SE-of-difference, so challenger\n # variance cannot widen it and a significant difference is never a tie (§21.7). Off -> exact only.\n verifier_ci_tie: bool = False\n # Verifier sample count for `select_verifier` (the §12 repeated-sampling expectation). 3 tames the\n # single-shot variance; 1 = single-shot (cheaper, noisier).\n select_verifier_samples: int = Field(default=3, ge=1, le=32)\n # Budget (I13): hard wall-clock ceiling; the run aborts cleanly when exceeded.\n max_seconds: float | None = None\n # Eval-compute budget (#2): hard ceiling on cumulative time spent INSIDE evals (training\n # runs), separate from wall-clock. Survives resume (summed from the event log). The real\n # guard against a silent multi-hour sweep when an eval is a minutes-hours training run.\n max_eval_seconds: float | None = None\n # Cross-run memory (I19, ADR-10): if set, the best result of each run is stored as\n # a case here, and the cases become retrievable knowledge for future runs.\n memory_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"memory\"))\n # HITL (I21, ADR-11): pause for human approval of the final best before finishing.\n require_approval: bool = False\n # Diversity archive (I22): niche bucket width in parameter space.\n archive_resolution: float = 1.0\n # Coverage read-model (narrowing signal): at the strategist cadence, compute a breadth summary\n # (themes / param-niches / theme entropy / dominant-theme fraction) from the folded run, record\n # it as a `coverage_snapshot` audit event, and surface it into the Strategist's decision context.\n # Deterministic, cheap, and purely additive context — no search-behavior change on its own. On by\n # default (like the rest of the always-on situational context). See search/coverage.py.\n coverage_context: bool = True\n # PART IV Phase 2a live steering (§21.11/§21.13). When on, the `concept_retag_every` cadence (NOT\n # `strategist_every` — the producer gates on `_should_consult_concepts`, which uses the seed boundary\n # + `concept_retag_every`, default 30) records a\n # concept-graph coverage + uncovered-region snapshot (deterministic heuristic tagger over the\n # task-type skeleton) and, on an `explore` stance, the Researcher's novelty hint names the exact\n # uncovered regions (\"0 coverage in {negatives/external-mining, distillation} — go there\") instead\n # of the vague \"broaden\". ON by default in the product Settings (ce4a379), EngineOptions off: it\n # only enriches an already-mode-gated explore hint, never forces exploration, and no-ops for a task\n # with no curated concept skeleton. The snapshot is auditable, but its prompt cue can change future\n # candidate generation; it does not directly rewrite best-metric selection. See search/concept_graph.py.\n # DESIGN NOTE (2026-07-17 critique): now ON unconditionally, but a toy / non-ML task yields EMPTY concept\n # tags while still paying the per-node LLM concept cost (~10 calls/node with the full Part IV/V bundle).\n # Consider AUTO-QUIESCING: if the heuristic tagger returns empty for the first N nodes (no concept signal),\n # skip the LLM concept work for the run — keeps it on for ML tasks, free on toy/degenerate ones.\n concept_pivot: bool = True\n # PART IV Phase 2b — D3 graded novelty into the LIVE novelty gate (§21.4/§21.13). When on and the\n # task has a curated concept skeleton, the novelty gate first grades a proposal over the concept\n # graph: a level-4 \"same direction, DIFFERENT implementation\" or a level-5 \"re-opens a\n # wrongly-abandoned FAILED direction\" proposal is ALLOWED through unchanged — the flat LLM/semantic\n # dedup gate can't tell \"this DCL tweak\" from \"the whole DCL branch\" and would wrongly reject a\n # legitimate variant or never re-open a sound-but-killed direction (the node_63 archetype). Levels\n # 1/2/3 (identical / near-dup / prior-run) still fall through to the existing gate. Deterministic\n # (heuristic tagger, no LLM), audit event `novelty_graded`, replay-safe. ON by default in the product\n # Settings (ce4a379), EngineOptions off; no-ops for a task with no skeleton. See search/graded_novelty.py.\n graded_novelty: bool = True\n # PART IV Phase 2b — D7 capability-expansion forced-jump DIRECTIVE (§21.8/§21.13, issue #7). When on\n # and the concept-graph cadence detects action-space LOCK-IN (the search has stayed inside one D5\n # branch for a long consecutive streak) on an `explore` stance, the Researcher's novelty hint\n # ESCALATES from \"broaden\" to \"expand the ACTION SPACE — build the missing infra (external mining,\n # a new data pipeline, a different eval), do NOT swap another {locked} lever.\" The same\n # gate also stamps the proposal's authoritative operator as `expand`; that node competes normally\n # under SearchFitness and its separate operator yield is measured. It does not force a winner.\n # Reads the 2a concept-coverage snapshot, so it needs `concept_pivot` to record one. OPT-IN\n # (default off). See engine/proposal_cues.py + search/lock_in.py.\n capability_expansion: bool = False\n # PART IV cross-run Step 0 (§21.20.12/§21.20.13). Universal task-fingerprint tokenization: drop the\n # ASCII-only `[a-z0-9]` allowlist on goal keywords so a non-Latin goal (Russian, CJK, …) is NOT\n # silently dropped from its cross-run fingerprint (today such a goal keys only on kind/dir/metric and\n # never reaches SIMILAR-task priors/lessons/cases). Uses `[^\\W_]+`/`.casefold()` — same splitting as\n # before, any script. ON by default in the product Settings (ce4a379); the bare-library EngineOptions\n # default stays off, so a run pinned to it won't silently re-key a portfolio mid-flight. See engine/memory.py.\n fingerprint_universal: bool = True\n # PART IV cross-run Step 2 (§21.20). Fill graded-novelty's `prior_concepts` from the cross-run\n # ConceptCapsuleStore: at run end write a per-run concept capsule (the shipped `node_concepts` tags +\n # best outcome, keyed by task_fingerprint) to `memory_dir`; when a later SIMILAR run proposes an idea\n # whose concept was tried before, SURFACE the prior outcome as a `cross_run_prior` audit event (never\n # reject — D3 level 3). Audit-only, off the selection path; ON by default in the product Settings\n # (ce4a379), EngineOptions off. Needs a `memory_dir` to share capsules.\n # See engine/memory.py (ConceptCapsuleStore) + engine/novelty.py.\n # PREREQUISITES (this flag is NOT standalone): the WRITE needs per-run concept tags\n # (`node_concepts`, produced by `concept_pivot`/F1 tagging) — no tags => no capsule; the SURFACE path\n # runs inside the graded-novelty precheck, so it also needs `graded_novelty` on. With only this flag +\n # `memory_dir` it persists/surfaces nothing. Enable `concept_pivot` + `graded_novelty` alongside it.\n # A non-Latin (Russian/CJK) portfolio should ALSO set `fingerprint_universal`, else the capsule's goal\n # tokens are dropped and its fingerprint over-matches on kind/dir/metric alone (the novelty direction\n # gate narrows this, but goal-word similarity is still lost).\n cross_run_concepts: bool = True\n # PART V (B): run-base + node-DELTA concept authoring. When on, once the first evaluated node has\n # authored concepts the engine seeds RunState.run_base_concepts from them (a one-shot EV_RUN_CONCEPTS,\n # idempotent because it only fires while the base is empty), and the Researcher's proposal hint tells it\n # to author only `concepts_added`/`concepts_removed` vs the run base + its parent — minimal per-node\n # annotations that inherit down the DAG (a swap propagates). Off -> every node authors its full concept\n # set as before. Product-on; the bare EngineOptions mirror defaults off (test_options_divergence).\n concept_run_base: bool = True\n # PART IV cross-run Step 5 advisory (§21.20.5). Fold the bounded cross-run CONTEXT PACK — evidence-\n # grounded claims with BOTH support and counter-evidence (Step 4) + a portfolio-coverage line (Step 3) —\n # into the Researcher's proposal prompt, exactly like the E4 cross-run prior note. Advisory ONLY: it is\n # prompt-grounding: it can change the Researcher's later candidates, but does not directly rewrite\n # best-metric selection (§21.7). Reads `memory_dir` (lessons.jsonl + concept_capsules.jsonl); \"\" when\n # empty. ON by explicit experimental product choice; no frozen A/B validation is claimed. The\n # bare-library EngineOptions default stays off (engine/options.py, frozen in\n # test_options_divergence). See engine/proposal_cues.py.\n cross_run_advisory: bool = True\n # PART IV cross-run §21.20.13 (full CR of the lean fuzzy claim merge). Switch the claim read-model to the\n # SCOPE+POLARITY-safe STRUCTURED claim key (engine/claim_key.py): claims from different tasks never\n # merge, opposite polarity (\"X helps\" vs \"X never helps\") is surfaced as a CONTRADICTION instead of being\n # collapsed, paraphrase/inflection variants group by exact structured key (no transitive over-merge), and\n # operator governance is scope-precise (a decision in task A cannot reach a same-worded claim in task B).\n # Affects the `cross_run_advisory` context pack only; ON by default in the product Settings (ce4a379);\n # the bare-library EngineOptions default stays off (engine/options.py). See engine/claims.py.\n cross_run_structured_claims: bool = True\n # PART IV cross-run §22.4 (AGENTIC taxonomy steward). At finalize, when an LLM client is available, let\n # the concept steward (engine/concept_steward.py) review the freshly-updated portfolio concept graph and\n # PROPOSE a curation — merge duplicate slugs / split conflated ones / purge noise. Proposals are LOGGED to\n # `concept_curation_log.jsonl` for operator ratification (via the serve/CLI governance surface); the LLM\n # never mutates fold state. The calls run synchronously during finalize, so they add latency and may add\n # paid inference; a caught steward failure does not prevent terminal completion or trigger a retry.\n # ON by explicit experimental product choice when `memory_dir` + an LLM backend are present; the\n # bare-library EngineOptions default stays off (engine/options.py). See engine/lessons.py.\n cross_run_curation: bool = True\n # Deprecated compatibility flag. Steward output is untrusted and finalize is proposal-only regardless\n # of this value; retained so old snapshots still validate and logs can disclose that auto was requested.\n cross_run_curation_auto: bool = False\n # Role backend (ADR-7/14): \"toy\" (offline optimizer) | \"llm\" (live model). Validated in\n # `_check_enum_fields` alongside the other enum-ish strings — every consumer tests `== \"llm\"`\n # exactly (cli/__init__.py, adapters/tasks.py), so an untyped `--set`/file/env typo (or a mis-cased\n # \"LLM\") used to fall through to the OFFLINE toy roles and quietly produce a run that never called\n # the model. Left as `str`, not `Literal`, so old snapshots still validate through the same path.\n backend: str = \"toy\"\n # Developer backend (ADR-7): \"default\" (templated/LLM from the task) or an external\n # CLI coding agent: \"opencode\" | \"aider\" | \"goose\" | \"continue\". A CLOSED enum (\"default\" +\n # cli_agent.PRESETS keys), validated loudly in `_check_enum_fields` — an unknown value used to reach\n # adapters/tasks.py (`developer_backend not in PRESETS`) and silently wire the DEFAULT in-house\n # developer, the same silent downgrade the `backend` guard closes.\n developer_backend: str = \"default\"\n # C2 best-of-N: generate N candidate implementations per node and keep the best by an\n # execution-free reward (static validity + metric-print). 1 = off. In-house LLM developer only\n # (not external agents — ADR-7 cost rule). The top SWE-bench reliability lever for weak models.\n best_of_n: int = 1\n # D10 list-wise best-of-N selection: when the top static-scorers TIE, break the tie with a\n # comparative LLM selection over the candidates presented together (+~3 pts vs pointwise on\n # GAIA, arXiv:2506.12928). The static score stays the primary filter — the LLM is a weak\n # comparative prior, never the sole oracle. ON by default (no-op when best_of_n==1).\n best_of_n_listwise: bool = True\n # === Foresight / predict-before-execute ===============================================\n # FOREAGENT predict-before-execute (arXiv:2601.05930): use the LLM as an IMPLICIT WORLD MODEL to\n # predict — WITHOUT executing — which candidate / hypothesis will score best, primed with a\n # Verified Data Analysis Report (data profile / brief) + the accumulated experiment memory. Master\n # switch, ON by default; a no-op wherever there is nothing to rank (best_of_n==1 and\n # foresight_panel==1) or no LLM client. See `looplab/search/foresight.py`.\n foresight: bool = True\n # Hypothesis foresight breadth: generate K candidate ideas per proposal and keep the one PREDICTED\n # best pre-execution by the world model — it ranks the STRUCTURAL / text ideas the numeric k-NN\n # surrogate (researcher_panel) is blind to, primed with the data profile + memory (the synergy).\n # >1 enables it (LLM backend only; needs `foresight` on); 2 = on by default at modest cost, 1 = off.\n foresight_panel: int = 2\n # AGENTIC foresight: run the ranking (hypothesis-board prioritization + K-idea pick) as a TOOL-USING\n # loop that can pull actual experiment results / data facts before deciding, instead of a one-shot\n # prediction from a pre-baked report. ON by default (needs foresight + a client; falls back to the\n # one-shot predictor on any hiccup). A few extra LLM calls per proposal; set False for the cheaper\n # single-call ranker.\n foresight_agentic: bool = True\n # §1 foresight confidence gate: the minimum predicted confidence at which a predict-before-execute\n # pick is ACTED ON. Below it the ranker abstains — the K-idea panel falls back to the first\n # proposal, best-of-N falls through to the D10 tie-break — instead of committing a low-confidence\n # choice. 0.0 (default) = OFF (act on every pick, the historical behavior); raise toward ~0.5 to\n # make the world model defer when it isn't sure. Pairs with the foresight track record (§1) the\n # predictor is now primed with. Range 0.0-1.0.\n # BOUNDED like its confidence/fraction siblings (train_monitor_kill_confidence,\n # asha_live_quantile). Unbounded, a typo'd 5.0 validated silently into a gate no score can ever\n # clear, so every foresight pick abstained forever with nothing in the snapshot to say why.\n foresight_min_confidence: float = Field(default=0.0, ge=0.0, le=1.0)\n # PART IV Phase 2c — replace the world model's SELF-REPORTED confidence (measured Pearson≈0 with the\n # realized outcome, §21.12) with a CALIBRATED §12-verifier score. When on and a client is available,\n # after the K-idea ranker picks the predicted-best candidate the §12 verifier scores it (grounded +\n # repeated + criteria-decomposed: `foresight_criteria` — likely to improve the objective, and\n # sound/feasible) and THAT calibrated score becomes the confidence the `foresight_min_confidence` gate\n # and the telemetry use. Degrades to the self-reported confidence without a client or on any verifier\n # error (the telemetry records which source was used). ON by default in the product Settings (ce4a379),\n # EngineOptions off; a few extra LLM calls per acted-on proposal. See looplab/trust/verifier.py + search/foresight.py.\n # REVISIT (cost/quality, not yet resolved): a live classification run measured ~10 LLM calls & ~3.8 min/node\n # on deepseek-v4-flash with the full Part IV/V default bundle; the calibrated verifier is part of that per-node\n # tax. Kept ON deliberately — it raises decision quality (self-reported foresight confidence has ≈0 Pearson\n # with realized outcome), and lowering quality to save calls is the wrong trade. TODO: run a frozen quality/cost\n # A/B (verifier-on vs -off) to quantify the recall@objective gain per extra call before considering a default flip.\n foresight_verify: bool = True\n # Verifier sample count for `foresight_verify` (the §12 repeated-sampling expectation on a no-logprob\n # backend). 3 tames the single-shot variance §21.12 measured; 1 = single-shot (cheaper, noisier).\n # RESUME-critical: this value is persisted in config.snapshot.json, so a hard `le` schema bound would\n # make an old run started with a larger LOOPLAB_FORESIGHT_VERIFY_SAMPLES un-resumable (Settings(**snapshot)\n # would raise on `looplab resume` / finalize-recovery / serve replay). CLAMP into [1, MAX] via a validator\n # instead of rejecting — the runtime cap is preserved without breaking the snapshots/env-compat rule.\n foresight_verify_samples: int = 3\n\n @field_validator(\"foresight_verify_samples\")\n @classmethod\n def _clamp_foresight_verify_samples(cls, v: int) -> int:\n # Coerce a persisted/env value into [1, MAX] rather than reject (see field note above).\n try:\n v = int(v)\n except (TypeError, ValueError):\n return 3\n return max(1, min(v, MAX_FORESIGHT_VERIFY_SAMPLES))\n\n # T7 LLM response cache: serve an identical DETERMINISTIC (temperature 0) request from an\n # in-process content-addressed cache instead of re-hitting the model — cuts cost on\n # retry/panel/verify flows. NEVER caches sampling calls (temp>0: best-of-N / panel / novelty\n # re-propose must vary). Off by default (most role calls run at temperature>0 anyway).\n llm_cache: bool = False\n agent_cmd: str | None = None # override the agent's launcher (path / wrapper)\n # External-agent validation (ADR-7): wrap a CLI-agent Developer with a validator that\n # audits each output (no-op/syntax/crash/timeout), retries with feedback, and falls\n # back to the in-house LLM Developer. Off -> use the raw agent output unchecked.\n validate_agent: bool = True\n agent_max_retries: int = 1 # re-prompts of the agent on an invalid result\n # Patch-gated multi-file agent (ADR-7 Rule 3): run the CLI agent in a git worktree and\n # accept only edits whose paths match `agent_surface` (reject-not-strip out-of-surface\n # touches). Lets the agent create helper modules, not just solution.py. Degrades to\n # whole-file readback if git is unavailable.\n agent_patch_gate: bool = True\n agent_surface: list[str] = [\"*.py\"] # edit-surface allow-list (globs)\n # Trust policy for an agent-authored eval/metric adapter (RepoTask onboarding, Phase 3):\n # \"ratify_freeze\" waits for human approval, \"autonomous\" accepts the proposed spec without that\n # gate, and \"ratify_freeze_drift\" also enforces the configured independent drift cross-check.\n eval_trust_mode: str = \"ratify_freeze\"\n # RepoTask node seeding policy (run-wide fallback; a task/editable `seed_mode` overrides):\n # \"auto\" (default) copies git-tracked files when the editable is a git repo (so a tree bloated\n # with untracked artifacts isn't deep-copied per node), else a full copy; \"tracked\" forces\n # code-only; \"all\" forces a full recursive copy (legacy). Genesis authors per-task from the\n # user's words; this is the global default when unspecified.\n seed_mode: str = \"auto\"\n llm_model: str = \"qwen3:8b\"\n # === LLM / transport ==================================================================\n llm_base_url: str = \"http://localhost:11434/v1\" # Ollama OpenAI-compatible endpoint\n llm_temperature: float = 0.6\n # H3 per-role model presets (5090 recipe): optionally run the Researcher and Developer on\n # DIFFERENT models/endpoints (e.g. Developer=Qwen3-Coder-30B for code, a fast model for breadth).\n # Blank = use the shared llm_model/llm_base_url. Generalizes the role-backend swap to per-role.\n researcher_model: str | None = None\n developer_model: str | None = None\n researcher_base_url: str | None = None\n developer_base_url: str | None = None\n # Per-role sampling temperature (§4.1): the Researcher wants breadth (higher temp = more diverse\n # ideas), the Developer wants determinism (lower temp = fewer syntax slips), the Strategist a\n # steady hand. Each overrides the shared `llm_temperature` for that role only; None = use the\n # shared value (byte-identical to before). Flat + nullable, mirroring the per-role model fields.\n researcher_temperature: float | None = None\n developer_temperature: float | None = None\n strategist_temperature: float | None = None\n # The Strategist had per-role TEMPERATURE but no model/endpoint of its own, so in split mode it\n # always ran on the shared llm_model however the other roles were pointed. These close that gap\n # with the same names/shape as the pair above; blank = its profile, else the shared value.\n strategist_model: str | None = None\n strategist_base_url: str | None = None\n # === Connection profiles (multi-provider) =============================================\n # A \"profile\" is a NAMED connection: {model, base_url, temperature, api_key_env, provider}. Roles\n # point at one by name through `role_profiles`; `llm_profile` is the default for every role that\n # doesn't. All three are EMPTY by default and the resolver short-circuits to the shared\n # llm_model/llm_base_url/llm_api_key path when they are — an operator running one model never\n # needs to know the word \"profile\".\n #\n # A profile stores `api_key_env`, the NAME of an environment variable, never the key itself. That\n # is ADR-11's \"a secret is a reference, not a value\" turned from a convention into a data type:\n # the whole profile map is then safe to write into config.snapshot.json, hand back over HTTP and\n # render into LOOPLAB_* — there is nothing in it to mask. It is also what makes per-role\n # CREDENTIALS expressible at all: two roles on one provider can carry different keys, which a\n # key-per-endpoint map could not say.\n llm_profiles: dict[str, dict] = Field(default_factory=dict)\n llm_profile: str | None = None\n role_profiles: dict[str, str] = Field(default_factory=dict)\n # Unified self-driving agent: one LLM identity that plays Researcher + Developer (+ Strategist)\n # across pipeline stages, choosing its own model/toolset per stage. ON by default — the agent\n # drives the loop (incl. crash triage: repair/abandon/reject_idea) and, via\n # `agent_drives_actions`, picks the next macro action within a pure legal-action gate. Set both\n # to False for the legacy byte-identical split-role behavior. (No-op unless backend=\"llm\".)\n unified_agent: bool = True\n agent_drives_actions: bool = True\n # Layer 3 Card queue ownership. False preserves the existing policy/unified-pilot action path.\n # True is pinned in run_started because changing the selector on resume would mix two search\n # treatments in one run. When both this and agent_drives_actions are true, Card selection wins.\n card_driven_selection: bool = False\n # Layer 5 bounded speculative Card buffer. Zero is a hard OFF switch and preserves the\n # historical alternating build/eval spine; positive values are pinned by run_started so a\n # resume cannot silently mix search treatments after a snapshot/default edit.\n speculation_depth: int = Field(default=0, ge=0, le=64)\n # Local evidence receipt produced by ``looplab speculation-gate``. This is deliberately absent\n # from the curated Settings UI: positive-depth execution is a calibrated rollout decision, not a\n # casual tuning knob. The Engine validates the receipt against the current scorer,\n # implementation, GPU inventory and raw paired-run evidence before it enables speculation.\n speculation_gate_receipt: str | None = Field(default=None, max_length=4096)\n # Per-stage model/endpoint overrides for the unified agent. Recognized keys (see\n # tasks.build_unified_agent): `propose` (researcher), `implement`/`repair` (developer),\n # `strategy`, `pilot`. Unlisted keys are ignored. Empty = the per-role researcher_*/developer_*\n # models, then the shared llm_model. Explicit map wins. No-op unless `unified_agent` is true.\n agent_stage_models: dict = {}\n agent_stage_base_urls: dict = {}\n llm_parser: str = \"tool_call\"\n # Reasoning/thinking toggle sent IN the request (providers differ — see `llm.reasoning_body`):\n # Qwen3 on vLLM/SGLang -> chat_template_kwargs.enable_thinking (bool)\n # OpenAI/Ollama-v1/DeepSeek -> reasoning_effort (low|medium|high|none)\n # `llm_reasoning`: \"\" = send nothing (server default — current behavior); off = actively disable;\n # on = enable at default depth; low|medium|high = enable at that effort.\n # Defaults to \"high\" so the agent reasons before proposing/repairing/triaging (better code, fewer\n # crashes); set \"\" to send nothing (server default) or \"off\" to disable. The model's chain-of-thought\n # is CAPTURED either way (reasoning_content field or inline <think>). NOTE: to get a SEPARATE\n # reasoning_content field from SGLang/vLLM, the server also needs `--reasoning-parser qwen3`.\n llm_reasoning: str = \"high\"\n llm_reasoning_style: str = \"auto\" # auto | qwen | effort | none — how to shape the request param\n llm_reasoning_extra: dict = {} # raw fields merged into the body (escape hatch, e.g. Anthropic thinking)\n # H1: drive structured calls with the endpoint's constrained decoding (vLLM/SGLang guided_json +\n # response_format json_schema) so weak models can't emit invalid JSON. Off for Ollama (default).\n llm_guided_json: bool = False\n # Stream every LLM request (SSE) and reassemble it, so the request `timeout` is an INTER-TOKEN\n # idle timeout rather than a whole-request deadline: a long-but-alive generation is never cut off\n # (tokens keep resetting the timer), while a genuinely STALLED endpoint (no token for `timeout` s)\n # is detected and retried on a fresh connection — the fix for silent multi-minute hangs. Set False\n # to use one blocking read (old per-op timeout semantics) if an endpoint streams badly.\n llm_stream: bool = True\n # LLM IDLE timeout (seconds). Stream mode: the INTER-TOKEN stall limit — a steady generation is\n # never cut off, only a silent endpoint. Non-stream: bounds the whole read. Raise for endpoints\n # with a long prefill on huge prompts; lower to fail fast on a flaky shared endpoint.\n llm_timeout: float = Field(default=180.0, gt=0)\n # First-byte (response-headers) window for STREAM attempts, seconds: an SSE response sends its\n # headers on admission, so no-headers ≈ a black-holed request — fail over to a fresh connection\n # fast instead of waiting the full idle timeout. Clamped to llm_timeout. Non-stream attempts are\n # NOT bounded by this (their headers arrive only after the whole generation). The default mirrors\n # `DEFAULT_HEADER_TIMEOUT_S` in looplab/core/llm.py is the single source (imported above).\n llm_header_timeout: float = Field(default=DEFAULT_HEADER_TIMEOUT_S, gt=0)\n # Honour HTTP(S)_PROXY / NO_PROXY / SSL_CERT_FILE env vars for LLM calls. Default False: the\n # transport connects DIRECTLY (many internal/OLAP endpoints are reachable only by bypassing an\n # ambient proxy — a picked-up proxy yields \"connection refused\"). Set true when the endpoint is\n # reachable ONLY via a corporate proxy or needs a custom CA bundle from the environment.\n llm_trust_env: bool = False\n # H4/C2: compact the growing agentic tool-call history once it exceeds this many chars (auto_summary\n # LLM-summarizes the stale middle; else middle-truncate). Sized to the model's real context window so\n # files read earlier STAY in context instead of being compacted away and re-read: ~1,000,000 chars ≈\n # 250k tokens (≈4 chars/token), matching the configured deepseek-v4-flash (≥200k tokens confirmed).\n # The old 120k-char (~30k-token) default was ~8× too aggressive and drove a read-thrash loop. 0 = off.\n context_budget_chars: int = 1_000_000\n # M5: the Researcher's always-on experiments digest budget (chars). 0 = AUTO — scales with the\n # run size (60 chars/node, bounded [1200, 6000]) instead of one flat cap for an 8-node toy run\n # and a 200-node benchmark run alike. Set a positive value to pin it.\n digest_char_cap: int = Field(default=0, ge=0)\n # D11 compression model slot (open_deep_research's four-slot pattern): a dedicated CHEAP model\n # for agent-history summarization (C2 auto-summary), so compression doesn't pay the main\n # model's price. Blank = use each loop's own model (legacy). `compressor_base_url` blank =\n # the shared llm_base_url.\n compressor_model: str | None = None\n compressor_base_url: str | None = None\n # === Agentic tool-loop ================================================================\n # Agentic tool-loop limits — apply to EVERY tool-using agent (the LLM Researcher, the unified\n # agent's pilot + crash-triage, Deep-Research, the run-chat Boss, the genesis repo-scout, and the\n # cross-run report synthesizer). The loop lets the model call read-only tools across turns before\n # emitting its structured result. Both default to UNLIMITED so the agent takes as many turns / as\n # long as it needs — it is never cut off mid-reasoning; set a positive value here (or per-run, or\n # in the UI settings) to bound latency/cost. These used to be hardcoded per call site (e.g. the\n # boss at max_turns=3 / 45s), which silently dropped a slow reasoning model to a no-op reply.\n agent_max_turns: int = 0 # max tool turns before the emit is forced (0 = unlimited)\n agent_time_budget_s: float = 0.0 # wall-clock ceiling across the loop's turns (0 = no cap)\n # G · Soft convergence SAFETY NET (not a research cap). Two thresholds on the agent's tool-turn count:\n # at `agent_emit_after` the loop NUDGES the agent once (\"you've investigated enough — emit now\"); at\n # `agent_emit_force` it FORCES the emit from what was gathered. Set GENEROUSLY — a focused researcher\n # may legitimately read many files/experiments before it can propose a grounded idea; these only catch\n # a genuine runaway that keeps issuing DIFFERENT calls forever (which stuck-detection, keyed on\n # repeats, misses). NB: the old 499-read runaway (node 63) was 79% REDUNDANT re-reads caused by a\n # broken read_file — fixing read_file (pagination) is the real cure; this is just insurance. 0 = off.\n agent_emit_after: int = 300\n agent_emit_force: int = 500\n # B1 · No-progress / stuck detection — the safety net that makes \"unlimited turns\" safe. The\n # turn/time ceilings above are only BACKSTOPS; this is what actually stops a runaway loop, on the\n # cheapest signal: when the model repeats the SAME tool call (or ping-pongs between two, or keeps\n # hitting the SAME error) with no progress, the loop forces the final emit and finishes. ON by\n # default; reading DIFFERENT files or running ONE long command never trips it. (OpenHands-style.)\n agent_stuck_detection: bool = True\n agent_stuck_repeat: int = 4 # identical calls in a row that count as \"stuck\" (>=2)\n agent_stuck_alternate: int = 4 # ping-pong cycles between two calls that count as \"stuck\" (>=2)\n # C1 · Self-plan (TodoWrite-style): expose an `update_plan` tool so a long-running agent keeps its\n # OWN working TODO and re-surfaces it every `agent_plan_reinject_every` turns — keeps the goal in\n # view across a long loop so the agent \"writes its own context\" (Claude Code style). ON by default.\n agent_self_plan: bool = True\n agent_plan_reinject_every: int = 5\n # C2 · Auto-summary (ON by default): when the tool-loop history grows long, LLM-summarize the\n # stale middle instead of only middle-truncating it. The trigger is `context_budget_chars` if set,\n # else a built-in high-water mark (context_budget.DEFAULT_SUMMARY_CHARS ≈ 120k chars) so short\n # loops are untouched. Falls back to truncation on any summarizer error; one extra model call per\n # over-budget turn.\n agent_auto_summary: bool = True\n # C4 · Repo-developer plan decomposition. A big feature attempted in ONE developer session made a\n # non-converging reasoning model loop indefinitely — it kept writing + exploring without ever\n # emitting `done` (10k+ LLM calls / hours; agentic stuck-detection can't catch VARIED exploration,\n # only literal repeats). Fix: the developer first proposes an ordered plan of ATOMIC steps; a\n # multi-step plan is executed step-by-step, each in a FRESH bounded session with only that step's\n # scope + the files accumulated so far, syntax-validated per write. Every session stays\n # small-context and convergent. ON by default for repo tasks (a 1-step plan == the old single pass).\n developer_plan_decompose: bool = True\n developer_plan_min_steps: int = 2 # a proposed plan with >= this many steps runs step-by-step\n developer_plan_max_steps: int = 8 # cap on plan length (a runaway planner can't spawn 100 steps)\n # Hard per-session backstop for the repo developer — the ONE write-heavy agent that can run away\n # even with stuck-detection on (varied writes/reads never trip the repeat/ping-pong signal). Bounds\n # the plan phase, EACH step, and the single-session fallback. Unlike the global agent_* limits\n # (0 = unlimited, meant for read-only agents), the developer always gets a FINITE ceiling so a\n # model that never emits `done` fails cleanly with the code it wrote so far, instead of looping.\n developer_session_max_turns: int = 500\n developer_session_time_budget_s: float = 1200.0 # 20 min wall-clock per developer session\n # Phase-handoff summaries. Each LLM phase in a node build (Researcher·propose → Developer·stages →\n # plan → implement) ends with ONE extra LLM call that distills its transcript — the repo structure\n # it mapped, files/data confirmed, decisions made — into a brief injected into the NEXT phase (even\n # across the role boundary). The next phase TRUSTS that instead of re-reading the same files, which\n # cuts the biggest source of tool-call thrash (stages/plan/implement each re-exploring the repo).\n # One summary call per phase in exchange for many fewer read calls downstream. ON by default.\n phase_handoff_summary: bool = True\n # Observability (ADR-17): capture each LLM call's full prompt + completion into the active\n # span (spans.jsonl) so the UI can show exactly what the model read and wrote per node.\n # Diagnostics only — `replay.fold` never reads spans. Default on for local single-user; set\n # LOOPLAB_TRACE_LLM_IO=0 to suppress (e.g. to keep spans.jsonl small or avoid storing prompts).\n trace_llm_io: bool = True\n # Run-introspection tools (ON by default): make the LLM Researcher (and DeepResearcher) a\n # tool-using agent that can read its OWN experiments (list/read/find-analogous/themes/code) and\n # the task data (schema/profile/asset) mid-loop, instead of seeing only best+parent. Advisory —\n # never changes best-selection. Off = the legacy single-shot Researcher (richer digest still added).\n researcher_tools: bool = True\n # Cross-run introspection: give the agentic Researcher / pilot read-only tools to look at\n # SIBLING runs (same task_id, same run-root) — list them, read an experiment / its code, and\n # find analogous configs across runs — so a run can build on what neighbouring runs already\n # learned instead of rediscovering it. Advisory; never changes best-selection. Needs the run's\n # own dir wired through (no-op for unit-built roles). Off = the legacy single-run view only.\n cross_run_tools: bool = True\n # Read-only access to EVERY run on this machine ACROSS ALL TASKS (not just same-task siblings):\n # the Developer/Researcher get list_all_runs + read_run_code + read_run_experiment so they can read\n # the code + result of ANY past experiment anywhere and reuse an approach. Broader than\n # `cross_run_tools` (same-task only); the agent decides when a foreign run is relevant. Advisory;\n # never changes best-selection. Needs the run's own dir wired through (no-op for unit-built roles).\n all_runs_tools: bool = True\n # PART V §22 — read-only CROSS-RUN KNOWLEDGE tool for the reasoning roles (Researcher, Strategist,\n # deep-research, and a Developer-scoped variant). Adds `cross_run_prior_attempts` / `cross_run_claims`\n # / `cross_run_atlas` over the §21.20 read-models (concept overview + claim assessments + atlas), read\n # from `memory_dir` (lessons.jsonl + concept_capsules.jsonl). Read-only is a storage-authority\n # boundary, not a behavioral no-op: retrieved content can steer later proposals, and a tool-using\n # reasoning loop can consume additional model calls. The tools never mutate cross-run truth (facts\n # are engine-written, verdicts operator-ratified, §22.4) or directly select a winner. ON by explicit\n # experimental product choice, EngineOptions off; stores only exist once Part-IV features have run.\n cross_run_read_tools: bool = True\n # Agentic retrieval (ADR-16): if set, the LLM Researcher gets grep/kb_search/read\n # tools over this directory of markdown notes and chooses when to use them.\n knowledge_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"knowledge\"))\n # T4 real embeddings: model id for an OpenAI-compatible `/embeddings` endpoint used for\n # kb_search / case retrieval. Blank (default) = the dependency-free lexical `hash_embed` (today's\n # behavior). Set e.g. \"nomic-embed-text\" (Ollama) or \"text-embedding-3-small\" for SEMANTIC\n # retrieval; a misconfigured/offline endpoint degrades back to hash_embed at call time (never\n # crashes a run). `embed_base_url` blank = reuse `llm_base_url`; the shared `llm_api_key` is used.\n embed_model: str | None = None\n embed_base_url: str | None = None\n # Harmonic memory (Memora, ICML'26 — idea import). When ON, cross-run cases and knowledge notes are\n # indexed by a short ABSTRACTION + cue ANCHORS instead of their raw text, near-duplicate memories are\n # CONSOLIDATED into one entry under a matching abstraction, and `kb_search`/case retrieval EXPAND\n # through the top hits' anchors to surface related-but-not-similar memories. ON by default. Set\n # `memora=false` to restore the pre-Memora raw-text index. Never a source of truth — abstractions\n # live only in the derived, rebuildable retrieval index (never the event log or canonical cases.jsonl).\n memora: bool = True\n # Write abstractions with the wired chat model (richer than the deterministic lexical fallback). ON\n # by default; results are CACHED by content hash (see `memora_cache`) so a re-built index doesn't\n # re-call the model on unchanged notes/cases, and any endpoint failure degrades to lexical at call\n # time — so an offline box just gets lexical abstractions, never a crash. Set false to force lexical.\n memora_llm: bool = True\n # === Cross-run memory / Memora ========================================================\n memora_anchors: int = 6 # max cue anchors kept per memory\n # Cosine similarity at/above which a new case is CONSOLIDATED into an existing one (same evolving\n # topic) rather than stored as a separate entry. Only used when `memora` is on.\n # BOUNDED like its 0..1 sibling `novelty_semantic_threshold`: consumers compare\n # `cosine(...) >= consolidate_threshold` (engine/memory.py, tools/knowledge_tools.py) and cosine\n # cannot exceed 1.0, so an unbounded typo above 1.0 validated cleanly and silently disabled\n # consolidation forever — while a negative one consolidated everything. Fail loud instead.\n memora_consolidate_threshold: float = Field(default=0.86, ge=0.0, le=1.0)\n # Where the LLM-abstraction cache lives (JSON). Blank (default) derives it from `memory_dir`\n # (`<memory_dir>/memora_cache.json`) or else `knowledge_dir` (`<knowledge_dir>/.memora_cache.json`);\n # with neither set the cache is in-memory only (per process). No-op unless `memora_llm` is on.\n memora_cache: str | None = None\n # E3 literature-grounded ideation (network-OPTIONAL): give the agentic Researcher an arXiv search\n # tool to ground ideas in real techniques. Off by default (egress is unreliable on some boxes);\n # fails gracefully if the network is blocked.\n literature_search: bool = False\n # Deep-Research stage (network-OPTIONAL): give the DeepResearcher a general web search/fetch\n # tool (DuckDuckGo) on top of arXiv to read across results + the web before steering the next\n # batch. Off by default (egress is unreliable on some boxes); fails gracefully when blocked.\n web_search: bool = False\n # Cadence for the Deep-Research stage: run it automatically every N created nodes. Zero disables\n # only the cadence; manual `deep_research` and Strategist `request_research` triggers still fire.\n # Default 3: deep research analyzes the accumulating results and steers the next batch (its\n # directions become hints + open hypotheses); paired with `concurrent_research` it overlaps the\n # think with the GPU-bound eval, so the cadence costs LLM tokens but no wall-clock on the search.\n deep_research_every: int = 3\n # Overlap a DUE deep-research \"think\" with the GPU-bound eval instead of running it in its own\n # serial step (the agent is otherwise idle while a node trains). research() computes from a state\n # snapshot; the joined background task records only allowlisted, order-tolerant\n # research/hint/hypothesis events as soon as the memo finishes. ON by default: the LLM is typically\n # remote (no GPU contention with eval), so overlapping hides latency behind long training.\n concurrent_research: bool = True\n # Concurrent-research REPEAT — don't idle a multi-DAY eval. The overlapped \"think\" fires ONCE per\n # window by default; ON here, it RE-RUNS on an adaptive time cadence for the WHOLE eval window\n # (a two-day training must not leave the reasoning agents idle after one memo). Self-paced: it\n # only records a memo whose CONTENT is new (identical re-runs are skipped, so the log/board don't\n # bloat) and backs off geometrically as the analysis converges, capped so it always re-checks.\n # The appended records never rewrite the current metric champion, but their hints/open hypotheses\n # deliberately steer later proposals and replay reconstructs that advice. The library default is\n # one-shot (== today, byte-identical); this product default turns the repetition on.\n concurrent_research_repeat: bool = True\n # Base cadence (seconds) between repeated concurrent-research passes. Research is EXPENSIVE\n # (multi-turn LLM + web/arXiv), so this is a FLOOR, not a ceiling: the effective cadence is\n # max(this, ~5% of the per-experiment time budget) — a two-day eval is re-researched about hourly,\n # a short eval not at all (the first tick outlasts it). Only matters when the repeat is on.\n concurrent_research_interval_s: float = Field(default=1800.0, gt=0)\n # Per-eval-window backstop on repeated-research LLM calls (0 = cadence-only, no cap). The adaptive\n # cadence + convergence backoff are the primary budget control; this bounds a pathological\n # never-converging window. Past it the loop stops calling the LLM (the health monitor still runs).\n concurrent_research_max_calls: int = Field(default=40, ge=0)\n # Concurrent hypothesis-board CONSOLIDATION during the eval window. Repeated research keeps ADDING\n # near-duplicate directions to the open board (each memo registers up to 5 as open hypotheses), so a\n # long eval's board bloats before the between-nodes merge runs. This dedups/merges it on the SAME\n # background loop so the board stays clean for the next proposal. In Card-driven mode the same merge\n # changes ownership/readiness, so it is deferred to the joined main-task cadence instead. Needs the\n # reflect client + `track_hypotheses`.\n # Only active while `concurrent_research` runs the overlap loop. Library default off (== today).\n concurrent_consolidate: bool = True\n # Cadence for the agent-authored run report: regenerate the conclusion-first narrative every N\n # created nodes (0 = off; it still regenerates on a manual `report_refresh` from the UI). The\n # deterministic report always renders from the node set regardless of this knob.\n report_every: int = 3\n # Agent Skills (I18, ADR-9): dir of SKILL.md the Researcher can list/load as tools.\n skills_dir: str | None = None\n # Prompt store (I18, ADR-8): dir of editable, hot-reloaded role prompt .md files.\n prompt_dir: str | None = None\n # API key as a reference, never serialized as a value (ADR-11). Local servers\n # ignore it; default to a placeholder.\n llm_api_key: SecretStr | None = None\n # Runtime-only endpoint binding for the shared credential. A key is usable only when this\n # canonical URL exactly matches the transport target. It is deliberately absent from run/UI\n # snapshots: the owner-side secret store persists key + binding together, while an operator who\n # supplies LOOPLAB_LLM_API_KEY directly must also supply LOOPLAB_LLM_API_KEY_BASE_URL.\n llm_api_key_base_url: str | None = None\n # Set only by a trusted source-aware resolver after it has selected key+binding as one record.\n # Config files, task payloads and --set cannot populate PrivateAttr.\n _llm_credential_pair_trusted: bool = PrivateAttr(default=False)\n\n @model_validator(mode=\"before\")\n @classmethod\n def _reject_bool_for_numeric_fields(cls, data):\n \"\"\"A JSON boolean is a TYPE ERROR for a numeric setting, not the number 1.\n\n Pydantic runs in lax mode here (`model_config` sets no `strict`), and `isinstance(True, int)`\n is True — so `{\"max_nodes\": true}` from a UI/API type slip validated cleanly as a budget of\n ONE node, and `{\"eval_parallel\": true}` collapsed the width to one. The run then looked\n configured and simply did almost nothing, with no 422 and nothing in the snapshot to show why.\n Rejecting at the boundary covers every numeric field at once, including the many that carry no\n `Field(ge=...)` bound of their own to trip over.\n \"\"\"\n if not isinstance(data, dict):\n return data\n for key, val in data.items():\n if type(val) is bool and _is_numeric_annotation(\n getattr(cls.model_fields.get(key), \"annotation\", None)):\n raise ValueError(f\"{key} must be a number, not a boolean (got {val!r})\")\n return data\n\n @model_validator(mode=\"before\")\n @classmethod\n def _apply_profile(cls, data):\n \"\"\"Expand `profile` into a bundle of setting defaults BEFORE validation.\n\n pydantic-settings hands this validator the fully-merged input from ALL explicit sources\n (init kwargs + LOOPLAB_* env + .env), with fields the user left unset simply ABSENT (they\n fall to field defaults afterward). So a profile value is applied only when its key is not\n already present — i.e. any explicit file/CLI/env setting wins over the profile, and the\n profile wins over the bare field default. This makes `thorough` a true convenience preset,\n never an override of what the user asked for.\"\"\"\n if not isinstance(data, dict):\n return data\n prof = data.get(\"profile\", \"default\")\n # env may deliver the key as \"profile\" (env_prefix stripped) — normalize to a str\n prof = str(prof).strip().lower() if prof is not None else \"default\"\n overrides = PROFILES.get(prof)\n if overrides is None:\n raise ValueError(f\"unknown profile {prof!r}; known: {sorted(PROFILES)}\")\n for key, val in overrides.items():\n if key not in data: # explicit source (file/CLI/env) always wins\n data[key] = val\n return data\n\n # The closed-vocabulary string fields, as a TABLE rather than a hand-written if-chain (doc 25\n # CO-13). Three accretion waves had appended to that chain, each repeating the same\n # \"must be a|b|c, got {!r}\" message, and a new enum-ish field is exactly the kind of thing that\n # gets added to `Settings` and forgotten here — where forgetting is silent: an out-of-set value\n # does not fail, it falls through whatever consumes the field as a NO-OP. A mis-cased\n # `LOOPLAB_NOVELTY_MODE=LLM` turned the novelty gate off with no diagnostic; a mis-cased\n # `strategist_backend` quietly ran the default; a typo'd `llm_parser` is indistinguishable from\n # asking for the default fallback order.\n #\n # Two entries resolve their vocabulary LAZILY, and for different reasons:\n # * `developer_backend` is validated against `DEVELOPER_BACKENDS`, which `agents/cli_agent.py`\n # asserts its PRESETS match — so a typo still fails loud without core executing\n # agents-package code on every Settings construction (doc 25 XP-04: the one upward import\n # out of core).\n # * `llm_parser` is validated against the parser registry itself, imported inside the callable\n # to keep `config` import-light.\n _ENUM_FIELDS: typing.ClassVar[tuple] = (\n (\"trust_gate\", (\"audit\", \"gate\", \"block\")),\n (\"merge_mode\", (\"auto\", \"mean\", \"ensemble\")),\n (\"novelty_mode\", (\"off\", \"algo\", \"llm\")),\n (\"strategist_backend\", (\"off\", \"rule\", \"llm\", \"agent\")),\n (\"eval_trust_mode\", (\"ratify_freeze\", \"autonomous\", \"ratify_freeze_drift\")),\n (\"seed_mode\", (\"auto\", \"tracked\", \"all\")),\n (\"backend\", (\"toy\", \"llm\")),\n (\"developer_backend\", lambda: DEVELOPER_BACKENDS),\n (\"llm_parser\", lambda: _parser_names()),\n )\n\n @model_validator(mode=\"after\")\n def _check_enum_fields(self):\n for field, vocabulary in self._ENUM_FIELDS:\n allowed = vocabulary() if callable(vocabulary) else vocabulary\n value = getattr(self, field)\n if value not in allowed:\n raise ValueError(f\"{field} must be {'|'.join(allowed)}, got {value!r}\")\n self._check_llm_profiles()\n return self\n\n def _check_llm_profiles(self) -> None:\n \"\"\"Validate the connection-profile maps LOUDLY, at construction.\n\n Assignment is not validated anywhere in this project, and these two maps are the kind of\n thing a typo makes into a silent no-op: an unknown role name would simply never be read, and\n a reference to a missing profile would fall back to the shared model while the operator\n believed a second provider was in play. Both are errors here instead.\n\n The `api_key_env` rules exist for a specific reason. It must LOOK like a secret to\n `runtime/sandbox.py::SECRET_ENV`, because that is the filter that strips the operator's keys\n out of the environment handed to generated candidate code — a key parked in a variable named\n `MY_HANDLE` would be invisible to it and ride straight into the sandbox. This pattern is\n deliberately the STRICTER of the two (the sandbox has to recognize whatever already exists;\n this names a variable the operator is about to create), and it is duplicated below rather than\n imported because layering forbids `core` from importing `runtime`.\n `tests/test_secret_env_pattern.py` pins the two together — including the invariant that every\n name accepted here is one the sandbox strips. And a literal key inside a profile is refused\n outright: the profile map is written verbatim into config.snapshot.json and served over HTTP\n precisely because it is supposed to contain no secrets.\"\"\"\n from looplab.core.llm import LLM_ROLE_KEYS\n if not isinstance(self.llm_profiles, dict):\n raise ValueError(\"llm_profiles must be a map of profile name -> profile\")\n for name, entry in self.llm_profiles.items():\n if not isinstance(entry, dict):\n raise ValueError(f\"llm_profiles[{name!r}] must be an object\")\n # BEFORE the unknown-field check: none of these is a known field, so checking them\n # afterwards could never fire and someone who pasted a live key was told it was a typo.\n for banned in (\"api_key\", \"key\", \"token\", \"secret\"):\n if banned in entry:\n raise ValueError(\n f\"llm_profiles[{name!r}] must not contain {banned!r}: a profile stores \"\n \"api_key_env (the NAME of an environment variable), never a key value — \"\n \"the whole map is written to config.snapshot.json and served over HTTP\")\n unknown = set(entry) - _PROFILE_FIELDS\n if unknown:\n raise ValueError(\n f\"llm_profiles[{name!r}] has unknown field(s) {sorted(unknown)}; \"\n f\"known: {sorted(_PROFILE_FIELDS)}\")\n env = entry.get(\"api_key_env\")\n if env is not None and not _SECRET_ENV_NAME.match(str(env)):\n raise ValueError(\n f\"llm_profiles[{name!r}].api_key_env={env!r} is not a usable variable name: it \"\n \"must be UPPER_SNAKE and contain KEY/SECRET/TOKEN/PASSWORD/PASSWD/CREDENTIAL, so \"\n \"the sandbox's secret-name filter strips it from generated code's environment\")\n temp = entry.get(\"temperature\")\n if temp is not None and (isinstance(temp, bool) or not isinstance(temp, (int, float))):\n raise ValueError(f\"llm_profiles[{name!r}].temperature must be a number\")\n if not isinstance(self.role_profiles, dict):\n raise ValueError(\"role_profiles must be a map of role name -> profile name\")\n for role, profile in self.role_profiles.items():\n if role not in LLM_ROLE_KEYS:\n raise ValueError(\n f\"role_profiles has unknown role {role!r}; known: {sorted(LLM_ROLE_KEYS)}\")\n if profile not in self.llm_profiles:\n raise ValueError(\n f\"role_profiles[{role!r}] names profile {profile!r}, which is not in llm_profiles\")\n if self.llm_profile is not None and self.llm_profile not in self.llm_profiles:\n raise ValueError(\n f\"llm_profile={self.llm_profile!r} is not in llm_profiles\")\n\n def masked_snapshot(self) -> dict:\n # Snapshots are a JSON contract (they are written verbatim to config.snapshot.json), not a\n # Python-object dump. In particular tuple-valued schema fields such as\n # ``inline_repair_reasons`` must become JSON arrays before callers hash the runtime envelope;\n # otherwise the strict speculation-scope canonicalizer sees a different/non-JSON preimage\n # from the one that ``json.dumps`` eventually persists.\n d = self.model_dump(mode=\"json\")\n if self.llm_api_key is not None:\n d[\"llm_api_key\"] = \"***\"\n else:\n d[\"llm_api_key\"] = None\n d.pop(\"llm_api_key_base_url\", None)\n # The snapshot's own FORMAT version — not a Settings field (it is never settable by env or\n # file, and it describes the document, not the run). `settings_from_snapshot` fails closed on\n # a version it does not understand; see CONFIG_SNAPSHOT_SCHEMA.\n d[CONFIG_SNAPSHOT_SCHEMA_KEY] = CONFIG_SNAPSHOT_SCHEMA\n return d\n\n\n# The config snapshot's document-format version, written by `masked_snapshot` and enforced by\n# `settings_from_snapshot`. Bump it when a snapshot written by this binary would be MISREAD by an\n# older one — i.e. when a new field changes paid, concurrency or selection behaviour and silently\n# defaulting it would resume the same event history under different semantics. `Settings` is\n# `extra=\"ignore\"`, so without this an older binary drops what it does not recognize and resumes\n# anyway, with no diagnostic; a version it does not know is now a loud refusal instead.\n# Version 1 is the first VERSIONED format. An absent key means a pre-versioning snapshot, which stays\n# readable through `LEGACY_CONFIG_SNAPSHOT_DEFAULTS` — that historical contract is unchanged.\nCONFIG_SNAPSHOT_SCHEMA = 1\nCONFIG_SNAPSHOT_SCHEMA_KEY = \"config_snapshot_schema\"\n\n\nclass ConfigSnapshotVersionError(ValueError):\n \"\"\"A config snapshot was written by a NEWER binary than the one reading it.\"\"\"\n\n\n# Pre-versioned snapshots are full Settings dumps, so absence is a reliable per-field version marker.\n# Keep their historical effective behavior when newer product defaults become active: re-entry must not\n# silently add paid calls, interventions, concurrency or a different selection policy to an old run.\nLEGACY_CONFIG_SNAPSHOT_DEFAULTS: dict[str, object] = {\n \"parallel_build\": 1,\n \"eval_parallel\": None,\n \"llm_parallel\": None,\n \"train_monitor\": False,\n \"train_monitor_interval_s\": 600.0,\n \"train_monitor_kill\": False,\n \"train_monitor_kill_confidence\": 0.8,\n \"asha_live\": False,\n \"asha_live_kill\": False,\n \"asha_live_quantile\": 0.5,\n \"asha_live_min_siblings\": 3,\n # Before this operator ceiling existed, the sandbox's 24-hour process limit was authoritative.\n \"max_eval_timeout\": 24 * 3600.0,\n \"watchdog_reflection\": False,\n \"card_driven_selection\": False,\n \"speculation_depth\": 0,\n \"speculation_gate_receipt\": None,\n \"concurrent_research_repeat\": False,\n \"concurrent_research_interval_s\": 1800.0,\n # Zero means unlimited; 40 was the first bounded default, and is latent while repeat is disabled.\n \"concurrent_research_max_calls\": 40,\n \"concurrent_consolidate\": False,\n # The map originally covered only the flags added in the days just before it was written\n # (the entries above are all 2026-07-19..07-21; the map itself landed 07-22). Everything below was\n # added 2026-06-24..07-17 — AFTER `config.snapshot.json` existed (it ships in the initial commit,\n # 2026-06-23) and BEFORE this map did — so a snapshot from that window carries none of these keys\n # and, without an entry here, resumed with today's paid product default ACTIVE. That is precisely\n # the \"silently add paid calls ... to an old run\" the paragraph above forbids: the resumed half of\n # the run would make predict-before-execute and cross-run LLM calls the original never made, and\n # admit candidates under a different novelty policy.\n # `setdefault` makes every one of these a no-op for a snapshot that DOES carry the key, so only\n # genuinely older runs are affected. Each value is the behaviour before the field existed: absent\n # feature = off, absent cadence = 0 (the same \"conservative library\" value the frozen\n # Settings-vs-EngineOptions divergence table records for these fields). Each was dated against the\n # tree before being listed — e.g. `report_every` (2026-06-25) is 0 because the run-report writer\n # itself did not exist before that commit, so an older run wrote no report to preserve.\n \"foresight\": False,\n \"foresight_agentic\": False,\n \"foresight_verify\": False,\n \"concept_pivot\": False,\n \"graded_novelty\": False,\n \"cross_run_concepts\": False,\n \"cross_run_advisory\": False,\n \"cross_run_structured_claims\": False,\n \"cross_run_curation\": False,\n \"cross_run_read_tools\": False,\n \"concept_run_base\": False,\n \"fingerprint_universal\": False,\n \"memora_llm\": False,\n \"comparative_lessons\": False,\n \"lessons_every\": 0,\n \"lessons_refresh_every\": 0,\n \"unified_agent\": False,\n \"deep_research_every\": 0,\n \"report_every\": 0,\n \"failure_reflection\": False,\n \"reflection_priors\": False,\n \"agent_drives_actions\": False,\n \"concurrent_research\": False,\n \"deep_repair\": False,\n # `merge_mode` meets all three conditions below, confirmed against the commits: the field was\n # introduced 2026-06-24 (bb421e0f), one day AFTER the cutoff, so a pre-cutoff snapshot carries no\n # such key; it arrived defaulting to \"mean\" and only later flipped to \"auto\" (a8b37944,\n # 2026-07-03), which `orchestrator.__init__` resolves to \"ensemble\" for a code-generating\n # developer — a PAID code-recombination merge producing a DIFFERENT merged solution; and the\n # before-the-field value is pointable and still frozen as the library default in\n # `EngineOptions` / `orchestrator._DEFAULTS`. Without this entry a pre-field snapshot resumed on\n # an LLM backend silently gained ensemble merges it never did.\n \"merge_mode\": \"mean\",\n # WHAT THIS MAP IS NOT. It is a hand-maintained list of FEATURE switches whose before-the-field\n # value is unambiguous, not a complete partition of `Settings`. Two classes stay out on purpose,\n # because for them a wrong entry is worse than a missing one — it would silently REMOVE behaviour\n # the original run really had:\n # * knobs that are latent while their parent feature is off — `foresight_panel`,\n # `foresight_verify_samples`, `memora_anchors`, `holdout_top_k`, `asha_eta`: the parent above\n # already pins them out of play, and their own before-the-field value is not recoverable;\n # * magnitudes and depths for behaviour that ALREADY existed — `debug_depth`,\n # `context_budget_chars`, `developer_plan_max_steps`, `agent_emit_after`: guessing 0/off there\n # disables working machinery rather than preserving history.\n # A new field belongs here only when it (a) postdates 2026-06-23, (b) defaults to adding paid\n # calls / interventions / concurrency / a different selection policy, and (c) has a historical\n # value you can point at a commit for. When (c) fails, leave it out and say so here.\n}\n\n\ndef migrate_config_snapshot(data: dict) -> dict:\n \"\"\"Return an effective copy of one legacy/current snapshot without rewriting evidence bytes.\"\"\"\n if not isinstance(data, dict):\n raise TypeError(\"config snapshot must be a JSON object\")\n migrated = dict(data)\n for key, value in LEGACY_CONFIG_SNAPSHOT_DEFAULTS.items():\n migrated.setdefault(key, value)\n return migrated\n\n\ndef config_snapshot_schema(data: dict) -> int:\n \"\"\"The snapshot's declared format version. 0 = pre-versioning (the key did not exist yet).\n\n A malformed value is treated as UNREADABLE rather than as 0: a snapshot whose own version marker\n is corrupt cannot be assumed to be the oldest format.\"\"\"\n raw = data.get(CONFIG_SNAPSHOT_SCHEMA_KEY, 0) if isinstance(data, dict) else 0\n if isinstance(raw, bool) or not isinstance(raw, int) or raw < 0:\n raise ConfigSnapshotVersionError(\n f\"config snapshot has a malformed {CONFIG_SNAPSHOT_SCHEMA_KEY}: {raw!r}\")\n return raw\n\n\ndef settings_from_snapshot(data: dict) -> Settings:\n \"\"\"Build re-entry Settings from a masked snapshot under its historical missing-field contract.\n\n Fails closed on a snapshot written by a NEWER binary. `Settings` is `extra=\"ignore\"`, so such a\n snapshot would otherwise load with every unrecognized control silently dropped — the run would\n resume on the same event history under different paid/concurrency/selection semantics, with\n nothing to show why. Refusing is the only honest answer: this binary cannot know what it is\n dropping. Older and pre-versioning snapshots keep loading exactly as before.\"\"\"\n found = config_snapshot_schema(data)\n if found > CONFIG_SNAPSHOT_SCHEMA:\n raise ConfigSnapshotVersionError(\n f\"this run's config.snapshot.json declares format v{found}, but this build understands \"\n f\"at most v{CONFIG_SNAPSHOT_SCHEMA}. It was written by a newer LoopLab; resuming here \"\n \"would silently drop settings this build does not know. Upgrade LoopLab to resume it.\")\n migrated = migrate_config_snapshot(data)\n migrated.pop(\"llm_api_key\", None)\n migrated.pop(\"llm_api_key_base_url\", None)\n migrated.pop(CONFIG_SNAPSHOT_SCHEMA_KEY, None) # a document marker, never a Settings field\n return Settings(**migrated)\n"}, "98cdbee90e796b6108f05e4a8833c454e28d8aea": {"t": 1785768721.5037856, "n": 121759, "h": "b8d62a3098c53833", "c": "\"\"\"Engine config: the schema (what settings exist). (I0, ADR-11)\n\nOne pydantic-settings class declaring every engine knob, its default, and its docs. The loader —\nhow a run file / CLI / env becomes a `Settings` — is `looplab.core.appconfig`, which also owns\nthe config-precedence order. A resolved, secret-masked snapshot is written next to each run for\nreproducibility. (No real secrets in P0, but the masking discipline is in place.)\n\"\"\"\nfrom __future__ import annotations\n\nimport re\nimport types\nimport typing\nfrom pathlib import Path\n\nfrom pydantic import Field, PrivateAttr, SecretStr, field_validator, model_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n# Single source of the LLM first-byte (response-headers) default — see core/llm.py.\nfrom looplab.core.llm import DEFAULT_HEADER_TIMEOUT_S\n\n# Default home for cross-run memory + the knowledge base (shared across every run). Both are ON by\n# default now (a user asked for it) so a fresh install accumulates + consults knowledge with no setup;\n# set the env var to \"\" to disable, or to a path to relocate. `~/.looplab/` keeps them out of any one\n# project + shared across projects.\n_LL_HOME = Path.home() / \".looplab\"\n\n# What one `llm_profiles` entry may contain. Deliberately closed: an unrecognized field is a typo the\n# operator would otherwise never see fail, and the map is a public artifact (config.snapshot.json,\n# HTTP, LOOPLAB_*), so it must never accrete free-form keys.\n_PROFILE_FIELDS = frozenset({\"model\", \"base_url\", \"temperature\", \"api_key_env\"})\n# A variable name that `runtime/sandbox.py::SECRET_ENV` will recognize as holding a secret and strip\n# from generated code's environment. Duplicated here, not imported: layering forbids `core` from\n# importing `runtime`. `tests/test_secret_env_pattern.py` holds the two in agreement.\n_SECRET_ENV_NAME = re.compile(\n r\"\\A(?=[A-Z][A-Z0-9_]{0,63}\\Z)(?=.*(KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL)).*\\Z\")\n\n\ndef _is_numeric_annotation(ann) -> bool:\n \"\"\"True when a field's annotation admits int/float but NOT bool.\n\n Used by `Settings._reject_bool_for_numeric_fields`: pydantic resolves annotations at class build,\n so `model_fields[...].annotation` is a real type object here even under\n `from __future__ import annotations`. Optionals are numeric when every non-None member is\n (`int | None` yes, `int | str` no), so a boolean can never slip in through a union arm.\"\"\"\n if ann is bool:\n return False # a bool field legitimately takes a bool\n if ann is int or ann is float:\n return True\n args = [a for a in typing.get_args(ann) if a is not type(None)]\n origin = typing.get_origin(ann)\n if args and origin in (typing.Union, types.UnionType):\n return all(_is_numeric_annotation(a) for a in args)\n return False\n\n# Autonomous roles that may be granted per-setting write access (see Settings.agent_control).\n# strategist = A7 meta-controller (run-wide); boss = run-chat operator-proxy (run-wide);\n# researcher = per-experiment proposer (per-node sizing, e.g. eval timeout for a heavy model).\nAGENT_ROLES = (\"strategist\", \"boss\", \"researcher\")\n\n# A foresight score is computed for every acted-on proposal. Keep this lower than the generic\n# verifier's emergency ceiling so a typo cannot multiply paid synchronous calls across a run.\nMAX_FORESIGHT_VERIFY_SAMPLES = 8\n\n\n# Layer-2 parallelism renamed two public settings without removing the durable legacy fields. Keep\n# the equivalence in one place: config-source precedence, governance migration, and live strategy\n# application must all agree which names describe the same authority/value.\nPARALLELISM_ALIASES: dict[str, str] = {\n \"max_parallel\": \"eval_parallel\",\n \"parallel_build\": \"llm_parallel\",\n}\n\n\ndef parallelism_aliases(setting: str) -> tuple[str, ...]:\n \"\"\"Return every spelling in ``setting``'s alias family, canonical name first.\"\"\"\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if setting in (legacy, canonical):\n return canonical, legacy\n return (setting,)\n\n\ndef canonicalize_parallelism_source(\n values: dict, *, promote_build_to_llm_parallel: bool = False,\n) -> dict:\n \"\"\"Promote legacy parallel keys *within one config-precedence source*.\n\n Applying this only after file/CLI/env were flattened would let a lower-priority canonical env\n value shadow a higher-priority legacy file value (or a file canonical value shadow a CLI legacy\n override), so callers with sub-layers normalize each layer independently.\n\n ``max_parallel``/``eval_parallel`` are true aliases (pure eval width, no extra semantics) and are\n always promoted. ``parallel_build``/``llm_parallel`` are NOT: a positive ``llm_parallel`` also flips\n on the finite shared LLM broker (``orchestrator._startup_llm_total``), while legacy ``parallel_build``\n means \"legacy build width, unbounded overlap, no shared total\". So promoting a legacy-only\n ``parallel_build`` layer (e.g. just ``LOOPLAB_PARALLEL_BUILD=1`` in env) would silently serialize\n every provider call — contradicting llm_broker.py's docstring, orchestrator's \"legacy-only preserves\n unbounded overlap\" note, the broker-neutral live-control path, and the configuration.md ``llm_parallel``\n row (\"falls back ... without enabling a shared total\"). Config/startup loads therefore keep broker\n opt-in tied to an EXPLICITLY-spelled canonical ``llm_parallel``; the orchestrator's own width fallback\n (``llm_parallel if not None else parallel_build``) still carries the legacy build width. Only the\n Strategist live-control path (``canonicalize_strategy_parallelism``) opts into\n ``promote_build_to_llm_parallel``, where ``parallel_build`` is a deliberate full alias of the\n canonical knob (see strategy.py ``_apply_strategy``).\n \"\"\"\n if not isinstance(values, dict):\n return values\n out = dict(values)\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if canonical == \"llm_parallel\" and not promote_build_to_llm_parallel:\n continue\n # None is the durable legacy mode for llm_parallel, not a missing value. A normal snapshot\n # contains llm_parallel:null plus parallel_build:1; promoting null to 1 would unexpectedly enable\n # the global one-call broker. Promote aliases only when the canonical key is absent, and pin\n # snapshot round-trip parity. Canonicalize inside each precedence layer, never across the\n # flattened result, so the alias migration cannot invert CLI > file > env precedence.\n if canonical not in out and out.get(legacy) is not None:\n out[canonical] = out[legacy]\n return out\n\n\ndef flatten_parallelism_layers(layers) -> dict:\n \"\"\"Flatten same-tier precedence layers (LOWEST priority first) and resolve the legacy pair.\n\n PRECEDENCE REPAIR for the non-promoted `parallel_build`/`llm_parallel` pair. Declining to promote\n in `canonicalize_parallelism_source` is right (a legacy-only layer must not flip on the shared\n broker), but it let a LOWER-priority canonical value survive a HIGHER-priority legacy override:\n file `llm_parallel: 2` + CLI `-s parallel_build=8` flattened to BOTH keys, and the orchestrator\n prefers `llm_parallel`, so the operator's explicit CLI width was silently discarded. When a higher\n layer names the legacy width over a lower layer's canonical one, mask with the LEGACY SENTINEL\n (`llm_parallel=None` is the durable \"legacy mode — build width from parallel_build, no shared\n total\"), so the higher layer wins with the semantics it asked for.\n\n The mask lives HERE, not in the per-layer canonicalizer, because only a flatten knows the layer\n ORDER. Stamping it per layer meant any layer naming `parallel_build` emitted `llm_parallel=None`\n even when NO layer in the tier set the canonical key — and since pydantic-settings ranks init\n kwargs above env, that sentinel then silently overrode a `LOOPLAB_LLM_PARALLEL` the operator had\n exported as a deliberate provider rate/cost ceiling. Precedence is per FIELD: a tier that never\n spelled `llm_parallel` has not set it, and it must fall through to env/.env/defaults — which is\n exactly what `build_settings`' docstring promises (\"the merged dict wins only where it actually\n sets a value\").\n \"\"\"\n merged: dict = {}\n legacy_at = canonical_at = -1\n for index, layer in enumerate(layers):\n if not isinstance(layer, dict):\n continue\n merged.update(layer)\n if layer.get(\"parallel_build\") is not None:\n legacy_at = index\n if \"llm_parallel\" in layer:\n canonical_at = index\n # Only when this tier DID set the canonical key, at a strictly lower layer than the legacy one.\n if canonical_at >= 0 and legacy_at > canonical_at:\n merged[\"llm_parallel\"] = None\n return merged\n\n# Settings whose semantics are committed by ``run_started`` and restored from the folded event log\n# on every re-entry. Changing one only in ``config.snapshot.json`` would mix incompatible evidence\n# (a different holdout split) or make the live producer disagree with replay's selection policy.\n# The HTTP config editor and the engine both consume this one contract; keep deliberately mutable\n# controls such as ``trust_gate`` out (that field has its own durable change event).\nRUN_START_PINNED_FIELDS = frozenset({\n \"card_driven_selection\",\n \"speculation_depth\",\n \"holdout_fraction\",\n \"holdout_select\",\n \"select_verifier\",\n \"select_verifier_samples\",\n \"verifier_ci_tie\",\n})\n\n\ndef run_start_pinned_settings(state) -> dict:\n \"\"\"Return the effective Settings names committed by a folded run-start record.\n\n Pre-pin legacy logs have no recorded holdout fraction, so their snapshot remains authoritative\n for both holdout knobs. Verifier fields, however, have always been re-pinned from the legacy-safe\n fold defaults whenever a valid ``run_started`` identity exists.\n \"\"\"\n if not getattr(state, \"run_id\", \"\"):\n return {}\n values = {\n # Layer 3 changes which queue owns macro-action selection. Once a run starts, replay and\n # resume must keep the same owner even if the snapshot or ambient default changes later.\n \"card_driven_selection\": bool(getattr(state, \"card_driven_selection\", False)),\n # Layer 5 overlaps Card production with evaluation only when this pinned treatment is\n # positive. Old logs omit it and therefore retain the exact serial build/eval spine.\n \"speculation_depth\": int(getattr(state, \"speculation_depth\", 0)),\n \"select_verifier\": bool(getattr(state, \"select_verifier_tiebreak\", False)),\n \"select_verifier_samples\": int(getattr(state, \"select_verifier_samples\", 3)),\n \"verifier_ci_tie\": bool(getattr(state, \"verifier_ci_tie\", False)),\n }\n holdout_fraction = getattr(state, \"holdout_fraction\", None)\n if holdout_fraction is not None:\n values.update({\n \"holdout_fraction\": holdout_fraction,\n \"holdout_select\": bool(getattr(state, \"holdout_select\", False)),\n })\n if not values.keys() <= RUN_START_PINNED_FIELDS:\n raise RuntimeError(\"folded run-start pinned settings contract drifted\")\n return values\n\n# --- Run profiles (config-first presets) -------------------------------------------------------\n# A `profile` is a NAMED BUNDLE of setting defaults — nothing more. It only fills fields the user\n# did NOT set explicitly (via file / --set / LOOPLAB_* env / init-kwargs), so any explicit knob\n# always wins (see `_apply_profile`). Most engine intelligence lives behind individual flags. The\n# PART IV/V cross-run + concept-graph machinery (concept_pivot, graded_novelty, cross_run_concepts /\n# _advisory / _structured_claims / _curation / _read_tools, fingerprint_universal, foresight_verify)\n# now ships ON in the product `Settings` default. That is an explicit experimental product choice,\n# not a claim that a frozen A/B or workload-wide cost/quality gate has cleared: the bundle can steer\n# prompts and candidate generation, change graded-novelty admission / pre-execution choices, and add\n# paid LLM work when its client and memory prerequisites are present. The bare-library `EngineOptions`\n# default stays lean (see tests/test_options_divergence.py — product side is the aggressive one) so a\n# toy `Engine(...)` in a test doesn't fire cross-run LLM work unasked. The trust/confirm/ablate QUALITY\n# bundle still ships OFF and is turned on by `thorough`. Every value here is reachable by hand — the\n# profile is a convenience, never a hidden mode.\n# keep this rationale behavioral and evidence-based; do not relabel read-only storage\n# access or proposal-only governance as a no-op for agent behavior, cost, or proposal admission.\n#\n# `thorough` deliberately touches only QUALITY/TRUST machinery, not spend: it does NOT raise\n# max_nodes / eval_parallel (those are cost knobs the user owns). It enables multi-seed confirmation\n# (demote seed-lucky leaders), the reward-hack / leakage / critic monitors AND flips them from\n# advisory to *gating* (`trust_gate=\"gate\"` — a\n# flagged win can no longer be selected as best), ablation-driven refinement, and the prompt cues\n# (complexity / budget / failure-reflection / watchdog-reflection). Agentic novelty and the experimental\n# Part IV/V bundle are already product defaults, not profile changes. Effect is large; marginal cost is a\n# few extra evals.\nPROFILES: dict[str, dict] = {\n \"default\": {}, # explicit no-op alias for \"ship-as-is\" (== omitting profile)\n \"fast\": {}, # today's lean defaults, named for symmetry with `thorough`\n \"thorough\": {\n \"confirm_top_k\": 3,\n \"confirm_seeds\": 3,\n # novelty_gate intentionally NOT enabled here: duplicate-detection is the agentic Researcher's\n # job (it reads past experiments + find_analogous_across_runs and DECIDES), not an algorithmic\n # param-distance / embedding auto-reject that mis-fired (it nudged a good fresh idea onto a\n # twice-dead lineage — live node 61). Search stays as tools; the LLM judges novelty.\n \"operator_bandit\": True, # P4: adaptive operator mix over folded yields (GreedyTree)\n \"reward_hack_detect\": True,\n \"code_leakage_detect\": True,\n \"critic_check\": True,\n \"trust_gate\": \"gate\", # detectors stop gating a flagged node from WINNING (not just audit)\n \"ablate_every\": 3,\n \"complexity_cue\": True,\n \"budget_aware\": True,\n \"failure_reflection\": True,\n \"watchdog_reflection\": True, # feed recent live-watchdog (train-monitor/ASHA) flags to proposals\n \"reflection_priors\": True, # no-op unless memory_dir is set (cross-run priors)\n },\n}\n\n\n# The shipped agent-governance matrix (who may change which setting at runtime). Defined as a module\n# constant so the Engine's direct-construction default matches this shipped default exactly (the\n# EngineOptions \"Engine() == shipped defaults\" invariant) instead of diverging to an all-locked map.\nDEFAULT_AGENT_CONTROL: dict[str, list[str]] = {\n \"timeout\": [\"researcher\", \"strategist\"],\n # Layer-2 canonical names are the presented/default governance surface. `_agent_may` falls back\n # to an old snapshot's legacy grant only when its canonical counterpart is absent, so removing\n # duplicate defaults preserves resume authority while an explicit canonical revocation still wins.\n \"eval_parallel\": [\"strategist\"],\n \"llm_parallel\": [\"strategist\"],\n # Strategy-only pseudo-setting: the closed per-lane allocation inside the canonical LLM total.\n # It is not a launch Settings field; operators pin it through `set_strategy` and the Strategist\n # may reallocate it only while this grant is present.\n \"llm_lane_limits\": [\"strategist\"],\n \"max_nodes\": [\"strategist\", \"boss\"],\n \"max_eval_seconds\": [\"strategist\", \"boss\"],\n \"policy\": [\"strategist\"],\n # The policy NAME and its PARAMS are gated independently in `_apply_strategy`; grant both by default\n # so the Strategist that may switch to (say) MCTS may also apply the `c`/`eta` it decided — else its\n # tuned params are silently dropped and the recorded `active_strategy` diverges from the live engine.\n \"policy_params\": [\"strategist\"],\n \"n_seeds\": [\"strategist\"],\n \"ablate_every\": [\"strategist\"],\n \"merge_mode\": [\"strategist\"],\n \"complexity_cue\": [\"strategist\"],\n \"ablate_code_blocks\": [\"strategist\"],\n \"prefer_sweep\": [\"strategist\"],\n \"novelty_stance\": [\"strategist\"],\n \"developer\": [\"strategist\"],\n \"fidelity\": [\"strategist\", \"researcher\"],\n}\n\n\ndef default_agent_control() -> dict[str, list[str]]:\n \"\"\"A FRESH deep-ish copy of the shipped governance matrix (each role list copied so a caller can\n mutate its own map without touching the module constant). The SINGLE constructor of the default,\n called by both `Settings.agent_control`'s default_factory AND the Engine's direct-construction\n default — so \"Engine() == shipped defaults\" can't drift if the copy depth ever needs to change\n (e.g. nested values requiring copy.deepcopy), instead of two verbatim copy expressions.\"\"\"\n return {k: list(v) for k, v in DEFAULT_AGENT_CONTROL.items()}\n\n\n# The closed set of `developer_backend` values: \"default\" (the in-house Developer) plus the external\n# coding-agent keys `agents/cli_agent.py::PRESETS` implements. It lives HERE, in the bottom layer,\n# because `Settings` validation needs it on every construction and `core` imports nothing above\n# itself — validating against the agents registry directly made this the ONE upward import out of\n# core in the tree, so an import-time error anywhere in agents/cli_agent.py broke ALL config\n# loading (doc 25 XP-04).\n#\n# The authority is not lost, it is inverted: `agents/cli_agent.py` asserts its PRESETS match this\n# set, and `tests/test_developer_backend_registry.py` checks BOTH directions. Adding a preset\n# without adding it here is a red test, not a backend that silently downgrades to the default.\nDEVELOPER_BACKENDS: tuple[str, ...] = (\"default\", \"aider\", \"continue\", \"goose\", \"opencode\")\n\n\nclass Settings(BaseSettings):\n \"\"\"The engine settings schema (every knob a run accepts).\n\n Timeout family — six distinct knobs, each owned by a different subsystem:\n - `timeout`: per-eval wall-clock budget for ONE experiment's evaluation (engine/eval).\n - `max_eval_timeout`: hard ceiling for a governed per-node Researcher timeout override.\n - `llm_timeout`: LLM request idle timeout — inter-token stall limit in stream mode\n (core.llm OpenAICompatibleClient).\n - `llm_header_timeout`: LLM first-byte (response-headers) window for stream attempts (core.llm).\n - `agent_time_budget_s`: wall-clock ceiling across a tool-using agent loop's turns (agents).\n - `dep_install_timeout`: per-package budget for auto-installing a missing dep (env self-prep).\n\n Config-source precedence is owned by `looplab.core.appconfig` (the loader) — see its module\n docstring for the one canonical order.\n \"\"\"\n # Config precedence: see appconfig.py — the loader owns the full order. The `.env` is read\n # so `looplab run`/`looplab ui` pick up LOOPLAB_LLM_BASE_URL etc. without exporting them by hand\n # (keys MUST carry the LOOPLAB_ prefix, same as the env vars). utf-8-sig tolerates a BOM from\n # Windows editors. The test suite disables this (tests/conftest.py) so a dev's real .env in the\n # repo root can't leak into assertions.\n model_config = SettingsConfigDict(\n env_prefix=\"LOOPLAB_\",\n env_file=\".env\",\n env_file_encoding=\"utf-8-sig\",\n extra=\"ignore\",\n allow_inf_nan=False,\n )\n\n @classmethod\n def settings_customise_sources(\n cls, settings_cls, init_settings, env_settings, dotenv_settings,\n file_secret_settings):\n \"\"\"Preserve source precedence across canonical/legacy parallelism names.\n\n Pydantic ranks init > env > dotenv correctly, but it ranks field names rather than semantic\n alias families. Promoting a legacy value inside each source before the merge makes an old\n explicit snapshot/file value beat a lower-priority canonical env value. ``appconfig`` also\n promotes its file/typed/--set sub-layers before combining them into the init source.\n \"\"\"\n def _with_parallel_aliases(source):\n def _read():\n values = canonicalize_parallelism_source(source())\n # Credential components are a record, not independently mergeable settings. If a\n # winning source supplies only one half, insert an explicit None for its peer so a\n # lower-priority source can never complete an attacker-controlled mixed pair.\n pair = {\"llm_api_key\", \"llm_api_key_base_url\"}\n present = pair.intersection(values)\n if present and present != pair:\n values = dict(values)\n values[next(iter(pair - present))] = None\n return values\n return _read\n\n return (\n _with_parallel_aliases(init_settings),\n _with_parallel_aliases(env_settings),\n _with_parallel_aliases(dotenv_settings),\n _with_parallel_aliases(file_secret_settings),\n )\n\n # Run profile (config-first preset, see PROFILES above). \"default\"/\"fast\" = today's lean\n # defaults; \"thorough\" turns the built quality/trust machinery ON in one word. A profile only\n # fills fields the user did NOT set — every knob it touches stays individually overridable.\n profile: str = \"default\"\n\n # Bounds (review C/⚪): a non-positive node/seed budget or timeout is never valid. Upper bounds are\n # equally load-bearing — the UI /start path writes these straight into the engine's env, and an\n # unbounded `eval_parallel` (or legacy `max_parallel`; e.g. 100000 from a crafted preflight) would\n # fan out that many sandboxes → resource exhaustion. Reject at config time, not mid-run. Ceilings\n # are generous; tests top out near 50.\n # Pydantic's lax mode coerces a JSON boolean into any numeric field (`true` -> 1), so a UI/API type\n # error used to silently collapse a run budget to ONE node instead of returning 422. Rejected for\n # EVERY numeric field by `_reject_bool_for_numeric_fields` below, not just the ones bounded here.\n n_seeds: int = Field(default=3, ge=1, le=1024)\n max_nodes: int = Field(default=8, ge=1, le=1_000_000)\n # legacy evaluation-width input. `eval_parallel` below is canonical and wins when set;\n # keep this field for old env/config/snapshots and direct Engine call sites. A width > 1 admits\n # concurrent evals, whose resource reservations may be CPU-only or span one/more GPUs — there is no\n # one-node/one-GPU promise. `max_parallel=0` is launch-time AUTO (detected GPU count, at least 1).\n max_parallel: int = Field(default=1, ge=0, le=1024)\n # legacy build-width input. `llm_parallel` below is canonical and also controls the\n # shared provider-call total when positive. A width > 1 uses fresh pooled researcher/developer pairs;\n # it says nothing about the later eval's CPU/GPU reservation. Startup `0` derives build fan-out from\n # resolved eval width; a live Strategist/operator zero settles to 1. Without role_factory it clamps to 1.\n parallel_build: int = Field(default=1, ge=0, le=64)\n # Hypothesis-card Kanban re-architecture (docs/23, Layer 2). Two INDEPENDENT concurrency axes,\n # decoupled from each other so cost (LLM work) can be tuned separately from throughput (GPU evals).\n # These are the CANONICAL names going forward; `max_parallel`/`parallel_build` above are kept as thin\n # back-compat aliases (env vars / snapshots / ~100 Engine(...) call sites depend on the exact names,\n # so they cannot be renamed — CLAUDE.md). Resolution (orchestrator.__init__): a NEW name, when set,\n # WINS over its legacy alias; None => fall back to the legacy field => byte-identical to today.\n # `eval_parallel`: concurrent EVALS (the GPU/experiment consumer). None -> max_parallel; 0 = AUTO\n # (one experiment per detected GPU). This is the in-Run eval-concurrency ceiling; the resource\n # scheduler bin-packs declared footprints separately. Sibling local GPU-owning Runs serialize\n # through the conservative host-pool lease; external execution needs its own admission boundary.\n # `llm_parallel`: total concurrent LLM provider calls + BUILD fan-out. None -> legacy\n # parallel_build semantics without a shared total; 0 = startup AUTO (= resolved eval_parallel\n # for build fan-out, historical research overlap unbounded). A positive canonical value enables\n # the named-lane broker the Strategist can reallocate at cadence.\n eval_parallel: int | None = Field(default=None, ge=0, le=1024)\n llm_parallel: int | None = Field(default=None, ge=0, le=64)\n # Training-log monitor (I-series watchdog family): a per-eval background observer that tails the live\n # training log while a (often multi-hour) declared stage runs in a worker thread. ON in the product\n # surface (Settings). Its alert event is fold-ignored and cannot directly change lifecycle/champion;\n # with watchdog_reflection on, that alert is nevertheless advice in a later Researcher\n # prompt and can change future proposals. It only fires on the command-eval path with an LLM client;\n # bare-library EngineOptions stays OFF. See test_options_divergence.\n # `_interval_s` is the BASE tick cadence (the effective one adapts to the per-experiment budget).\n train_monitor: bool = True\n train_monitor_interval_s: float = Field(default=600.0, gt=0)\n # Phase 3 — INTERVENTION (opt-in, separate from observation): let the monitor tree-kill a training the\n # LLM judges 'broken' (diverged / silent CPU fallback / not learning) EARLY, instead of burning the\n # whole budget. Off by default — the observer only WATCHES unless this is on. A kill only fires on a\n # 'broken' verdict (a plateau is 'watch', never killed) with confidence >= the threshold; the node then\n # fails normally (reason='monitor_broken'), so replay reconstructs it from that one terminal event.\n train_monitor_kill: bool = False\n train_monitor_kill_confidence: float = Field(default=0.8, ge=0.0, le=1.0)\n # ASHA live-curve watchdog (sibling of the training monitor): reads the latest INTERMEDIATE value of\n # the objective metric off the live log (reusing the eval's OWN metric reader). Finished-endpoint rank\n # remains advisory. An opt-in KILL additionally requires an operator-declared metric.resource_key and\n # enough sibling observations at exactly that resource; absent comparable evidence cannot stop a run.\n # Records a fold-ignored `asha_rank` diagnostic + trace span; the library default is OFF (off == today).\n asha_live: bool = True\n # Opt-in INTERVENTION (separate from the advisory signal): let the watchdog tree-kill a node whose\n # intermediate metric stays below the SAME-RESOURCE bar past a short grace window. Off by default —\n # it only surfaces endpoint rank unless this is on. A kill fails normally (asha_underperforming).\n asha_live_kill: bool = False\n # The bar sits at `asha_live_quantile` along a WORST->BEST ordering of the finished siblings' finals:\n # 0.5 = the median (a node worse than the median peer flags); a SMALLER value is more conservative —\n # it lowers the bar toward the WORST finished peer, so only a node worse than nearly all peers flags\n # (0.0 = worse than the worst). `min_siblings` is the floor of finished peers required before it ranks\n # at all, so it never acts on too little evidence.\n asha_live_quantile: float = Field(default=0.5, ge=0.0, le=1.0)\n asha_live_min_siblings: int = Field(default=3, ge=1)\n timeout: float = Field(default=30.0, gt=0)\n # Hard ceiling for the Researcher's governed per-node `Idea.eval_timeout`. One hour preserves the\n # existing heavy/neural-net authoring example while staying well below the sandbox's defensive\n # 24-hour subprocess ceiling. This is operator-owned run config: agents may request less or more,\n # but the accepted action is always clamped here after the `agent_control.timeout` permission gate.\n max_eval_timeout: float = Field(default=3600.0, gt=0, le=24 * 3600.0)\n # Intra-node sweep: a sweep node runs a whole grid in one process, so it gets this multiple of\n # `timeout` as its wall-clock budget (solution.py path; RepoTasks use their per-profile timeout).\n sweep_timeout_mult: float = Field(default=8.0, ge=1)\n # Eval STALL watchdog CAP (seconds): a stage that is completely SILENT on stdout/stderr for this\n # long — while still alive and BELOW its wall-clock deadline — is tree-killed early with a STALLED\n # marker (a hung dataloader/deadlock dies in minutes instead of burning a multi-hour timeout). The\n # per-stage window is `min(this, the stage's own timeout)`, so a short scorer's deadline fires first.\n # `0` DISABLES the watchdog entirely (only the hard deadline applies) — for a legitimately quiet\n # non-Python stage (block-buffered stdout, a script that logs only to its own file). Threaded into\n # the eval and surfaced to the Developer so its code can emit periodic progress to stay alive.\n eval_stall_timeout_s: float = Field(default=1800.0, ge=0)\n # Sandbox tier (ADR-13): \"trusted_local\" (subprocess, no Docker) for the CLI;\n # \"untrusted\" (Docker --network none, shared-kernel runtime) for hosted/multi-tenant UI;\n # \"hostile\" (untrusted + a true-isolation OCI runtime, gVisor `runsc` by default / Kata) for\n # running untrusted code. See runtime/sandbox.py::make_sandbox for the tier selection.\n trust_mode: str = \"trusted_local\"\n # Image for the untrusted command-eval tier (RepoTask, Phase 4): the framework's deps\n # should be baked in (the container runs --network none, so a pip setup can't fetch).\n docker_image: str = \"python:3.12-slim\"\n # Resource caps for the untrusted/hostile Docker tier (passed to `docker run --memory/--cpus`).\n # The default 4g bound protects other tenants from an OOM, but a model-training MLE-bench eval\n # routinely needs more — raise it (e.g. \"16g\") for such runs. \"\" disables the cap (unbounded).\n # Ignored by the trusted_local (subprocess) tier.\n sandbox_memory: str = \"4g\"\n sandbox_cpus: str = \"\"\n # Best-effort host-OOM guard for the TRUSTED-LOCAL (subprocess) tier. When set (e.g. \"8g\"), each\n # eval child gets an RLIMIT_AS address-space cap so a runaway trainer hits MemoryError instead of\n # OOM-killing the whole host (and the engine). POSIX only; a no-op on Windows. Default \"\" = OFF:\n # RLIMIT_AS bounds VIRTUAL memory, and CUDA/torch reserve tens of GB of virtual without using it,\n # so a default cap would spuriously kill GPU evals — leave it off for those and use the Docker\n # tier's real --memory cgroup bound (sandbox_memory) instead. Ideal for CPU/numpy/sklearn runs.\n sandbox_memory_local: str = \"\"\n # Best-effort disk-fill guard for the TRUSTED-LOCAL tier (P1-5): an RLIMIT_FSIZE cap on the size of\n # any single file an eval child writes (e.g. \"2g\"), so a runaway that writes an unbounded file gets\n # SIGXFSZ instead of filling the host disk. POSIX only. Default \"\" = OFF (large model checkpoints\n # need big files); set it for tasks that write only small artifacts.\n sandbox_fsize_local: str = \"\"\n # Search policy (ADR-2): \"greedy\" | \"evolutionary\" | \"mcts\" | \"asha\" | \"bohb\".\n # `bohb` is a registered policy (`search/policy.py::_REGISTRY` aliases it to the ASHA factory,\n # which BOHB reuses as its racing half) and is advertised by `appconfig.render_template` and the\n # `bohb` template kind; this comment used to omit it and undersell the valid set. The value is\n # not validated at config time, but `make_policy` raises on an unknown name — loud-late rather\n # than silent, unlike `developer_backend` above.\n policy: str = \"greedy\"\n # Ablation-driven refinement (I7): every N improves, ablate the best to find the\n # highest-impact parameter and refine it. 0 = off. (GreedyTree only.)\n ablate_every: int = 0\n # A0a (MLE-STAR): when ablating, treat each generated pipeline *code block* (not just a\n # numeric param) as an ablation unit — neutralize a block, measure the metric delta, refine\n # the highest-impact block. Off => the classic numeric-param ablation. Config-first knob.\n ablate_code_blocks: bool = False\n # A0b: real merge/ensembling. \"mean\" = legacy mean-param merge; \"ensemble\" = the Developer\n # writes a code-recombination ensemble over the two parents' solutions (verified: agent-proposed\n # ensembling 37.9%->43.9%). \"auto\" (default) = ensemble whenever the Developer actually\n # generates code (LLM/agent backends; declared via `is_code_generating`), mean for the\n # templated/toy developers where a code ensemble is meaningless — the evidence says code\n # recombination is the strongest single merge (removing it costs ~9 pp), so it is the default\n # wherever it can work. Falls back to mean when the Developer can't ensemble.\n merge_mode: str = \"auto\"\n # A0d (AIRA): inject a dynamic complexity hint into the draft/improve prompt keyed on the\n # node's child count (few children -> keep minimal; many -> escalate to ensembling/HPO).\n complexity_cue: bool = False\n # I1 (CAAFE-style) LLM feature-engineering: instruct the proposer/developer to add\n # semantically-meaningful engineered features (ratios, interactions, aggregations) as code. The\n # existing cross-validation eval + best-selection is the CV GATE — a feature set that doesn't\n # improve CV is never selected (feature engineering is non-universal, so this gate is mandatory).\n # Tabular tasks (those exposing column data). Off by default.\n feature_engineering: bool = False\n # A5: surface the REMAINING eval-compute budget into the proposal prompt so the Researcher can\n # reason over how much compute is left (explore broad while flush, exploit/cheapen when low).\n # No-op unless a max_eval_seconds budget is set.\n budget_aware: bool = False\n # A4 (LATS-style): feed a summary of the most recent FAILED branches (operator + error reason)\n # back into the proposal prompt so the proposer reflects on and avoids repeating them. ON by\n # default: it is SELECTIVE by construction (injects only when recent failures exist — the\n # evidenced form of reflection; reflecting at every step shows no gains, arXiv:2506.12928).\n failure_reflection: bool = True\n # Feed the LIVE-WATCHDOG observations (train-monitor health verdicts + ASHA intermediate-rank flags)\n # of recent experiments back into the proposal prompt, so the Researcher avoids re-proposing a config\n # whose TRAINING was already observed to be weak — even when the watchdog kills are off (default) and\n # the node ran to completion (those diagnostics are fold-ignored, so failure_reflection never sees\n # them). ON by default and SELECTIVE like failure_reflection (injects only when a recent flag exists).\n watchdog_reflection: bool = True\n # C3 deep test-driven repair: hand the Developer the failure taxonomy + a structured \"reproduce\n # then fix\" directive on debug, not just the raw stderr tail. ON by default (product surface); the\n # conservative library default in EngineOptions stays off, per that module's contract.\n deep_repair: bool = True\n # === Inline crash repair ==============================================================\n # Hybrid in-node crash repair: when an LLM-generated node CRASHES at runtime (mechanical errors\n # — bad import, removed kwarg, typo), the agent triages it and may repair the code IN PLACE within\n # the same eval (cheap; does NOT consume max_nodes and does NOT add a node to the search tree).\n # Semantic failures still flow to a new idea/debug node via the policy. ON by default; set False\n # to restore the prior behavior (every crash waits for a budgeted inter-node debug node).\n inline_repair: bool = True\n # Max in-place repair attempts per node before it fails normally. Default 0 = UNLIMITED: the same\n # agent keeps fixing + re-evaluating IN THE SAME node until it produces a clean metric — no new\n # node just to retry. Runaway is prevented not by a count but by the anti-stuck guard\n # (`inline_repair_stuck_repeat`): when the SAME error signature repeats with no progress, or the\n # agent's crash-triage says \"abandon\", the node fails. Set a positive N to cap the retries instead.\n inline_repair_attempts: int = Field(default=0, ge=0)\n # Anti-stuck: abandon in-node repair when the SAME error recurs this many times in a row (no\n # progress). Keeps \"unlimited\" repair from looping forever on an unfixable error.\n inline_repair_stuck_repeat: int = Field(default=4, ge=2)\n # Which failure reasons (_failure_reason: crash|timeout|oom|setup|no_metric|drift) are eligible for\n # inline repair. Default: mechanical crashes, timeouts AND OOM-kills — a timeout/OOM means the code\n # was too slow / too memory-hungry for the budget (or the pod's cgroup limit), not that the idea is\n # wrong, so the agent repairs it by reducing compute/memory (fewer estimators/epochs/folds/seeds, a\n # lighter model, a smaller batch, subsampling) instead of letting the node die unrecovered. Env\n # override expects a JSON array.\n inline_repair_reasons: tuple[str, ...] = (\"crash\", \"timeout\", \"oom\")\n # Cost ceiling for in-node repair of a MULTI-STAGE eval: the max number of FULL pipeline re-runs\n # (i.e. re-trains from the first stage) the inline-repair loop may do before abandoning to the\n # inter-node debug operator. A late-stage failure (a broken `score`/eval script that didn't touch\n # `train`) REUSES the completed train checkpoint and re-runs only from the failed stage — that is\n # CHEAP and NOT counted here; only a repair that changes an EARLIER stage's code (train.py/loss.py/…)\n # forces a full re-train, which each costs the whole training time. Without this bound, a repair that\n # keeps changing training code could burn many full trains (the anti-stuck guard is error-signature\n # based, not cost based). 0 = unlimited (legacy behavior). Single-command evals ignore it.\n inline_repair_retrain_cap: int = Field(default=2, ge=0)\n # Environment self-prep: when a solution crashes purely because a KNOWN library isn't installed\n # (ModuleNotFoundError), the engine pip-installs it into the eval interpreter and re-runs — so a\n # torch/XGBoost/CatBoost idea (e.g. a GRU model) actually runs on a fresh box instead of being\n # thrown away as `idea_rejected`. Trusted_local tier only (the untrusted/hostile Docker tiers run\n # --network none). A typo'd or local helper module is NOT installed (that's a code bug to repair).\n auto_install_deps: bool = True\n # Per-package wall-clock budget for an auto-install (seconds). Generous: large wheels like torch\n # can take minutes to download/build.\n dep_install_timeout: float = Field(default=900.0, gt=0)\n # --- Agent governance: who may change a setting at runtime (per-setting allow-list of roles) ---\n # Roles: \"strategist\" (A7 meta-controller; run-wide), \"boss\" (run-chat operator-proxy; run-wide),\n # \"researcher\" (per-experiment proposer; PER-NODE — e.g. set a longer eval timeout for a neural-net\n # idea). A setting ABSENT from this map is LOCKED — no agent may change it, only a human via the\n # snapshot/UI. The sole conditional exception is Strategy-only `card_scoring`: Card mode grants\n # it implicitly without changing the default flag-off snapshot, while an explicit [] revokes it.\n # The gate is ENFORCED at every AGENT seam via `_agent_may` — the strategist's whole\n # control surface (policy/operators/novelty_stance/prefer_sweep/developer/fidelity/timeout/\n # eval_parallel/llm_parallel) is checked in `_apply_strategy`, so removing a role from a knob locks it,\n # not just greys out the UI pill. The boss/max_* grants document boss-relevant knobs, but a\n # `budget_extend` is a HUMAN control intent (the boss action-builder can only emit `add_nodes`), so\n # it is applied as-is — the human always wins via the UI/snapshot. Defaults are conservative: resource/\n # search-shape knobs the agents already reason about; provider infra (llm_model/base_url/api_key,\n # docker_image) stays locked. The UI shows S/B/R pills per setting. `timeout` granted to the\n # researcher IS the \"auto\" per-node mode — the researcher sizes each experiment's wall-clock\n # (Idea.eval_timeout); the config `timeout` is the fallback for nodes it doesn't size. Env override\n # expects a JSON object. (`fidelity`/`novelty_stance`/`prefer_sweep`/`developer` are governance keys\n # for the strategist's per-node dials — not 1:1 Settings fields, but gated the same way.) The default\n # lives in the module-level DEFAULT_AGENT_CONTROL so the Engine's direct-construction default matches.\n agent_control: dict[str, list[str]] = Field(default_factory=default_agent_control)\n # C1 (Agentless localization): for repo tasks, rank the source files most relevant to the most\n # recent failure (traceback + identifiers) and surface them in the proposal/repair prompt so the\n # Developer edits the right place. Off by default; repo tasks only.\n localize_faults: bool = False\n # A2 surrogate-guided proposal (BO-lite): fit a k-NN surrogate over (params->metric) and propose\n # by acquisition instead of random/hill-climb. Numeric-bounds tasks only; bootstraps via the\n # base Researcher. Off by default. `surrogate_explore` = UCB-style exploration weight.\n surrogate_proposer: bool = False\n surrogate_explore: float = 0.1\n # E2 researcher panel: generate K candidate ideas per proposal and keep the one ranked best by a\n # cheap empirical surrogate over the (params->metric) history (NOT an LLM-judge). 1 = off.\n researcher_panel: int = 1\n # A1 ASHA / successive-halving: reduction factor (keep top 1/eta per rung) and rung budget.\n asha_eta: int = Field(default=3, ge=2)\n # Rung-0 width for ASHA/BOHB (the wide base of cheap drafts). 0 = use n_seeds (default,\n # preserving prior behavior); set >0 to seed a wider/narrower base independent of n_seeds.\n asha_rung_nodes: int = Field(default=0, ge=0)\n # A6 proxy/predictive scoring: cheaply rank a candidate's potential from early-stage signals\n # and skip a full eval for the doomed bottom fraction. 0.0 = off (no candidate skipped).\n proxy_scoring: bool = False\n proxy_kill_fraction: float = Field(default=0.0, ge=0.0, le=0.9)\n # === Novelty / dedup ==================================================================\n # E1 novelty/dedup gate: reject near-duplicate proposals (normalized param-space distance below\n # `novelty_epsilon`) by deterministically nudging them off the duplicate — stops the search\n # wasting evals re-trying the same idea. Audit event `novelty_rejected`. Off by default.\n # Novelty/dedup MODE selector: how a fresh proposal is checked against already-tried experiments.\n # \"off\" — no explicit gate; the agentic Researcher's own read-the-history judgment is trusted.\n # \"algo\" — the deterministic gate: numeric param-distance (`novelty_epsilon`) + optional embedding\n # similarity (`novelty_semantic`). Cheap, no LLM call, but can't explain itself.\n # \"llm\" — an LLM adjudicates (reads the real experiments via tools) whether the idea is a\n # near-duplicate and, if so, asks the Researcher once more for something different. One\n # extra LLM call per proposal — highest quality, follows the \"let the LLM decide\" line.\n novelty_mode: str = \"llm\"\n # Legacy sub-toggles, honored by the \"algo\" mode (and back-compat: novelty_gate=True forces \"algo\"):\n novelty_gate: bool = False\n novelty_epsilon: float = 0.05\n # T5 semantic novelty (active whenever the deterministic gate runs — novelty_mode=algo,\n # novelty_gate=true which is the legacy alias for algo, OR the Strategist novelty stance is\n # \"explore\"; a no-op only under novelty_mode=llm/off with a non-explore stance): reject a proposal\n # whose idea TEXT embeds within\n # `novelty_semantic_threshold` cosine of an existing node's. OFF BY DEFAULT: novelty is the agentic\n # Researcher's call, made by READING the actual prior experiments (read_experiment /\n # find_analogous_across_runs / list_experiments), NOT an embedding-similarity auto-reject that can't\n # explain itself and mis-fires on paraphrases. Semantic/param search stays available as TOOLS in the\n # Researcher's context; the LLM decides whether an idea is a true duplicate.\n novelty_semantic: bool = False\n novelty_semantic_threshold: float = Field(default=0.92, ge=0.5, le=1.0)\n # T10: debug-lineage depth bound for every policy — how many error-feedback repairs a failing\n # lineage gets before it is abandoned. The old default (1) abandoned lineages after ONE repair;\n # multi-turn/deeper debugging is a verified lever (AIRA2 ReAct debug +5.5 percentile points).\n debug_depth: int = Field(default=2, ge=1)\n # P4 operator bandit (GreedyTree): replace the fixed merge/ablate cadences with a\n # deterministic UCB over per-operator yield (Δmetric per eval-second, folded from the run).\n # Off by default (the cadences are well-tested and the bandit has no direct published\n # ablation); `thorough` turns it on.\n operator_bandit: bool = False\n # P1 hypothesis ledger: ask the Researcher to state the one-line hypothesis each experiment tests,\n # register deep-research directions as hypotheses, and track them to a verdict on the board. ON by\n # default. The board is shown to the Researcher and can be foresight-ordered, so it can\n # steer later proposals even though it never directly re-ranks already-evaluated nodes. Set False\n # to drop the prompt nudge + registration.\n track_hypotheses: bool = True\n # E4/M2/M3 reflection-memory -> priors (gradient-free cross-run meta-learning): at run end distill\n # the winner into `<memory_dir>/meta_notes.jsonl` AND structured lessons (incl. NEGATIVE results:\n # tested/abandoned hypotheses + failure themes) into `lessons.jsonl` with a task fingerprint; at run\n # start, inject exact-task notes + fingerprint-matched lessons from SIMILAR tasks into the proposal\n # prompt. ON by default — but a NO-OP until `memory_dir` is set (that's where cross-run memory lives).\n reflection_priors: bool = True\n # M6 comparative lessons (MARS \"comparative reflective memory\", doc 13 §7 item 2): distill\n # credit-assigned lessons from PAIRS of solutions — which SPECIFIC change made a child beat\n # (or regress from) its parent, and what fixed a failure — on top of the one-shot run-end\n # reflection. At most one extra LLM call per distillation (all pairs batched); offline a\n # deterministic param-diff credit stands in. Gated on reflection_priors + memory_dir like the\n # rest of cross-run memory — and since memory_dir has a default (~/.looplab/memory), this is\n # ACTIVE out of the box; clear memory_dir to disable cross-run memory wholesale.\n comparative_lessons: bool = True\n # M6 mid-run cadences (doc 13 §7 item 5 — the AgentRxiv live-share pattern): every N created\n # nodes, WRITE comparative lessons to the shared cross-run store during the run (lessons_every\n # — one batched LLM call per firing) and RE-READ the store so lessons distilled by CONCURRENT\n # runs reach this run's proposals (lessons_refresh_every; file re-read, no LLM call, skipped\n # when the store is unchanged). ON by default (every 4 nodes) and, like comparative_lessons\n # above, live out of the box because memory_dir defaults on; 0 = off — run-end write /\n # run-start read only, the pre-M6 behavior.\n lessons_every: int = Field(default=4, ge=0)\n lessons_refresh_every: int = Field(default=4, ge=0)\n # B3 output redaction: mask credentials (known key shapes + high-entropy tokens) in the\n # stdout/stderr tail before it is persisted to the event log / spans / UI — a leaked secret in a\n # print()/traceback must not land in the durable log. Off by default; recommend on for untrusted.\n redact_output: bool = False\n # B5 reward-hacking detector: a host-side monitor that flags suspicious wins (grader/answer-key\n # access, runtime writes to frozen files, suspiciously-perfect metrics) as a `reward_hack_suspected`\n # audit event in the Trust panel. Off by default. Whether a flag CHANGES selection is governed by\n # `trust_gate` below (default: audit-only, never changes selection).\n reward_hack_detect: bool = False\n # Trust enforcement (T2): what a reward-hack / data-leakage flag actually DOES to selection.\n # \"audit\" (default) = surface it in the Trust panel, never change selection (today's behavior);\n # \"gate\" = a flagged node can no longer be selected as BEST **and isn't bred/confirmed from**\n # (breed_excluded — see replay._apply_trust_gate / models.breedable_nodes), but stays\n # FEASIBLE so it still counts for diversity/audit — closes the \"a hacked/leaky node can\n # win OR seed its lineage\" hole;\n # \"block\" = additionally mark it fully INFEASIBLE (removed from feasible_nodes entirely).\n # Deliberately gates only high-precision CHEATING/LEAKAGE signals. Broad `critic:*` findings stay\n # advisory; `critic:hardcoded_metric` is the one narrow high-precision critic exception\n # and gates because it requires a literal metric with no computed assignment anywhere.\n trust_gate: str = \"audit\"\n # I3 data-centric: static code-leakage scan of each evaluated solution (fit-before-split,\n # fit-on-test) surfaced into the Trust panel alongside reward-hack flags. Off by default.\n code_leakage_detect: bool = False\n # 4.4 sandbox instrumentation (needs reward_hack_detect): after each eval, flag a RUNTIME write\n # to a protected/frozen file (an answer key or grader tampered mid-run) — behavioral evidence a\n # static code scan misses. ON by default; only fires when the reward-hack detector is on.\n workdir_audit: bool = True\n # D8 decoupled research Verifier: check every Deep-Research memo's CLAIMS against their cited\n # evidence (node ids / urls) before recording — synthesis is the documented weak link (Kosmos\n # 57.9% accurate). Deterministic layer always; an LLM rubric pass when the DeepResearcher has a\n # client. The verdict cannot change this run's node/champion, but finalize uses aligned supported\n # verdicts as the evidence gate for persisted D8 cross-run claims; unavailable/misaligned stays\n # unverified. ON by default (no-op with no memo/claims).\n research_verify: bool = True\n # C4 independent critic: an execution-free critic of each solution (stub / hardcoded-metric /\n # params-ignored; on host-graded tasks the metric checks become a submission-output check)\n # surfaced in the Trust panel. Broad findings are advisory; `critic:hardcoded_metric` can gate under\n # trust_gate=gate/block. Off by default.\n critic_check: bool = False\n # A7 Strategist (NEW, user-requested): optional LLM/rule meta-controller that picks the search\n # policy/allocator + operator mix + fidelity (+ Developer backend) per situation. Config-first:\n # every choice the Strategist makes is also a direct knob. Defaults to \"agent\" so the agent adapts\n # the search policy/operators/fidelity per situation; set \"off\" for static config-driven search.\n # \"agent\" = a tool-using Strategist that READS the run/data/sibling-runs/KB/memory (B1-guarded)\n # before deciding, instead of the single-shot \"llm\" call over aggregate stats.\n strategist_backend: str = \"agent\" # \"off\" | \"rule\" | \"llm\" | \"agent\" — default AGENTIC: the\n # Strategist READS the run/data/sibling-runs/KB/memory with tools before deciding, not a single-shot\n # call over aggregate stats. \"llm\" = the old non-agentic single-shot; \"rule\"/\"off\" = no LLM.\n strategist_every: int = Field(default=3, ge=1) # consult cadence (created nodes)\n # PART V (F1): concept CLASSIFIER re-tag + consolidation cadence, DECOUPLED from strategist_every. The\n # LLM concept map is heavier and slower-moving than a strategy consult, so it refreshes on its own\n # (sparser) interval. Researcher-authored idea.concepts still fold immediately at node_created — this\n # only paces the classifier-evidence + consolidation refresh (and the concept-coverage pivot snapshot).\n concept_retag_every: int = Field(default=30, ge=1)\n # === Confirmation & holdout ===========================================================\n # Multi-seed confirmation (I12, ADR-15): confirm the top-k under N seeds before\n # finishing. 0 disables (default). Only meaningful when eval has variance.\n confirm_top_k: int = 0\n confirm_seeds: int = 0\n # D1 seed-holdout discipline: the FIRST seed the confirm phase uses. Search evals run with the\n # implicit seed 0 (LOOPLAB_EVAL_SEED unset -> \"0\"), so a base of 1 keeps every confirm split\n # DISJOINT from anything the search optimized against — the confirm metric is then a\n # generalization signal, not a re-measurement (AIRA: selecting on a signal the search saw\n # overfits by 9-13 pp). Set 0 to restore the legacy overlapping seeds 0..N-1.\n confirm_seed_base: int = Field(default=1, ge=0)\n # D1 holdout-gated promotion (B6, Arbor-style): for host-graded tasks, reserve this fraction of\n # the held-out labels as a FINAL holdout partition the search never sees — every search/confirm\n # eval is scored on the remaining rows only, and at finish the val-top-k are re-scored on the\n # holdout partition (free: the predictions already exist). 0.0 = off (legacy full-label scoring).\n holdout_fraction: float = Field(default=0.25, ge=0.0, le=0.9)\n # Champion selection prefers the HOLDOUT metric among the nodes that have one (the val-top-k).\n # Recorded in run_started so replay applies the same rule; old logs fold to False (legacy pick).\n # The per-node val-holdout `generalization_gap` folds into the Trust panel either way.\n holdout_select: bool = True\n # How many val-leaders get a holdout evaluation at finish (host-graded tasks).\n holdout_top_k: int = Field(default=3, ge=1)\n # R1-c / Part IV — calibrated §12-verifier tie-break in best-selection. When a fresh node's robust\n # metric EXACTLY TIES an already-eligible node, the engine runs the §12 verifier (grounded on each\n # node's realized result, `selection_criteria`) and records a soundness score per node. The tie-break\n # then applies across the WHOLE selection path: the mean pick breaks a robust_metric tie by the score,\n # and — when `holdout_select` is on — the holdout-slot ranking prefers the sounder node so it isn't\n # denied a holdout eval, and the final holdout override breaks a holdout_metric tie by it too. Strictly\n # a TIE-BREAK — it can NEVER move a node ahead of a strictly-better metric (§21.7 advisory-never-\n # overrides). Best-effort: verification is LAZY (only fires on an actual tie) and bounded per cadence,\n # so a large/late tie stays on deterministic metric+id order unless one atomic treatment scores every\n # member. Recorded in run_started so\n # replay applies the same rule; old logs fold to False (legacy pick). Opt-in (default off); needs a\n # reachable LLM client. Enable only after validating calibration offline with `verifier.calibrate`.\n # See trust/verifier.py + core/fitness.py.\n select_verifier: bool = False\n # R1-d (§21.19): widen the verifier tie-break from EXACT-metric to a conservative STATISTICAL tie.\n # The band is capped by both the metric leader's SE and the pooled SE-of-difference, so challenger\n # variance cannot widen it and a significant difference is never a tie (§21.7). Off -> exact only.\n verifier_ci_tie: bool = False\n # Verifier sample count for `select_verifier` (the §12 repeated-sampling expectation). 3 tames the\n # single-shot variance; 1 = single-shot (cheaper, noisier).\n select_verifier_samples: int = Field(default=3, ge=1, le=32)\n # Budget (I13): hard wall-clock ceiling; the run aborts cleanly when exceeded.\n max_seconds: float | None = None\n # Eval-compute budget (#2): hard ceiling on cumulative time spent INSIDE evals (training\n # runs), separate from wall-clock. Survives resume (summed from the event log). The real\n # guard against a silent multi-hour sweep when an eval is a minutes-hours training run.\n max_eval_seconds: float | None = None\n # Cross-run memory (I19, ADR-10): if set, the best result of each run is stored as\n # a case here, and the cases become retrievable knowledge for future runs.\n memory_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"memory\"))\n # HITL (I21, ADR-11): pause for human approval of the final best before finishing.\n require_approval: bool = False\n # Diversity archive (I22): niche bucket width in parameter space.\n archive_resolution: float = 1.0\n # Coverage read-model (narrowing signal): at the strategist cadence, compute a breadth summary\n # (themes / param-niches / theme entropy / dominant-theme fraction) from the folded run, record\n # it as a `coverage_snapshot` audit event, and surface it into the Strategist's decision context.\n # Deterministic, cheap, and purely additive context — no search-behavior change on its own. On by\n # default (like the rest of the always-on situational context). See search/coverage.py.\n coverage_context: bool = True\n # PART IV Phase 2a live steering (§21.11/§21.13). When on, the `concept_retag_every` cadence (NOT\n # `strategist_every` — the producer gates on `_should_consult_concepts`, which uses the seed boundary\n # + `concept_retag_every`, default 30) records a\n # concept-graph coverage + uncovered-region snapshot (deterministic heuristic tagger over the\n # task-type skeleton) and, on an `explore` stance, the Researcher's novelty hint names the exact\n # uncovered regions (\"0 coverage in {negatives/external-mining, distillation} — go there\") instead\n # of the vague \"broaden\". ON by default in the product Settings (ce4a379), EngineOptions off: it\n # only enriches an already-mode-gated explore hint, never forces exploration, and no-ops for a task\n # with no curated concept skeleton. The snapshot is auditable, but its prompt cue can change future\n # candidate generation; it does not directly rewrite best-metric selection. See search/concept_graph.py.\n # DESIGN NOTE (2026-07-17 critique): now ON unconditionally, but a toy / non-ML task yields EMPTY concept\n # tags while still paying the per-node LLM concept cost (~10 calls/node with the full Part IV/V bundle).\n # Consider AUTO-QUIESCING: if the heuristic tagger returns empty for the first N nodes (no concept signal),\n # skip the LLM concept work for the run — keeps it on for ML tasks, free on toy/degenerate ones.\n concept_pivot: bool = True\n # PART IV Phase 2b — D3 graded novelty into the LIVE novelty gate (§21.4/§21.13). When on and the\n # task has a curated concept skeleton, the novelty gate first grades a proposal over the concept\n # graph: a level-4 \"same direction, DIFFERENT implementation\" or a level-5 \"re-opens a\n # wrongly-abandoned FAILED direction\" proposal is ALLOWED through unchanged — the flat LLM/semantic\n # dedup gate can't tell \"this DCL tweak\" from \"the whole DCL branch\" and would wrongly reject a\n # legitimate variant or never re-open a sound-but-killed direction (the node_63 archetype). Levels\n # 1/2/3 (identical / near-dup / prior-run) still fall through to the existing gate. Deterministic\n # (heuristic tagger, no LLM), audit event `novelty_graded`, replay-safe. ON by default in the product\n # Settings (ce4a379), EngineOptions off; no-ops for a task with no skeleton. See search/graded_novelty.py.\n graded_novelty: bool = True\n # PART IV Phase 2b — D7 capability-expansion forced-jump DIRECTIVE (§21.8/§21.13, issue #7). When on\n # and the concept-graph cadence detects action-space LOCK-IN (the search has stayed inside one D5\n # branch for a long consecutive streak) on an `explore` stance, the Researcher's novelty hint\n # ESCALATES from \"broaden\" to \"expand the ACTION SPACE — build the missing infra (external mining,\n # a new data pipeline, a different eval), do NOT swap another {locked} lever.\" The same\n # gate also stamps the proposal's authoritative operator as `expand`; that node competes normally\n # under SearchFitness and its separate operator yield is measured. It does not force a winner.\n # Reads the 2a concept-coverage snapshot, so it needs `concept_pivot` to record one. OPT-IN\n # (default off). See engine/proposal_cues.py + search/lock_in.py.\n capability_expansion: bool = False\n # PART IV cross-run Step 0 (§21.20.12/§21.20.13). Universal task-fingerprint tokenization: drop the\n # ASCII-only `[a-z0-9]` allowlist on goal keywords so a non-Latin goal (Russian, CJK, …) is NOT\n # silently dropped from its cross-run fingerprint (today such a goal keys only on kind/dir/metric and\n # never reaches SIMILAR-task priors/lessons/cases). Uses `[^\\W_]+`/`.casefold()` — same splitting as\n # before, any script. ON by default in the product Settings (ce4a379); the bare-library EngineOptions\n # default stays off, so a run pinned to it won't silently re-key a portfolio mid-flight. See engine/memory.py.\n fingerprint_universal: bool = True\n # PART IV cross-run Step 2 (§21.20). Fill graded-novelty's `prior_concepts` from the cross-run\n # ConceptCapsuleStore: at run end write a per-run concept capsule (the shipped `node_concepts` tags +\n # best outcome, keyed by task_fingerprint) to `memory_dir`; when a later SIMILAR run proposes an idea\n # whose concept was tried before, SURFACE the prior outcome as a `cross_run_prior` audit event (never\n # reject — D3 level 3). Audit-only, off the selection path; ON by default in the product Settings\n # (ce4a379), EngineOptions off. Needs a `memory_dir` to share capsules.\n # See engine/memory.py (ConceptCapsuleStore) + engine/novelty.py.\n # PREREQUISITES (this flag is NOT standalone): the WRITE needs per-run concept tags\n # (`node_concepts`, produced by `concept_pivot`/F1 tagging) — no tags => no capsule; the SURFACE path\n # runs inside the graded-novelty precheck, so it also needs `graded_novelty` on. With only this flag +\n # `memory_dir` it persists/surfaces nothing. Enable `concept_pivot` + `graded_novelty` alongside it.\n # A non-Latin (Russian/CJK) portfolio should ALSO set `fingerprint_universal`, else the capsule's goal\n # tokens are dropped and its fingerprint over-matches on kind/dir/metric alone (the novelty direction\n # gate narrows this, but goal-word similarity is still lost).\n cross_run_concepts: bool = True\n # PART V (B): run-base + node-DELTA concept authoring. When on, once the first evaluated node has\n # authored concepts the engine seeds RunState.run_base_concepts from them (a one-shot EV_RUN_CONCEPTS,\n # idempotent because it only fires while the base is empty), and the Researcher's proposal hint tells it\n # to author only `concepts_added`/`concepts_removed` vs the run base + its parent — minimal per-node\n # annotations that inherit down the DAG (a swap propagates). Off -> every node authors its full concept\n # set as before. Product-on; the bare EngineOptions mirror defaults off (test_options_divergence).\n concept_run_base: bool = True\n # PART IV cross-run Step 5 advisory (§21.20.5). Fold the bounded cross-run CONTEXT PACK — evidence-\n # grounded claims with BOTH support and counter-evidence (Step 4) + a portfolio-coverage line (Step 3) —\n # into the Researcher's proposal prompt, exactly like the E4 cross-run prior note. Advisory ONLY: it is\n # prompt-grounding: it can change the Researcher's later candidates, but does not directly rewrite\n # best-metric selection (§21.7). Reads `memory_dir` (lessons.jsonl + concept_capsules.jsonl); \"\" when\n # empty. ON by explicit experimental product choice; no frozen A/B validation is claimed. The\n # bare-library EngineOptions default stays off (engine/options.py, frozen in\n # test_options_divergence). See engine/proposal_cues.py.\n cross_run_advisory: bool = True\n # PART IV cross-run §21.20.13 (full CR of the lean fuzzy claim merge). Switch the claim read-model to the\n # SCOPE+POLARITY-safe STRUCTURED claim key (engine/claim_key.py): claims from different tasks never\n # merge, opposite polarity (\"X helps\" vs \"X never helps\") is surfaced as a CONTRADICTION instead of being\n # collapsed, paraphrase/inflection variants group by exact structured key (no transitive over-merge), and\n # operator governance is scope-precise (a decision in task A cannot reach a same-worded claim in task B).\n # Affects the `cross_run_advisory` context pack only; ON by default in the product Settings (ce4a379);\n # the bare-library EngineOptions default stays off (engine/options.py). See engine/claims.py.\n cross_run_structured_claims: bool = True\n # PART IV cross-run §22.4 (AGENTIC taxonomy steward). At finalize, when an LLM client is available, let\n # the concept steward (engine/concept_steward.py) review the freshly-updated portfolio concept graph and\n # PROPOSE a curation — merge duplicate slugs / split conflated ones / purge noise. Proposals are LOGGED to\n # `concept_curation_log.jsonl` for operator ratification (via the serve/CLI governance surface); the LLM\n # never mutates fold state. The calls run synchronously during finalize, so they add latency and may add\n # paid inference; a caught steward failure does not prevent terminal completion or trigger a retry.\n # ON by explicit experimental product choice when `memory_dir` + an LLM backend are present; the\n # bare-library EngineOptions default stays off (engine/options.py). See engine/lessons.py.\n cross_run_curation: bool = True\n # Deprecated compatibility flag. Steward output is untrusted and finalize is proposal-only regardless\n # of this value; retained so old snapshots still validate and logs can disclose that auto was requested.\n cross_run_curation_auto: bool = False\n # Role backend (ADR-7/14): \"toy\" (offline optimizer) | \"llm\" (live model). Validated in\n # `_check_trust_gate` alongside the other enum-ish strings — every consumer tests `== \"llm\"`\n # exactly (cli/__init__.py, adapters/tasks.py), so an untyped `--set`/file/env typo (or a mis-cased\n # \"LLM\") used to fall through to the OFFLINE toy roles and quietly produce a run that never called\n # the model. Left as `str`, not `Literal`, so old snapshots still validate through the same path.\n backend: str = \"toy\"\n # Developer backend (ADR-7): \"default\" (templated/LLM from the task) or an external\n # CLI coding agent: \"opencode\" | \"aider\" | \"goose\" | \"continue\". A CLOSED enum (\"default\" +\n # cli_agent.PRESETS keys), validated loudly in `_check_trust_gate` — an unknown value used to reach\n # adapters/tasks.py (`developer_backend not in PRESETS`) and silently wire the DEFAULT in-house\n # developer, the same silent downgrade the `backend` guard closes.\n developer_backend: str = \"default\"\n # C2 best-of-N: generate N candidate implementations per node and keep the best by an\n # execution-free reward (static validity + metric-print). 1 = off. In-house LLM developer only\n # (not external agents — ADR-7 cost rule). The top SWE-bench reliability lever for weak models.\n best_of_n: int = 1\n # D10 list-wise best-of-N selection: when the top static-scorers TIE, break the tie with a\n # comparative LLM selection over the candidates presented together (+~3 pts vs pointwise on\n # GAIA, arXiv:2506.12928). The static score stays the primary filter — the LLM is a weak\n # comparative prior, never the sole oracle. ON by default (no-op when best_of_n==1).\n best_of_n_listwise: bool = True\n # === Foresight / predict-before-execute ===============================================\n # FOREAGENT predict-before-execute (arXiv:2601.05930): use the LLM as an IMPLICIT WORLD MODEL to\n # predict — WITHOUT executing — which candidate / hypothesis will score best, primed with a\n # Verified Data Analysis Report (data profile / brief) + the accumulated experiment memory. Master\n # switch, ON by default; a no-op wherever there is nothing to rank (best_of_n==1 and\n # foresight_panel==1) or no LLM client. See `looplab/search/foresight.py`.\n foresight: bool = True\n # Hypothesis foresight breadth: generate K candidate ideas per proposal and keep the one PREDICTED\n # best pre-execution by the world model — it ranks the STRUCTURAL / text ideas the numeric k-NN\n # surrogate (researcher_panel) is blind to, primed with the data profile + memory (the synergy).\n # >1 enables it (LLM backend only; needs `foresight` on); 2 = on by default at modest cost, 1 = off.\n foresight_panel: int = 2\n # AGENTIC foresight: run the ranking (hypothesis-board prioritization + K-idea pick) as a TOOL-USING\n # loop that can pull actual experiment results / data facts before deciding, instead of a one-shot\n # prediction from a pre-baked report. ON by default (needs foresight + a client; falls back to the\n # one-shot predictor on any hiccup). A few extra LLM calls per proposal; set False for the cheaper\n # single-call ranker.\n foresight_agentic: bool = True\n # §1 foresight confidence gate: the minimum predicted confidence at which a predict-before-execute\n # pick is ACTED ON. Below it the ranker abstains — the K-idea panel falls back to the first\n # proposal, best-of-N falls through to the D10 tie-break — instead of committing a low-confidence\n # choice. 0.0 (default) = OFF (act on every pick, the historical behavior); raise toward ~0.5 to\n # make the world model defer when it isn't sure. Pairs with the foresight track record (§1) the\n # predictor is now primed with. Range 0.0-1.0.\n # BOUNDED like its confidence/fraction siblings (train_monitor_kill_confidence,\n # asha_live_quantile). Unbounded, a typo'd 5.0 validated silently into a gate no score can ever\n # clear, so every foresight pick abstained forever with nothing in the snapshot to say why.\n foresight_min_confidence: float = Field(default=0.0, ge=0.0, le=1.0)\n # PART IV Phase 2c — replace the world model's SELF-REPORTED confidence (measured Pearson≈0 with the\n # realized outcome, §21.12) with a CALIBRATED §12-verifier score. When on and a client is available,\n # after the K-idea ranker picks the predicted-best candidate the §12 verifier scores it (grounded +\n # repeated + criteria-decomposed: `foresight_criteria` — likely to improve the objective, and\n # sound/feasible) and THAT calibrated score becomes the confidence the `foresight_min_confidence` gate\n # and the telemetry use. Degrades to the self-reported confidence without a client or on any verifier\n # error (the telemetry records which source was used). ON by default in the product Settings (ce4a379),\n # EngineOptions off; a few extra LLM calls per acted-on proposal. See looplab/trust/verifier.py + search/foresight.py.\n # REVISIT (cost/quality, not yet resolved): a live classification run measured ~10 LLM calls & ~3.8 min/node\n # on deepseek-v4-flash with the full Part IV/V default bundle; the calibrated verifier is part of that per-node\n # tax. Kept ON deliberately — it raises decision quality (self-reported foresight confidence has ≈0 Pearson\n # with realized outcome), and lowering quality to save calls is the wrong trade. TODO: run a frozen quality/cost\n # A/B (verifier-on vs -off) to quantify the recall@objective gain per extra call before considering a default flip.\n foresight_verify: bool = True\n # Verifier sample count for `foresight_verify` (the §12 repeated-sampling expectation on a no-logprob\n # backend). 3 tames the single-shot variance §21.12 measured; 1 = single-shot (cheaper, noisier).\n # RESUME-critical: this value is persisted in config.snapshot.json, so a hard `le` schema bound would\n # make an old run started with a larger LOOPLAB_FORESIGHT_VERIFY_SAMPLES un-resumable (Settings(**snapshot)\n # would raise on `looplab resume` / finalize-recovery / serve replay). CLAMP into [1, MAX] via a validator\n # instead of rejecting — the runtime cap is preserved without breaking the snapshots/env-compat rule.\n foresight_verify_samples: int = 3\n\n @field_validator(\"foresight_verify_samples\")\n @classmethod\n def _clamp_foresight_verify_samples(cls, v: int) -> int:\n # Coerce a persisted/env value into [1, MAX] rather than reject (see field note above).\n try:\n v = int(v)\n except (TypeError, ValueError):\n return 3\n return max(1, min(v, MAX_FORESIGHT_VERIFY_SAMPLES))\n\n # T7 LLM response cache: serve an identical DETERMINISTIC (temperature 0) request from an\n # in-process content-addressed cache instead of re-hitting the model — cuts cost on\n # retry/panel/verify flows. NEVER caches sampling calls (temp>0: best-of-N / panel / novelty\n # re-propose must vary). Off by default (most role calls run at temperature>0 anyway).\n llm_cache: bool = False\n agent_cmd: str | None = None # override the agent's launcher (path / wrapper)\n # External-agent validation (ADR-7): wrap a CLI-agent Developer with a validator that\n # audits each output (no-op/syntax/crash/timeout), retries with feedback, and falls\n # back to the in-house LLM Developer. Off -> use the raw agent output unchecked.\n validate_agent: bool = True\n agent_max_retries: int = 1 # re-prompts of the agent on an invalid result\n # Patch-gated multi-file agent (ADR-7 Rule 3): run the CLI agent in a git worktree and\n # accept only edits whose paths match `agent_surface` (reject-not-strip out-of-surface\n # touches). Lets the agent create helper modules, not just solution.py. Degrades to\n # whole-file readback if git is unavailable.\n agent_patch_gate: bool = True\n agent_surface: list[str] = [\"*.py\"] # edit-surface allow-list (globs)\n # Trust policy for an agent-authored eval/metric adapter (RepoTask onboarding, Phase 3):\n # \"ratify_freeze\" waits for human approval, \"autonomous\" accepts the proposed spec without that\n # gate, and \"ratify_freeze_drift\" also enforces the configured independent drift cross-check.\n eval_trust_mode: str = \"ratify_freeze\"\n # RepoTask node seeding policy (run-wide fallback; a task/editable `seed_mode` overrides):\n # \"auto\" (default) copies git-tracked files when the editable is a git repo (so a tree bloated\n # with untracked artifacts isn't deep-copied per node), else a full copy; \"tracked\" forces\n # code-only; \"all\" forces a full recursive copy (legacy). Genesis authors per-task from the\n # user's words; this is the global default when unspecified.\n seed_mode: str = \"auto\"\n llm_model: str = \"qwen3:8b\"\n # === LLM / transport ==================================================================\n llm_base_url: str = \"http://localhost:11434/v1\" # Ollama OpenAI-compatible endpoint\n llm_temperature: float = 0.6\n # H3 per-role model presets (5090 recipe): optionally run the Researcher and Developer on\n # DIFFERENT models/endpoints (e.g. Developer=Qwen3-Coder-30B for code, a fast model for breadth).\n # Blank = use the shared llm_model/llm_base_url. Generalizes the role-backend swap to per-role.\n researcher_model: str | None = None\n developer_model: str | None = None\n researcher_base_url: str | None = None\n developer_base_url: str | None = None\n # Per-role sampling temperature (§4.1): the Researcher wants breadth (higher temp = more diverse\n # ideas), the Developer wants determinism (lower temp = fewer syntax slips), the Strategist a\n # steady hand. Each overrides the shared `llm_temperature` for that role only; None = use the\n # shared value (byte-identical to before). Flat + nullable, mirroring the per-role model fields.\n researcher_temperature: float | None = None\n developer_temperature: float | None = None\n strategist_temperature: float | None = None\n # The Strategist had per-role TEMPERATURE but no model/endpoint of its own, so in split mode it\n # always ran on the shared llm_model however the other roles were pointed. These close that gap\n # with the same names/shape as the pair above; blank = its profile, else the shared value.\n strategist_model: str | None = None\n strategist_base_url: str | None = None\n # === Connection profiles (multi-provider) =============================================\n # A \"profile\" is a NAMED connection: {model, base_url, temperature, api_key_env, provider}. Roles\n # point at one by name through `role_profiles`; `llm_profile` is the default for every role that\n # doesn't. All three are EMPTY by default and the resolver short-circuits to the shared\n # llm_model/llm_base_url/llm_api_key path when they are — an operator running one model never\n # needs to know the word \"profile\".\n #\n # A profile stores `api_key_env`, the NAME of an environment variable, never the key itself. That\n # is ADR-11's \"a secret is a reference, not a value\" turned from a convention into a data type:\n # the whole profile map is then safe to write into config.snapshot.json, hand back over HTTP and\n # render into LOOPLAB_* — there is nothing in it to mask. It is also what makes per-role\n # CREDENTIALS expressible at all: two roles on one provider can carry different keys, which a\n # key-per-endpoint map could not say.\n llm_profiles: dict[str, dict] = Field(default_factory=dict)\n llm_profile: str | None = None\n role_profiles: dict[str, str] = Field(default_factory=dict)\n # Unified self-driving agent: one LLM identity that plays Researcher + Developer (+ Strategist)\n # across pipeline stages, choosing its own model/toolset per stage. ON by default — the agent\n # drives the loop (incl. crash triage: repair/abandon/reject_idea) and, via\n # `agent_drives_actions`, picks the next macro action within a pure legal-action gate. Set both\n # to False for the legacy byte-identical split-role behavior. (No-op unless backend=\"llm\".)\n unified_agent: bool = True\n agent_drives_actions: bool = True\n # Layer 3 Card queue ownership. False preserves the existing policy/unified-pilot action path.\n # True is pinned in run_started because changing the selector on resume would mix two search\n # treatments in one run. When both this and agent_drives_actions are true, Card selection wins.\n card_driven_selection: bool = False\n # Layer 5 bounded speculative Card buffer. Zero is a hard OFF switch and preserves the\n # historical alternating build/eval spine; positive values are pinned by run_started so a\n # resume cannot silently mix search treatments after a snapshot/default edit.\n speculation_depth: int = Field(default=0, ge=0, le=64)\n # Local evidence receipt produced by ``looplab speculation-gate``. This is deliberately absent\n # from the curated Settings UI: positive-depth execution is a calibrated rollout decision, not a\n # casual tuning knob. The Engine validates the receipt against the current scorer,\n # implementation, GPU inventory and raw paired-run evidence before it enables speculation.\n speculation_gate_receipt: str | None = Field(default=None, max_length=4096)\n # Per-stage model/endpoint overrides for the unified agent. Recognized keys (see\n # tasks.build_unified_agent): `propose` (researcher), `implement`/`repair` (developer),\n # `strategy`, `pilot`. Unlisted keys are ignored. Empty = the per-role researcher_*/developer_*\n # models, then the shared llm_model. Explicit map wins. No-op unless `unified_agent` is true.\n agent_stage_models: dict = {}\n agent_stage_base_urls: dict = {}\n llm_parser: str = \"tool_call\"\n # Reasoning/thinking toggle sent IN the request (providers differ — see `llm.reasoning_body`):\n # Qwen3 on vLLM/SGLang -> chat_template_kwargs.enable_thinking (bool)\n # OpenAI/Ollama-v1/DeepSeek -> reasoning_effort (low|medium|high|none)\n # `llm_reasoning`: \"\" = send nothing (server default — current behavior); off = actively disable;\n # on = enable at default depth; low|medium|high = enable at that effort.\n # Defaults to \"high\" so the agent reasons before proposing/repairing/triaging (better code, fewer\n # crashes); set \"\" to send nothing (server default) or \"off\" to disable. The model's chain-of-thought\n # is CAPTURED either way (reasoning_content field or inline <think>). NOTE: to get a SEPARATE\n # reasoning_content field from SGLang/vLLM, the server also needs `--reasoning-parser qwen3`.\n llm_reasoning: str = \"high\"\n llm_reasoning_style: str = \"auto\" # auto | qwen | effort | none — how to shape the request param\n llm_reasoning_extra: dict = {} # raw fields merged into the body (escape hatch, e.g. Anthropic thinking)\n # H1: drive structured calls with the endpoint's constrained decoding (vLLM/SGLang guided_json +\n # response_format json_schema) so weak models can't emit invalid JSON. Off for Ollama (default).\n llm_guided_json: bool = False\n # Stream every LLM request (SSE) and reassemble it, so the request `timeout` is an INTER-TOKEN\n # idle timeout rather than a whole-request deadline: a long-but-alive generation is never cut off\n # (tokens keep resetting the timer), while a genuinely STALLED endpoint (no token for `timeout` s)\n # is detected and retried on a fresh connection — the fix for silent multi-minute hangs. Set False\n # to use one blocking read (old per-op timeout semantics) if an endpoint streams badly.\n llm_stream: bool = True\n # LLM IDLE timeout (seconds). Stream mode: the INTER-TOKEN stall limit — a steady generation is\n # never cut off, only a silent endpoint. Non-stream: bounds the whole read. Raise for endpoints\n # with a long prefill on huge prompts; lower to fail fast on a flaky shared endpoint.\n llm_timeout: float = Field(default=180.0, gt=0)\n # First-byte (response-headers) window for STREAM attempts, seconds: an SSE response sends its\n # headers on admission, so no-headers ≈ a black-holed request — fail over to a fresh connection\n # fast instead of waiting the full idle timeout. Clamped to llm_timeout. Non-stream attempts are\n # NOT bounded by this (their headers arrive only after the whole generation). The default mirrors\n # `DEFAULT_HEADER_TIMEOUT_S` in looplab/core/llm.py is the single source (imported above).\n llm_header_timeout: float = Field(default=DEFAULT_HEADER_TIMEOUT_S, gt=0)\n # Honour HTTP(S)_PROXY / NO_PROXY / SSL_CERT_FILE env vars for LLM calls. Default False: the\n # transport connects DIRECTLY (many internal/OLAP endpoints are reachable only by bypassing an\n # ambient proxy — a picked-up proxy yields \"connection refused\"). Set true when the endpoint is\n # reachable ONLY via a corporate proxy or needs a custom CA bundle from the environment.\n llm_trust_env: bool = False\n # H4/C2: compact the growing agentic tool-call history once it exceeds this many chars (auto_summary\n # LLM-summarizes the stale middle; else middle-truncate). Sized to the model's real context window so\n # files read earlier STAY in context instead of being compacted away and re-read: ~1,000,000 chars ≈\n # 250k tokens (≈4 chars/token), matching the configured deepseek-v4-flash (≥200k tokens confirmed).\n # The old 120k-char (~30k-token) default was ~8× too aggressive and drove a read-thrash loop. 0 = off.\n context_budget_chars: int = 1_000_000\n # M5: the Researcher's always-on experiments digest budget (chars). 0 = AUTO — scales with the\n # run size (60 chars/node, bounded [1200, 6000]) instead of one flat cap for an 8-node toy run\n # and a 200-node benchmark run alike. Set a positive value to pin it.\n digest_char_cap: int = Field(default=0, ge=0)\n # D11 compression model slot (open_deep_research's four-slot pattern): a dedicated CHEAP model\n # for agent-history summarization (C2 auto-summary), so compression doesn't pay the main\n # model's price. Blank = use each loop's own model (legacy). `compressor_base_url` blank =\n # the shared llm_base_url.\n compressor_model: str | None = None\n compressor_base_url: str | None = None\n # === Agentic tool-loop ================================================================\n # Agentic tool-loop limits — apply to EVERY tool-using agent (the LLM Researcher, the unified\n # agent's pilot + crash-triage, Deep-Research, the run-chat Boss, the genesis repo-scout, and the\n # cross-run report synthesizer). The loop lets the model call read-only tools across turns before\n # emitting its structured result. Both default to UNLIMITED so the agent takes as many turns / as\n # long as it needs — it is never cut off mid-reasoning; set a positive value here (or per-run, or\n # in the UI settings) to bound latency/cost. These used to be hardcoded per call site (e.g. the\n # boss at max_turns=3 / 45s), which silently dropped a slow reasoning model to a no-op reply.\n agent_max_turns: int = 0 # max tool turns before the emit is forced (0 = unlimited)\n agent_time_budget_s: float = 0.0 # wall-clock ceiling across the loop's turns (0 = no cap)\n # G · Soft convergence SAFETY NET (not a research cap). Two thresholds on the agent's tool-turn count:\n # at `agent_emit_after` the loop NUDGES the agent once (\"you've investigated enough — emit now\"); at\n # `agent_emit_force` it FORCES the emit from what was gathered. Set GENEROUSLY — a focused researcher\n # may legitimately read many files/experiments before it can propose a grounded idea; these only catch\n # a genuine runaway that keeps issuing DIFFERENT calls forever (which stuck-detection, keyed on\n # repeats, misses). NB: the old 499-read runaway (node 63) was 79% REDUNDANT re-reads caused by a\n # broken read_file — fixing read_file (pagination) is the real cure; this is just insurance. 0 = off.\n agent_emit_after: int = 300\n agent_emit_force: int = 500\n # B1 · No-progress / stuck detection — the safety net that makes \"unlimited turns\" safe. The\n # turn/time ceilings above are only BACKSTOPS; this is what actually stops a runaway loop, on the\n # cheapest signal: when the model repeats the SAME tool call (or ping-pongs between two, or keeps\n # hitting the SAME error) with no progress, the loop forces the final emit and finishes. ON by\n # default; reading DIFFERENT files or running ONE long command never trips it. (OpenHands-style.)\n agent_stuck_detection: bool = True\n agent_stuck_repeat: int = 4 # identical calls in a row that count as \"stuck\" (>=2)\n agent_stuck_alternate: int = 4 # ping-pong cycles between two calls that count as \"stuck\" (>=2)\n # C1 · Self-plan (TodoWrite-style): expose an `update_plan` tool so a long-running agent keeps its\n # OWN working TODO and re-surfaces it every `agent_plan_reinject_every` turns — keeps the goal in\n # view across a long loop so the agent \"writes its own context\" (Claude Code style). ON by default.\n agent_self_plan: bool = True\n agent_plan_reinject_every: int = 5\n # C2 · Auto-summary (ON by default): when the tool-loop history grows long, LLM-summarize the\n # stale middle instead of only middle-truncating it. The trigger is `context_budget_chars` if set,\n # else a built-in high-water mark (context_budget.DEFAULT_SUMMARY_CHARS ≈ 120k chars) so short\n # loops are untouched. Falls back to truncation on any summarizer error; one extra model call per\n # over-budget turn.\n agent_auto_summary: bool = True\n # C4 · Repo-developer plan decomposition. A big feature attempted in ONE developer session made a\n # non-converging reasoning model loop indefinitely — it kept writing + exploring without ever\n # emitting `done` (10k+ LLM calls / hours; agentic stuck-detection can't catch VARIED exploration,\n # only literal repeats). Fix: the developer first proposes an ordered plan of ATOMIC steps; a\n # multi-step plan is executed step-by-step, each in a FRESH bounded session with only that step's\n # scope + the files accumulated so far, syntax-validated per write. Every session stays\n # small-context and convergent. ON by default for repo tasks (a 1-step plan == the old single pass).\n developer_plan_decompose: bool = True\n developer_plan_min_steps: int = 2 # a proposed plan with >= this many steps runs step-by-step\n developer_plan_max_steps: int = 8 # cap on plan length (a runaway planner can't spawn 100 steps)\n # Hard per-session backstop for the repo developer — the ONE write-heavy agent that can run away\n # even with stuck-detection on (varied writes/reads never trip the repeat/ping-pong signal). Bounds\n # the plan phase, EACH step, and the single-session fallback. Unlike the global agent_* limits\n # (0 = unlimited, meant for read-only agents), the developer always gets a FINITE ceiling so a\n # model that never emits `done` fails cleanly with the code it wrote so far, instead of looping.\n developer_session_max_turns: int = 500\n developer_session_time_budget_s: float = 1200.0 # 20 min wall-clock per developer session\n # Phase-handoff summaries. Each LLM phase in a node build (Researcher·propose → Developer·stages →\n # plan → implement) ends with ONE extra LLM call that distills its transcript — the repo structure\n # it mapped, files/data confirmed, decisions made — into a brief injected into the NEXT phase (even\n # across the role boundary). The next phase TRUSTS that instead of re-reading the same files, which\n # cuts the biggest source of tool-call thrash (stages/plan/implement each re-exploring the repo).\n # One summary call per phase in exchange for many fewer read calls downstream. ON by default.\n phase_handoff_summary: bool = True\n # Observability (ADR-17): capture each LLM call's full prompt + completion into the active\n # span (spans.jsonl) so the UI can show exactly what the model read and wrote per node.\n # Diagnostics only — `replay.fold` never reads spans. Default on for local single-user; set\n # LOOPLAB_TRACE_LLM_IO=0 to suppress (e.g. to keep spans.jsonl small or avoid storing prompts).\n trace_llm_io: bool = True\n # Run-introspection tools (ON by default): make the LLM Researcher (and DeepResearcher) a\n # tool-using agent that can read its OWN experiments (list/read/find-analogous/themes/code) and\n # the task data (schema/profile/asset) mid-loop, instead of seeing only best+parent. Advisory —\n # never changes best-selection. Off = the legacy single-shot Researcher (richer digest still added).\n researcher_tools: bool = True\n # Cross-run introspection: give the agentic Researcher / pilot read-only tools to look at\n # SIBLING runs (same task_id, same run-root) — list them, read an experiment / its code, and\n # find analogous configs across runs — so a run can build on what neighbouring runs already\n # learned instead of rediscovering it. Advisory; never changes best-selection. Needs the run's\n # own dir wired through (no-op for unit-built roles). Off = the legacy single-run view only.\n cross_run_tools: bool = True\n # Read-only access to EVERY run on this machine ACROSS ALL TASKS (not just same-task siblings):\n # the Developer/Researcher get list_all_runs + read_run_code + read_run_experiment so they can read\n # the code + result of ANY past experiment anywhere and reuse an approach. Broader than\n # `cross_run_tools` (same-task only); the agent decides when a foreign run is relevant. Advisory;\n # never changes best-selection. Needs the run's own dir wired through (no-op for unit-built roles).\n all_runs_tools: bool = True\n # PART V §22 — read-only CROSS-RUN KNOWLEDGE tool for the reasoning roles (Researcher, Strategist,\n # deep-research, and a Developer-scoped variant). Adds `cross_run_prior_attempts` / `cross_run_claims`\n # / `cross_run_atlas` over the §21.20 read-models (concept overview + claim assessments + atlas), read\n # from `memory_dir` (lessons.jsonl + concept_capsules.jsonl). Read-only is a storage-authority\n # boundary, not a behavioral no-op: retrieved content can steer later proposals, and a tool-using\n # reasoning loop can consume additional model calls. The tools never mutate cross-run truth (facts\n # are engine-written, verdicts operator-ratified, §22.4) or directly select a winner. ON by explicit\n # experimental product choice, EngineOptions off; stores only exist once Part-IV features have run.\n cross_run_read_tools: bool = True\n # Agentic retrieval (ADR-16): if set, the LLM Researcher gets grep/kb_search/read\n # tools over this directory of markdown notes and chooses when to use them.\n knowledge_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"knowledge\"))\n # T4 real embeddings: model id for an OpenAI-compatible `/embeddings` endpoint used for\n # kb_search / case retrieval. Blank (default) = the dependency-free lexical `hash_embed` (today's\n # behavior). Set e.g. \"nomic-embed-text\" (Ollama) or \"text-embedding-3-small\" for SEMANTIC\n # retrieval; a misconfigured/offline endpoint degrades back to hash_embed at call time (never\n # crashes a run). `embed_base_url` blank = reuse `llm_base_url`; the shared `llm_api_key` is used.\n embed_model: str | None = None\n embed_base_url: str | None = None\n # Harmonic memory (Memora, ICML'26 — idea import). When ON, cross-run cases and knowledge notes are\n # indexed by a short ABSTRACTION + cue ANCHORS instead of their raw text, near-duplicate memories are\n # CONSOLIDATED into one entry under a matching abstraction, and `kb_search`/case retrieval EXPAND\n # through the top hits' anchors to surface related-but-not-similar memories. ON by default. Set\n # `memora=false` to restore the pre-Memora raw-text index. Never a source of truth — abstractions\n # live only in the derived, rebuildable retrieval index (never the event log or canonical cases.jsonl).\n memora: bool = True\n # Write abstractions with the wired chat model (richer than the deterministic lexical fallback). ON\n # by default; results are CACHED by content hash (see `memora_cache`) so a re-built index doesn't\n # re-call the model on unchanged notes/cases, and any endpoint failure degrades to lexical at call\n # time — so an offline box just gets lexical abstractions, never a crash. Set false to force lexical.\n memora_llm: bool = True\n # === Cross-run memory / Memora ========================================================\n memora_anchors: int = 6 # max cue anchors kept per memory\n # Cosine similarity at/above which a new case is CONSOLIDATED into an existing one (same evolving\n # topic) rather than stored as a separate entry. Only used when `memora` is on.\n # BOUNDED like its 0..1 sibling `novelty_semantic_threshold`: consumers compare\n # `cosine(...) >= consolidate_threshold` (engine/memory.py, tools/knowledge_tools.py) and cosine\n # cannot exceed 1.0, so an unbounded typo above 1.0 validated cleanly and silently disabled\n # consolidation forever — while a negative one consolidated everything. Fail loud instead.\n memora_consolidate_threshold: float = Field(default=0.86, ge=0.0, le=1.0)\n # Where the LLM-abstraction cache lives (JSON). Blank (default) derives it from `memory_dir`\n # (`<memory_dir>/memora_cache.json`) or else `knowledge_dir` (`<knowledge_dir>/.memora_cache.json`);\n # with neither set the cache is in-memory only (per process). No-op unless `memora_llm` is on.\n memora_cache: str | None = None\n # E3 literature-grounded ideation (network-OPTIONAL): give the agentic Researcher an arXiv search\n # tool to ground ideas in real techniques. Off by default (egress is unreliable on some boxes);\n # fails gracefully if the network is blocked.\n literature_search: bool = False\n # Deep-Research stage (network-OPTIONAL): give the DeepResearcher a general web search/fetch\n # tool (DuckDuckGo) on top of arXiv to read across results + the web before steering the next\n # batch. Off by default (egress is unreliable on some boxes); fails gracefully when blocked.\n web_search: bool = False\n # Cadence for the Deep-Research stage: run it automatically every N created nodes. Zero disables\n # only the cadence; manual `deep_research` and Strategist `request_research` triggers still fire.\n # Default 3: deep research analyzes the accumulating results and steers the next batch (its\n # directions become hints + open hypotheses); paired with `concurrent_research` it overlaps the\n # think with the GPU-bound eval, so the cadence costs LLM tokens but no wall-clock on the search.\n deep_research_every: int = 3\n # Overlap a DUE deep-research \"think\" with the GPU-bound eval instead of running it in its own\n # serial step (the agent is otherwise idle while a node trains). research() computes from a state\n # snapshot; the joined background task records only allowlisted, order-tolerant\n # research/hint/hypothesis events as soon as the memo finishes. ON by default: the LLM is typically\n # remote (no GPU contention with eval), so overlapping hides latency behind long training.\n concurrent_research: bool = True\n # Concurrent-research REPEAT — don't idle a multi-DAY eval. The overlapped \"think\" fires ONCE per\n # window by default; ON here, it RE-RUNS on an adaptive time cadence for the WHOLE eval window\n # (a two-day training must not leave the reasoning agents idle after one memo). Self-paced: it\n # only records a memo whose CONTENT is new (identical re-runs are skipped, so the log/board don't\n # bloat) and backs off geometrically as the analysis converges, capped so it always re-checks.\n # The appended records never rewrite the current metric champion, but their hints/open hypotheses\n # deliberately steer later proposals and replay reconstructs that advice. The library default is\n # one-shot (== today, byte-identical); this product default turns the repetition on.\n concurrent_research_repeat: bool = True\n # Base cadence (seconds) between repeated concurrent-research passes. Research is EXPENSIVE\n # (multi-turn LLM + web/arXiv), so this is a FLOOR, not a ceiling: the effective cadence is\n # max(this, ~5% of the per-experiment time budget) — a two-day eval is re-researched about hourly,\n # a short eval not at all (the first tick outlasts it). Only matters when the repeat is on.\n concurrent_research_interval_s: float = Field(default=1800.0, gt=0)\n # Per-eval-window backstop on repeated-research LLM calls (0 = cadence-only, no cap). The adaptive\n # cadence + convergence backoff are the primary budget control; this bounds a pathological\n # never-converging window. Past it the loop stops calling the LLM (the health monitor still runs).\n concurrent_research_max_calls: int = Field(default=40, ge=0)\n # Concurrent hypothesis-board CONSOLIDATION during the eval window. Repeated research keeps ADDING\n # near-duplicate directions to the open board (each memo registers up to 5 as open hypotheses), so a\n # long eval's board bloats before the between-nodes merge runs. This dedups/merges it on the SAME\n # background loop so the board stays clean for the next proposal. In Card-driven mode the same merge\n # changes ownership/readiness, so it is deferred to the joined main-task cadence instead. Needs the\n # reflect client + `track_hypotheses`.\n # Only active while `concurrent_research` runs the overlap loop. Library default off (== today).\n concurrent_consolidate: bool = True\n # Cadence for the agent-authored run report: regenerate the conclusion-first narrative every N\n # created nodes (0 = off; it still regenerates on a manual `report_refresh` from the UI). The\n # deterministic report always renders from the node set regardless of this knob.\n report_every: int = 3\n # Agent Skills (I18, ADR-9): dir of SKILL.md the Researcher can list/load as tools.\n skills_dir: str | None = None\n # Prompt store (I18, ADR-8): dir of editable, hot-reloaded role prompt .md files.\n prompt_dir: str | None = None\n # API key as a reference, never serialized as a value (ADR-11). Local servers\n # ignore it; default to a placeholder.\n llm_api_key: SecretStr | None = None\n # Runtime-only endpoint binding for the shared credential. A key is usable only when this\n # canonical URL exactly matches the transport target. It is deliberately absent from run/UI\n # snapshots: the owner-side secret store persists key + binding together, while an operator who\n # supplies LOOPLAB_LLM_API_KEY directly must also supply LOOPLAB_LLM_API_KEY_BASE_URL.\n llm_api_key_base_url: str | None = None\n # Set only by a trusted source-aware resolver after it has selected key+binding as one record.\n # Config files, task payloads and --set cannot populate PrivateAttr.\n _llm_credential_pair_trusted: bool = PrivateAttr(default=False)\n\n @model_validator(mode=\"before\")\n @classmethod\n def _reject_bool_for_numeric_fields(cls, data):\n \"\"\"A JSON boolean is a TYPE ERROR for a numeric setting, not the number 1.\n\n Pydantic runs in lax mode here (`model_config` sets no `strict`), and `isinstance(True, int)`\n is True — so `{\"max_nodes\": true}` from a UI/API type slip validated cleanly as a budget of\n ONE node, and `{\"eval_parallel\": true}` collapsed the width to one. The run then looked\n configured and simply did almost nothing, with no 422 and nothing in the snapshot to show why.\n Rejecting at the boundary covers every numeric field at once, including the many that carry no\n `Field(ge=...)` bound of their own to trip over.\n \"\"\"\n if not isinstance(data, dict):\n return data\n for key, val in data.items():\n if type(val) is bool and _is_numeric_annotation(\n getattr(cls.model_fields.get(key), \"annotation\", None)):\n raise ValueError(f\"{key} must be a number, not a boolean (got {val!r})\")\n return data\n\n @model_validator(mode=\"before\")\n @classmethod\n def _apply_profile(cls, data):\n \"\"\"Expand `profile` into a bundle of setting defaults BEFORE validation.\n\n pydantic-settings hands this validator the fully-merged input from ALL explicit sources\n (init kwargs + LOOPLAB_* env + .env), with fields the user left unset simply ABSENT (they\n fall to field defaults afterward). So a profile value is applied only when its key is not\n already present — i.e. any explicit file/CLI/env setting wins over the profile, and the\n profile wins over the bare field default. This makes `thorough` a true convenience preset,\n never an override of what the user asked for.\"\"\"\n if not isinstance(data, dict):\n return data\n prof = data.get(\"profile\", \"default\")\n # env may deliver the key as \"profile\" (env_prefix stripped) — normalize to a str\n prof = str(prof).strip().lower() if prof is not None else \"default\"\n overrides = PROFILES.get(prof)\n if overrides is None:\n raise ValueError(f\"unknown profile {prof!r}; known: {sorted(PROFILES)}\")\n for key, val in overrides.items():\n if key not in data: # explicit source (file/CLI/env) always wins\n data[key] = val\n return data\n\n @model_validator(mode=\"after\")\n def _check_trust_gate(self):\n if self.trust_gate not in (\"audit\", \"gate\", \"block\"):\n raise ValueError(\n f\"trust_gate must be audit|gate|block, got {self.trust_gate!r}\")\n if self.merge_mode not in (\"auto\", \"mean\", \"ensemble\"):\n raise ValueError(\n f\"merge_mode must be auto|mean|ensemble, got {self.merge_mode!r}\")\n # novelty_mode drives the dedup/novelty gate; an out-of-set value (e.g. a mis-cased\n # LOOPLAB_NOVELTY_MODE=LLM, or \"on\") used to fall through _apply_novelty_gate silently as a\n # NO-OP — turning the gate off with no diagnostic. Fail loudly at config time like trust_gate/\n # merge_mode do. (seed_mode / eval_trust_mode / strategist_backend share the pattern and are\n # validated in this same block, just below.)\n if self.novelty_mode not in (\"off\", \"algo\", \"llm\"):\n raise ValueError(\n f\"novelty_mode must be off|algo|llm, got {self.novelty_mode!r}\")\n # The remaining enum-ish string fields (arch-review §5 P3): a typo used to be accepted at\n # construction and only fail-safe/later-loud downstream (e.g. a mis-cased strategist_backend\n # silently ran the default). Fail loudly here like the fields above.\n if self.strategist_backend not in (\"off\", \"rule\", \"llm\", \"agent\"):\n raise ValueError(\n f\"strategist_backend must be off|rule|llm|agent, got {self.strategist_backend!r}\")\n if self.eval_trust_mode not in (\"ratify_freeze\", \"autonomous\", \"ratify_freeze_drift\"):\n raise ValueError(\n \"eval_trust_mode must be ratify_freeze|autonomous|ratify_freeze_drift, \"\n f\"got {self.eval_trust_mode!r}\")\n if self.seed_mode not in (\"auto\", \"tracked\", \"all\"):\n raise ValueError(f\"seed_mode must be auto|tracked|all, got {self.seed_mode!r}\")\n if self.backend not in (\"toy\", \"llm\"):\n raise ValueError(f\"backend must be toy|llm, got {self.backend!r}\")\n # developer_backend is a CLOSED enum: \"default\" (in-house developer) plus the external\n # coding-agent keys in agents/cli_agent.py::PRESETS. The CLI --developer-backend flag guards it\n # (_DEV_BACKENDS), but a file/env/`--set` value reached here unchecked and adapters/tasks.py then\n # silently wired the DEFAULT developer for anything not in PRESETS. Validate against the SAME\n # closed set below, which `agents/cli_agent.py` asserts its PRESETS match — so a typo still\n # fails loud instead of downgrading, WITHOUT core executing agents-package code on every\n # Settings construction (doc 25 XP-04: this was the one upward import out of core).\n if self.developer_backend not in DEVELOPER_BACKENDS:\n raise ValueError(\n f\"developer_backend must be {'|'.join(DEVELOPER_BACKENDS)}, \"\n f\"got {self.developer_backend!r}\")\n # `parse_structured` resolves an unknown name to the DEFAULT fallback order, so a typo'd\n # llm_parser is indistinguishable from asking for the default. Validate against the parser\n # registry itself (imported here, not at module scope, to keep config import-light).\n from looplab.core.parse import _ORDER as _PARSER_ORDER\n if self.llm_parser not in _PARSER_ORDER:\n raise ValueError(f\"llm_parser must be one of {'|'.join(sorted(_PARSER_ORDER))}, \"\n f\"got {self.llm_parser!r}\")\n self._check_llm_profiles()\n return self\n\n def _check_llm_profiles(self) -> None:\n \"\"\"Validate the connection-profile maps LOUDLY, at construction.\n\n Assignment is not validated anywhere in this project, and these two maps are the kind of\n thing a typo makes into a silent no-op: an unknown role name would simply never be read, and\n a reference to a missing profile would fall back to the shared model while the operator\n believed a second provider was in play. Both are errors here instead.\n\n The `api_key_env` rules exist for a specific reason. It must LOOK like a secret to\n `runtime/sandbox.py::SECRET_ENV`, because that is the filter that strips the operator's keys\n out of the environment handed to generated candidate code — a key parked in a variable named\n `MY_HANDLE` would be invisible to it and ride straight into the sandbox. This pattern is\n deliberately the STRICTER of the two (the sandbox has to recognize whatever already exists;\n this names a variable the operator is about to create), and it is duplicated below rather than\n imported because layering forbids `core` from importing `runtime`.\n `tests/test_secret_env_pattern.py` pins the two together — including the invariant that every\n name accepted here is one the sandbox strips. And a literal key inside a profile is refused\n outright: the profile map is written verbatim into config.snapshot.json and served over HTTP\n precisely because it is supposed to contain no secrets.\"\"\"\n from looplab.core.llm import LLM_ROLE_KEYS\n if not isinstance(self.llm_profiles, dict):\n raise ValueError(\"llm_profiles must be a map of profile name -> profile\")\n for name, entry in self.llm_profiles.items():\n if not isinstance(entry, dict):\n raise ValueError(f\"llm_profiles[{name!r}] must be an object\")\n # BEFORE the unknown-field check: none of these is a known field, so checking them\n # afterwards could never fire and someone who pasted a live key was told it was a typo.\n for banned in (\"api_key\", \"key\", \"token\", \"secret\"):\n if banned in entry:\n raise ValueError(\n f\"llm_profiles[{name!r}] must not contain {banned!r}: a profile stores \"\n \"api_key_env (the NAME of an environment variable), never a key value — \"\n \"the whole map is written to config.snapshot.json and served over HTTP\")\n unknown = set(entry) - _PROFILE_FIELDS\n if unknown:\n raise ValueError(\n f\"llm_profiles[{name!r}] has unknown field(s) {sorted(unknown)}; \"\n f\"known: {sorted(_PROFILE_FIELDS)}\")\n env = entry.get(\"api_key_env\")\n if env is not None and not _SECRET_ENV_NAME.match(str(env)):\n raise ValueError(\n f\"llm_profiles[{name!r}].api_key_env={env!r} is not a usable variable name: it \"\n \"must be UPPER_SNAKE and contain KEY/SECRET/TOKEN/PASSWORD/PASSWD/CREDENTIAL, so \"\n \"the sandbox's secret-name filter strips it from generated code's environment\")\n temp = entry.get(\"temperature\")\n if temp is not None and (isinstance(temp, bool) or not isinstance(temp, (int, float))):\n raise ValueError(f\"llm_profiles[{name!r}].temperature must be a number\")\n if not isinstance(self.role_profiles, dict):\n raise ValueError(\"role_profiles must be a map of role name -> profile name\")\n for role, profile in self.role_profiles.items():\n if role not in LLM_ROLE_KEYS:\n raise ValueError(\n f\"role_profiles has unknown role {role!r}; known: {sorted(LLM_ROLE_KEYS)}\")\n if profile not in self.llm_profiles:\n raise ValueError(\n f\"role_profiles[{role!r}] names profile {profile!r}, which is not in llm_profiles\")\n if self.llm_profile is not None and self.llm_profile not in self.llm_profiles:\n raise ValueError(\n f\"llm_profile={self.llm_profile!r} is not in llm_profiles\")\n\n def masked_snapshot(self) -> dict:\n # Snapshots are a JSON contract (they are written verbatim to config.snapshot.json), not a\n # Python-object dump. In particular tuple-valued schema fields such as\n # ``inline_repair_reasons`` must become JSON arrays before callers hash the runtime envelope;\n # otherwise the strict speculation-scope canonicalizer sees a different/non-JSON preimage\n # from the one that ``json.dumps`` eventually persists.\n d = self.model_dump(mode=\"json\")\n if self.llm_api_key is not None:\n d[\"llm_api_key\"] = \"***\"\n else:\n d[\"llm_api_key\"] = None\n d.pop(\"llm_api_key_base_url\", None)\n # The snapshot's own FORMAT version — not a Settings field (it is never settable by env or\n # file, and it describes the document, not the run). `settings_from_snapshot` fails closed on\n # a version it does not understand; see CONFIG_SNAPSHOT_SCHEMA.\n d[CONFIG_SNAPSHOT_SCHEMA_KEY] = CONFIG_SNAPSHOT_SCHEMA\n return d\n\n\n# The config snapshot's document-format version, written by `masked_snapshot` and enforced by\n# `settings_from_snapshot`. Bump it when a snapshot written by this binary would be MISREAD by an\n# older one — i.e. when a new field changes paid, concurrency or selection behaviour and silently\n# defaulting it would resume the same event history under different semantics. `Settings` is\n# `extra=\"ignore\"`, so without this an older binary drops what it does not recognize and resumes\n# anyway, with no diagnostic; a version it does not know is now a loud refusal instead.\n# Version 1 is the first VERSIONED format. An absent key means a pre-versioning snapshot, which stays\n# readable through `LEGACY_CONFIG_SNAPSHOT_DEFAULTS` — that historical contract is unchanged.\nCONFIG_SNAPSHOT_SCHEMA = 1\nCONFIG_SNAPSHOT_SCHEMA_KEY = \"config_snapshot_schema\"\n\n\nclass ConfigSnapshotVersionError(ValueError):\n \"\"\"A config snapshot was written by a NEWER binary than the one reading it.\"\"\"\n\n\n# Pre-versioned snapshots are full Settings dumps, so absence is a reliable per-field version marker.\n# Keep their historical effective behavior when newer product defaults become active: re-entry must not\n# silently add paid calls, interventions, concurrency or a different selection policy to an old run.\nLEGACY_CONFIG_SNAPSHOT_DEFAULTS: dict[str, object] = {\n \"parallel_build\": 1,\n \"eval_parallel\": None,\n \"llm_parallel\": None,\n \"train_monitor\": False,\n \"train_monitor_interval_s\": 600.0,\n \"train_monitor_kill\": False,\n \"train_monitor_kill_confidence\": 0.8,\n \"asha_live\": False,\n \"asha_live_kill\": False,\n \"asha_live_quantile\": 0.5,\n \"asha_live_min_siblings\": 3,\n # Before this operator ceiling existed, the sandbox's 24-hour process limit was authoritative.\n \"max_eval_timeout\": 24 * 3600.0,\n \"watchdog_reflection\": False,\n \"card_driven_selection\": False,\n \"speculation_depth\": 0,\n \"speculation_gate_receipt\": None,\n \"concurrent_research_repeat\": False,\n \"concurrent_research_interval_s\": 1800.0,\n # Zero means unlimited; 40 was the first bounded default, and is latent while repeat is disabled.\n \"concurrent_research_max_calls\": 40,\n \"concurrent_consolidate\": False,\n # The map originally covered only the flags added in the days just before it was written\n # (the entries above are all 2026-07-19..07-21; the map itself landed 07-22). Everything below was\n # added 2026-06-24..07-17 — AFTER `config.snapshot.json` existed (it ships in the initial commit,\n # 2026-06-23) and BEFORE this map did — so a snapshot from that window carries none of these keys\n # and, without an entry here, resumed with today's paid product default ACTIVE. That is precisely\n # the \"silently add paid calls ... to an old run\" the paragraph above forbids: the resumed half of\n # the run would make predict-before-execute and cross-run LLM calls the original never made, and\n # admit candidates under a different novelty policy.\n # `setdefault` makes every one of these a no-op for a snapshot that DOES carry the key, so only\n # genuinely older runs are affected. Each value is the behaviour before the field existed: absent\n # feature = off, absent cadence = 0 (the same \"conservative library\" value the frozen\n # Settings-vs-EngineOptions divergence table records for these fields). Each was dated against the\n # tree before being listed — e.g. `report_every` (2026-06-25) is 0 because the run-report writer\n # itself did not exist before that commit, so an older run wrote no report to preserve.\n \"foresight\": False,\n \"foresight_agentic\": False,\n \"foresight_verify\": False,\n \"concept_pivot\": False,\n \"graded_novelty\": False,\n \"cross_run_concepts\": False,\n \"cross_run_advisory\": False,\n \"cross_run_structured_claims\": False,\n \"cross_run_curation\": False,\n \"cross_run_read_tools\": False,\n \"concept_run_base\": False,\n \"fingerprint_universal\": False,\n \"memora_llm\": False,\n \"comparative_lessons\": False,\n \"lessons_every\": 0,\n \"lessons_refresh_every\": 0,\n \"unified_agent\": False,\n \"deep_research_every\": 0,\n \"report_every\": 0,\n \"failure_reflection\": False,\n \"reflection_priors\": False,\n \"agent_drives_actions\": False,\n \"concurrent_research\": False,\n \"deep_repair\": False,\n # `merge_mode` meets all three conditions below, confirmed against the commits: the field was\n # introduced 2026-06-24 (bb421e0f), one day AFTER the cutoff, so a pre-cutoff snapshot carries no\n # such key; it arrived defaulting to \"mean\" and only later flipped to \"auto\" (a8b37944,\n # 2026-07-03), which `orchestrator.__init__` resolves to \"ensemble\" for a code-generating\n # developer — a PAID code-recombination merge producing a DIFFERENT merged solution; and the\n # before-the-field value is pointable and still frozen as the library default in\n # `EngineOptions` / `orchestrator._DEFAULTS`. Without this entry a pre-field snapshot resumed on\n # an LLM backend silently gained ensemble merges it never did.\n \"merge_mode\": \"mean\",\n # WHAT THIS MAP IS NOT. It is a hand-maintained list of FEATURE switches whose before-the-field\n # value is unambiguous, not a complete partition of `Settings`. Two classes stay out on purpose,\n # because for them a wrong entry is worse than a missing one — it would silently REMOVE behaviour\n # the original run really had:\n # * knobs that are latent while their parent feature is off — `foresight_panel`,\n # `foresight_verify_samples`, `memora_anchors`, `holdout_top_k`, `asha_eta`: the parent above\n # already pins them out of play, and their own before-the-field value is not recoverable;\n # * magnitudes and depths for behaviour that ALREADY existed — `debug_depth`,\n # `context_budget_chars`, `developer_plan_max_steps`, `agent_emit_after`: guessing 0/off there\n # disables working machinery rather than preserving history.\n # A new field belongs here only when it (a) postdates 2026-06-23, (b) defaults to adding paid\n # calls / interventions / concurrency / a different selection policy, and (c) has a historical\n # value you can point at a commit for. When (c) fails, leave it out and say so here.\n}\n\n\ndef migrate_config_snapshot(data: dict) -> dict:\n \"\"\"Return an effective copy of one legacy/current snapshot without rewriting evidence bytes.\"\"\"\n if not isinstance(data, dict):\n raise TypeError(\"config snapshot must be a JSON object\")\n migrated = dict(data)\n for key, value in LEGACY_CONFIG_SNAPSHOT_DEFAULTS.items():\n migrated.setdefault(key, value)\n return migrated\n\n\ndef config_snapshot_schema(data: dict) -> int:\n \"\"\"The snapshot's declared format version. 0 = pre-versioning (the key did not exist yet).\n\n A malformed value is treated as UNREADABLE rather than as 0: a snapshot whose own version marker\n is corrupt cannot be assumed to be the oldest format.\"\"\"\n raw = data.get(CONFIG_SNAPSHOT_SCHEMA_KEY, 0) if isinstance(data, dict) else 0\n if isinstance(raw, bool) or not isinstance(raw, int) or raw < 0:\n raise ConfigSnapshotVersionError(\n f\"config snapshot has a malformed {CONFIG_SNAPSHOT_SCHEMA_KEY}: {raw!r}\")\n return raw\n\n\ndef settings_from_snapshot(data: dict) -> Settings:\n \"\"\"Build re-entry Settings from a masked snapshot under its historical missing-field contract.\n\n Fails closed on a snapshot written by a NEWER binary. `Settings` is `extra=\"ignore\"`, so such a\n snapshot would otherwise load with every unrecognized control silently dropped — the run would\n resume on the same event history under different paid/concurrency/selection semantics, with\n nothing to show why. Refusing is the only honest answer: this binary cannot know what it is\n dropping. Older and pre-versioning snapshots keep loading exactly as before.\"\"\"\n found = config_snapshot_schema(data)\n if found > CONFIG_SNAPSHOT_SCHEMA:\n raise ConfigSnapshotVersionError(\n f\"this run's config.snapshot.json declares format v{found}, but this build understands \"\n f\"at most v{CONFIG_SNAPSHOT_SCHEMA}. It was written by a newer LoopLab; resuming here \"\n \"would silently drop settings this build does not know. Upgrade LoopLab to resume it.\")\n migrated = migrate_config_snapshot(data)\n migrated.pop(\"llm_api_key\", None)\n migrated.pop(\"llm_api_key_base_url\", None)\n migrated.pop(CONFIG_SNAPSHOT_SCHEMA_KEY, None) # a document marker, never a Settings field\n return Settings(**migrated)\n"}, "e3f3a56e465a7d59e71cbd8a6d99a576cc55b327": {"t": 1785768723.1344528, "n": 120884, "h": "ab01d9ce6938adbc", "c": "\"\"\"Engine config: the schema (what settings exist). (I0, ADR-11)\n\nOne pydantic-settings class declaring every engine knob, its default, and its docs. The loader —\nhow a run file / CLI / env becomes a `Settings` — is `looplab.core.appconfig`, which also owns\nthe config-precedence order. A resolved, secret-masked snapshot is written next to each run for\nreproducibility. (No real secrets in P0, but the masking discipline is in place.)\n\"\"\"\nfrom __future__ import annotations\n\nimport re\nimport types\nimport typing\nfrom pathlib import Path\n\nfrom pydantic import Field, PrivateAttr, SecretStr, field_validator, model_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n# Single source of the LLM first-byte (response-headers) default — see core/llm.py.\nfrom looplab.core.llm import DEFAULT_HEADER_TIMEOUT_S\n\n# Default home for cross-run memory + the knowledge base (shared across every run). Both are ON by\n# default now (a user asked for it) so a fresh install accumulates + consults knowledge with no setup;\n# set the env var to \"\" to disable, or to a path to relocate. `~/.looplab/` keeps them out of any one\n# project + shared across projects.\n_LL_HOME = Path.home() / \".looplab\"\n\n# What one `llm_profiles` entry may contain. Deliberately closed: an unrecognized field is a typo the\n# operator would otherwise never see fail, and the map is a public artifact (config.snapshot.json,\n# HTTP, LOOPLAB_*), so it must never accrete free-form keys.\n_PROFILE_FIELDS = frozenset({\"model\", \"base_url\", \"temperature\", \"api_key_env\"})\n# A variable name that `runtime/sandbox.py::SECRET_ENV` will recognize as holding a secret and strip\n# from generated code's environment. Duplicated here, not imported: layering forbids `core` from\n# importing `runtime`. `tests/test_secret_env_pattern.py` holds the two in agreement.\n_SECRET_ENV_NAME = re.compile(\n r\"\\A(?=[A-Z][A-Z0-9_]{0,63}\\Z)(?=.*(KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL)).*\\Z\")\n\n\ndef _is_numeric_annotation(ann) -> bool:\n \"\"\"True when a field's annotation admits int/float but NOT bool.\n\n Used by `Settings._reject_bool_for_numeric_fields`: pydantic resolves annotations at class build,\n so `model_fields[...].annotation` is a real type object here even under\n `from __future__ import annotations`. Optionals are numeric when every non-None member is\n (`int | None` yes, `int | str` no), so a boolean can never slip in through a union arm.\"\"\"\n if ann is bool:\n return False # a bool field legitimately takes a bool\n if ann is int or ann is float:\n return True\n args = [a for a in typing.get_args(ann) if a is not type(None)]\n origin = typing.get_origin(ann)\n if args and origin in (typing.Union, types.UnionType):\n return all(_is_numeric_annotation(a) for a in args)\n return False\n\n# Autonomous roles that may be granted per-setting write access (see Settings.agent_control).\n# strategist = A7 meta-controller (run-wide); boss = run-chat operator-proxy (run-wide);\n# researcher = per-experiment proposer (per-node sizing, e.g. eval timeout for a heavy model).\nAGENT_ROLES = (\"strategist\", \"boss\", \"researcher\")\n\n# A foresight score is computed for every acted-on proposal. Keep this lower than the generic\n# verifier's emergency ceiling so a typo cannot multiply paid synchronous calls across a run.\nMAX_FORESIGHT_VERIFY_SAMPLES = 8\n\n\n# Layer-2 parallelism renamed two public settings without removing the durable legacy fields. Keep\n# the equivalence in one place: config-source precedence, governance migration, and live strategy\n# application must all agree which names describe the same authority/value.\nPARALLELISM_ALIASES: dict[str, str] = {\n \"max_parallel\": \"eval_parallel\",\n \"parallel_build\": \"llm_parallel\",\n}\n\n\ndef parallelism_aliases(setting: str) -> tuple[str, ...]:\n \"\"\"Return every spelling in ``setting``'s alias family, canonical name first.\"\"\"\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if setting in (legacy, canonical):\n return canonical, legacy\n return (setting,)\n\n\ndef canonicalize_parallelism_source(\n values: dict, *, promote_build_to_llm_parallel: bool = False,\n) -> dict:\n \"\"\"Promote legacy parallel keys *within one config-precedence source*.\n\n Applying this only after file/CLI/env were flattened would let a lower-priority canonical env\n value shadow a higher-priority legacy file value (or a file canonical value shadow a CLI legacy\n override), so callers with sub-layers normalize each layer independently.\n\n ``max_parallel``/``eval_parallel`` are true aliases (pure eval width, no extra semantics) and are\n always promoted. ``parallel_build``/``llm_parallel`` are NOT: a positive ``llm_parallel`` also flips\n on the finite shared LLM broker (``orchestrator._startup_llm_total``), while legacy ``parallel_build``\n means \"legacy build width, unbounded overlap, no shared total\". So promoting a legacy-only\n ``parallel_build`` layer (e.g. just ``LOOPLAB_PARALLEL_BUILD=1`` in env) would silently serialize\n every provider call — contradicting llm_broker.py's docstring, orchestrator's \"legacy-only preserves\n unbounded overlap\" note, the broker-neutral live-control path, and the configuration.md ``llm_parallel``\n row (\"falls back ... without enabling a shared total\"). Config/startup loads therefore keep broker\n opt-in tied to an EXPLICITLY-spelled canonical ``llm_parallel``; the orchestrator's own width fallback\n (``llm_parallel if not None else parallel_build``) still carries the legacy build width. Only the\n Strategist live-control path (``canonicalize_strategy_parallelism``) opts into\n ``promote_build_to_llm_parallel``, where ``parallel_build`` is a deliberate full alias of the\n canonical knob (see strategy.py ``_apply_strategy``).\n \"\"\"\n if not isinstance(values, dict):\n return values\n out = dict(values)\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if canonical == \"llm_parallel\" and not promote_build_to_llm_parallel:\n continue\n # None is the durable legacy mode for llm_parallel, not a missing value. A normal snapshot\n # contains llm_parallel:null plus parallel_build:1; promoting null to 1 would unexpectedly enable\n # the global one-call broker. Promote aliases only when the canonical key is absent, and pin\n # snapshot round-trip parity. Canonicalize inside each precedence layer, never across the\n # flattened result, so the alias migration cannot invert CLI > file > env precedence.\n if canonical not in out and out.get(legacy) is not None:\n out[canonical] = out[legacy]\n return out\n\n\ndef flatten_parallelism_layers(layers) -> dict:\n \"\"\"Flatten same-tier precedence layers (LOWEST priority first) and resolve the legacy pair.\n\n PRECEDENCE REPAIR for the non-promoted `parallel_build`/`llm_parallel` pair. Declining to promote\n in `canonicalize_parallelism_source` is right (a legacy-only layer must not flip on the shared\n broker), but it let a LOWER-priority canonical value survive a HIGHER-priority legacy override:\n file `llm_parallel: 2` + CLI `-s parallel_build=8` flattened to BOTH keys, and the orchestrator\n prefers `llm_parallel`, so the operator's explicit CLI width was silently discarded. When a higher\n layer names the legacy width over a lower layer's canonical one, mask with the LEGACY SENTINEL\n (`llm_parallel=None` is the durable \"legacy mode — build width from parallel_build, no shared\n total\"), so the higher layer wins with the semantics it asked for.\n\n The mask lives HERE, not in the per-layer canonicalizer, because only a flatten knows the layer\n ORDER. Stamping it per layer meant any layer naming `parallel_build` emitted `llm_parallel=None`\n even when NO layer in the tier set the canonical key — and since pydantic-settings ranks init\n kwargs above env, that sentinel then silently overrode a `LOOPLAB_LLM_PARALLEL` the operator had\n exported as a deliberate provider rate/cost ceiling. Precedence is per FIELD: a tier that never\n spelled `llm_parallel` has not set it, and it must fall through to env/.env/defaults — which is\n exactly what `build_settings`' docstring promises (\"the merged dict wins only where it actually\n sets a value\").\n \"\"\"\n merged: dict = {}\n legacy_at = canonical_at = -1\n for index, layer in enumerate(layers):\n if not isinstance(layer, dict):\n continue\n merged.update(layer)\n if layer.get(\"parallel_build\") is not None:\n legacy_at = index\n if \"llm_parallel\" in layer:\n canonical_at = index\n # Only when this tier DID set the canonical key, at a strictly lower layer than the legacy one.\n if canonical_at >= 0 and legacy_at > canonical_at:\n merged[\"llm_parallel\"] = None\n return merged\n\n# Settings whose semantics are committed by ``run_started`` and restored from the folded event log\n# on every re-entry. Changing one only in ``config.snapshot.json`` would mix incompatible evidence\n# (a different holdout split) or make the live producer disagree with replay's selection policy.\n# The HTTP config editor and the engine both consume this one contract; keep deliberately mutable\n# controls such as ``trust_gate`` out (that field has its own durable change event).\nRUN_START_PINNED_FIELDS = frozenset({\n \"card_driven_selection\",\n \"speculation_depth\",\n \"holdout_fraction\",\n \"holdout_select\",\n \"select_verifier\",\n \"select_verifier_samples\",\n \"verifier_ci_tie\",\n})\n\n\ndef run_start_pinned_settings(state) -> dict:\n \"\"\"Return the effective Settings names committed by a folded run-start record.\n\n Pre-pin legacy logs have no recorded holdout fraction, so their snapshot remains authoritative\n for both holdout knobs. Verifier fields, however, have always been re-pinned from the legacy-safe\n fold defaults whenever a valid ``run_started`` identity exists.\n \"\"\"\n if not getattr(state, \"run_id\", \"\"):\n return {}\n values = {\n # Layer 3 changes which queue owns macro-action selection. Once a run starts, replay and\n # resume must keep the same owner even if the snapshot or ambient default changes later.\n \"card_driven_selection\": bool(getattr(state, \"card_driven_selection\", False)),\n # Layer 5 overlaps Card production with evaluation only when this pinned treatment is\n # positive. Old logs omit it and therefore retain the exact serial build/eval spine.\n \"speculation_depth\": int(getattr(state, \"speculation_depth\", 0)),\n \"select_verifier\": bool(getattr(state, \"select_verifier_tiebreak\", False)),\n \"select_verifier_samples\": int(getattr(state, \"select_verifier_samples\", 3)),\n \"verifier_ci_tie\": bool(getattr(state, \"verifier_ci_tie\", False)),\n }\n holdout_fraction = getattr(state, \"holdout_fraction\", None)\n if holdout_fraction is not None:\n values.update({\n \"holdout_fraction\": holdout_fraction,\n \"holdout_select\": bool(getattr(state, \"holdout_select\", False)),\n })\n if not values.keys() <= RUN_START_PINNED_FIELDS:\n raise RuntimeError(\"folded run-start pinned settings contract drifted\")\n return values\n\n# --- Run profiles (config-first presets) -------------------------------------------------------\n# A `profile` is a NAMED BUNDLE of setting defaults — nothing more. It only fills fields the user\n# did NOT set explicitly (via file / --set / LOOPLAB_* env / init-kwargs), so any explicit knob\n# always wins (see `_apply_profile`). Most engine intelligence lives behind individual flags. The\n# PART IV/V cross-run + concept-graph machinery (concept_pivot, graded_novelty, cross_run_concepts /\n# _advisory / _structured_claims / _curation / _read_tools, fingerprint_universal, foresight_verify)\n# now ships ON in the product `Settings` default. That is an explicit experimental product choice,\n# not a claim that a frozen A/B or workload-wide cost/quality gate has cleared: the bundle can steer\n# prompts and candidate generation, change graded-novelty admission / pre-execution choices, and add\n# paid LLM work when its client and memory prerequisites are present. The bare-library `EngineOptions`\n# default stays lean (see tests/test_options_divergence.py — product side is the aggressive one) so a\n# toy `Engine(...)` in a test doesn't fire cross-run LLM work unasked. The trust/confirm/ablate QUALITY\n# bundle still ships OFF and is turned on by `thorough`. Every value here is reachable by hand — the\n# profile is a convenience, never a hidden mode.\n# keep this rationale behavioral and evidence-based; do not relabel read-only storage\n# access or proposal-only governance as a no-op for agent behavior, cost, or proposal admission.\n#\n# `thorough` deliberately touches only QUALITY/TRUST machinery, not spend: it does NOT raise\n# max_nodes / eval_parallel (those are cost knobs the user owns). It enables multi-seed confirmation\n# (demote seed-lucky leaders), the reward-hack / leakage / critic monitors AND flips them from\n# advisory to *gating* (`trust_gate=\"gate\"` — a\n# flagged win can no longer be selected as best), ablation-driven refinement, and the prompt cues\n# (complexity / budget / failure-reflection / watchdog-reflection). Agentic novelty and the experimental\n# Part IV/V bundle are already product defaults, not profile changes. Effect is large; marginal cost is a\n# few extra evals.\nPROFILES: dict[str, dict] = {\n \"default\": {}, # explicit no-op alias for \"ship-as-is\" (== omitting profile)\n \"fast\": {}, # today's lean defaults, named for symmetry with `thorough`\n \"thorough\": {\n \"confirm_top_k\": 3,\n \"confirm_seeds\": 3,\n # novelty_gate intentionally NOT enabled here: duplicate-detection is the agentic Researcher's\n # job (it reads past experiments + find_analogous_across_runs and DECIDES), not an algorithmic\n # param-distance / embedding auto-reject that mis-fired (it nudged a good fresh idea onto a\n # twice-dead lineage — live node 61). Search stays as tools; the LLM judges novelty.\n \"operator_bandit\": True, # P4: adaptive operator mix over folded yields (GreedyTree)\n \"reward_hack_detect\": True,\n \"code_leakage_detect\": True,\n \"critic_check\": True,\n \"trust_gate\": \"gate\", # detectors stop gating a flagged node from WINNING (not just audit)\n \"ablate_every\": 3,\n \"complexity_cue\": True,\n \"budget_aware\": True,\n \"failure_reflection\": True,\n \"watchdog_reflection\": True, # feed recent live-watchdog (train-monitor/ASHA) flags to proposals\n \"reflection_priors\": True, # no-op unless memory_dir is set (cross-run priors)\n },\n}\n\n\n# The shipped agent-governance matrix (who may change which setting at runtime). Defined as a module\n# constant so the Engine's direct-construction default matches this shipped default exactly (the\n# EngineOptions \"Engine() == shipped defaults\" invariant) instead of diverging to an all-locked map.\nDEFAULT_AGENT_CONTROL: dict[str, list[str]] = {\n \"timeout\": [\"researcher\", \"strategist\"],\n # Layer-2 canonical names are the presented/default governance surface. `_agent_may` falls back\n # to an old snapshot's legacy grant only when its canonical counterpart is absent, so removing\n # duplicate defaults preserves resume authority while an explicit canonical revocation still wins.\n \"eval_parallel\": [\"strategist\"],\n \"llm_parallel\": [\"strategist\"],\n # Strategy-only pseudo-setting: the closed per-lane allocation inside the canonical LLM total.\n # It is not a launch Settings field; operators pin it through `set_strategy` and the Strategist\n # may reallocate it only while this grant is present.\n \"llm_lane_limits\": [\"strategist\"],\n \"max_nodes\": [\"strategist\", \"boss\"],\n \"max_eval_seconds\": [\"strategist\", \"boss\"],\n \"policy\": [\"strategist\"],\n # The policy NAME and its PARAMS are gated independently in `_apply_strategy`; grant both by default\n # so the Strategist that may switch to (say) MCTS may also apply the `c`/`eta` it decided — else its\n # tuned params are silently dropped and the recorded `active_strategy` diverges from the live engine.\n \"policy_params\": [\"strategist\"],\n \"n_seeds\": [\"strategist\"],\n \"ablate_every\": [\"strategist\"],\n \"merge_mode\": [\"strategist\"],\n \"complexity_cue\": [\"strategist\"],\n \"ablate_code_blocks\": [\"strategist\"],\n \"prefer_sweep\": [\"strategist\"],\n \"novelty_stance\": [\"strategist\"],\n \"developer\": [\"strategist\"],\n \"fidelity\": [\"strategist\", \"researcher\"],\n}\n\n\ndef default_agent_control() -> dict[str, list[str]]:\n \"\"\"A FRESH deep-ish copy of the shipped governance matrix (each role list copied so a caller can\n mutate its own map without touching the module constant). The SINGLE constructor of the default,\n called by both `Settings.agent_control`'s default_factory AND the Engine's direct-construction\n default — so \"Engine() == shipped defaults\" can't drift if the copy depth ever needs to change\n (e.g. nested values requiring copy.deepcopy), instead of two verbatim copy expressions.\"\"\"\n return {k: list(v) for k, v in DEFAULT_AGENT_CONTROL.items()}\n\n\nclass Settings(BaseSettings):\n \"\"\"The engine settings schema (every knob a run accepts).\n\n Timeout family — six distinct knobs, each owned by a different subsystem:\n - `timeout`: per-eval wall-clock budget for ONE experiment's evaluation (engine/eval).\n - `max_eval_timeout`: hard ceiling for a governed per-node Researcher timeout override.\n - `llm_timeout`: LLM request idle timeout — inter-token stall limit in stream mode\n (core.llm OpenAICompatibleClient).\n - `llm_header_timeout`: LLM first-byte (response-headers) window for stream attempts (core.llm).\n - `agent_time_budget_s`: wall-clock ceiling across a tool-using agent loop's turns (agents).\n - `dep_install_timeout`: per-package budget for auto-installing a missing dep (env self-prep).\n\n Config-source precedence is owned by `looplab.core.appconfig` (the loader) — see its module\n docstring for the one canonical order.\n \"\"\"\n # Config precedence: see appconfig.py — the loader owns the full order. The `.env` is read\n # so `looplab run`/`looplab ui` pick up LOOPLAB_LLM_BASE_URL etc. without exporting them by hand\n # (keys MUST carry the LOOPLAB_ prefix, same as the env vars). utf-8-sig tolerates a BOM from\n # Windows editors. The test suite disables this (tests/conftest.py) so a dev's real .env in the\n # repo root can't leak into assertions.\n model_config = SettingsConfigDict(\n env_prefix=\"LOOPLAB_\",\n env_file=\".env\",\n env_file_encoding=\"utf-8-sig\",\n extra=\"ignore\",\n allow_inf_nan=False,\n )\n\n @classmethod\n def settings_customise_sources(\n cls, settings_cls, init_settings, env_settings, dotenv_settings,\n file_secret_settings):\n \"\"\"Preserve source precedence across canonical/legacy parallelism names.\n\n Pydantic ranks init > env > dotenv correctly, but it ranks field names rather than semantic\n alias families. Promoting a legacy value inside each source before the merge makes an old\n explicit snapshot/file value beat a lower-priority canonical env value. ``appconfig`` also\n promotes its file/typed/--set sub-layers before combining them into the init source.\n \"\"\"\n def _with_parallel_aliases(source):\n def _read():\n values = canonicalize_parallelism_source(source())\n # Credential components are a record, not independently mergeable settings. If a\n # winning source supplies only one half, insert an explicit None for its peer so a\n # lower-priority source can never complete an attacker-controlled mixed pair.\n pair = {\"llm_api_key\", \"llm_api_key_base_url\"}\n present = pair.intersection(values)\n if present and present != pair:\n values = dict(values)\n values[next(iter(pair - present))] = None\n return values\n return _read\n\n return (\n _with_parallel_aliases(init_settings),\n _with_parallel_aliases(env_settings),\n _with_parallel_aliases(dotenv_settings),\n _with_parallel_aliases(file_secret_settings),\n )\n\n # Run profile (config-first preset, see PROFILES above). \"default\"/\"fast\" = today's lean\n # defaults; \"thorough\" turns the built quality/trust machinery ON in one word. A profile only\n # fills fields the user did NOT set — every knob it touches stays individually overridable.\n profile: str = \"default\"\n\n # Bounds (review C/⚪): a non-positive node/seed budget or timeout is never valid. Upper bounds are\n # equally load-bearing — the UI /start path writes these straight into the engine's env, and an\n # unbounded `eval_parallel` (or legacy `max_parallel`; e.g. 100000 from a crafted preflight) would\n # fan out that many sandboxes → resource exhaustion. Reject at config time, not mid-run. Ceilings\n # are generous; tests top out near 50.\n # Pydantic's lax mode coerces a JSON boolean into any numeric field (`true` -> 1), so a UI/API type\n # error used to silently collapse a run budget to ONE node instead of returning 422. Rejected for\n # EVERY numeric field by `_reject_bool_for_numeric_fields` below, not just the ones bounded here.\n n_seeds: int = Field(default=3, ge=1, le=1024)\n max_nodes: int = Field(default=8, ge=1, le=1_000_000)\n # legacy evaluation-width input. `eval_parallel` below is canonical and wins when set;\n # keep this field for old env/config/snapshots and direct Engine call sites. A width > 1 admits\n # concurrent evals, whose resource reservations may be CPU-only or span one/more GPUs — there is no\n # one-node/one-GPU promise. `max_parallel=0` is launch-time AUTO (detected GPU count, at least 1).\n max_parallel: int = Field(default=1, ge=0, le=1024)\n # legacy build-width input. `llm_parallel` below is canonical and also controls the\n # shared provider-call total when positive. A width > 1 uses fresh pooled researcher/developer pairs;\n # it says nothing about the later eval's CPU/GPU reservation. Startup `0` derives build fan-out from\n # resolved eval width; a live Strategist/operator zero settles to 1. Without role_factory it clamps to 1.\n parallel_build: int = Field(default=1, ge=0, le=64)\n # Hypothesis-card Kanban re-architecture (docs/23, Layer 2). Two INDEPENDENT concurrency axes,\n # decoupled from each other so cost (LLM work) can be tuned separately from throughput (GPU evals).\n # These are the CANONICAL names going forward; `max_parallel`/`parallel_build` above are kept as thin\n # back-compat aliases (env vars / snapshots / ~100 Engine(...) call sites depend on the exact names,\n # so they cannot be renamed — CLAUDE.md). Resolution (orchestrator.__init__): a NEW name, when set,\n # WINS over its legacy alias; None => fall back to the legacy field => byte-identical to today.\n # `eval_parallel`: concurrent EVALS (the GPU/experiment consumer). None -> max_parallel; 0 = AUTO\n # (one experiment per detected GPU). This is the in-Run eval-concurrency ceiling; the resource\n # scheduler bin-packs declared footprints separately. Sibling local GPU-owning Runs serialize\n # through the conservative host-pool lease; external execution needs its own admission boundary.\n # `llm_parallel`: total concurrent LLM provider calls + BUILD fan-out. None -> legacy\n # parallel_build semantics without a shared total; 0 = startup AUTO (= resolved eval_parallel\n # for build fan-out, historical research overlap unbounded). A positive canonical value enables\n # the named-lane broker the Strategist can reallocate at cadence.\n eval_parallel: int | None = Field(default=None, ge=0, le=1024)\n llm_parallel: int | None = Field(default=None, ge=0, le=64)\n # Training-log monitor (I-series watchdog family): a per-eval background observer that tails the live\n # training log while a (often multi-hour) declared stage runs in a worker thread. ON in the product\n # surface (Settings). Its alert event is fold-ignored and cannot directly change lifecycle/champion;\n # with watchdog_reflection on, that alert is nevertheless advice in a later Researcher\n # prompt and can change future proposals. It only fires on the command-eval path with an LLM client;\n # bare-library EngineOptions stays OFF. See test_options_divergence.\n # `_interval_s` is the BASE tick cadence (the effective one adapts to the per-experiment budget).\n train_monitor: bool = True\n train_monitor_interval_s: float = Field(default=600.0, gt=0)\n # Phase 3 — INTERVENTION (opt-in, separate from observation): let the monitor tree-kill a training the\n # LLM judges 'broken' (diverged / silent CPU fallback / not learning) EARLY, instead of burning the\n # whole budget. Off by default — the observer only WATCHES unless this is on. A kill only fires on a\n # 'broken' verdict (a plateau is 'watch', never killed) with confidence >= the threshold; the node then\n # fails normally (reason='monitor_broken'), so replay reconstructs it from that one terminal event.\n train_monitor_kill: bool = False\n train_monitor_kill_confidence: float = Field(default=0.8, ge=0.0, le=1.0)\n # ASHA live-curve watchdog (sibling of the training monitor): reads the latest INTERMEDIATE value of\n # the objective metric off the live log (reusing the eval's OWN metric reader). Finished-endpoint rank\n # remains advisory. An opt-in KILL additionally requires an operator-declared metric.resource_key and\n # enough sibling observations at exactly that resource; absent comparable evidence cannot stop a run.\n # Records a fold-ignored `asha_rank` diagnostic + trace span; the library default is OFF (off == today).\n asha_live: bool = True\n # Opt-in INTERVENTION (separate from the advisory signal): let the watchdog tree-kill a node whose\n # intermediate metric stays below the SAME-RESOURCE bar past a short grace window. Off by default —\n # it only surfaces endpoint rank unless this is on. A kill fails normally (asha_underperforming).\n asha_live_kill: bool = False\n # The bar sits at `asha_live_quantile` along a WORST->BEST ordering of the finished siblings' finals:\n # 0.5 = the median (a node worse than the median peer flags); a SMALLER value is more conservative —\n # it lowers the bar toward the WORST finished peer, so only a node worse than nearly all peers flags\n # (0.0 = worse than the worst). `min_siblings` is the floor of finished peers required before it ranks\n # at all, so it never acts on too little evidence.\n asha_live_quantile: float = Field(default=0.5, ge=0.0, le=1.0)\n asha_live_min_siblings: int = Field(default=3, ge=1)\n timeout: float = Field(default=30.0, gt=0)\n # Hard ceiling for the Researcher's governed per-node `Idea.eval_timeout`. One hour preserves the\n # existing heavy/neural-net authoring example while staying well below the sandbox's defensive\n # 24-hour subprocess ceiling. This is operator-owned run config: agents may request less or more,\n # but the accepted action is always clamped here after the `agent_control.timeout` permission gate.\n max_eval_timeout: float = Field(default=3600.0, gt=0, le=24 * 3600.0)\n # Intra-node sweep: a sweep node runs a whole grid in one process, so it gets this multiple of\n # `timeout` as its wall-clock budget (solution.py path; RepoTasks use their per-profile timeout).\n sweep_timeout_mult: float = Field(default=8.0, ge=1)\n # Eval STALL watchdog CAP (seconds): a stage that is completely SILENT on stdout/stderr for this\n # long — while still alive and BELOW its wall-clock deadline — is tree-killed early with a STALLED\n # marker (a hung dataloader/deadlock dies in minutes instead of burning a multi-hour timeout). The\n # per-stage window is `min(this, the stage's own timeout)`, so a short scorer's deadline fires first.\n # `0` DISABLES the watchdog entirely (only the hard deadline applies) — for a legitimately quiet\n # non-Python stage (block-buffered stdout, a script that logs only to its own file). Threaded into\n # the eval and surfaced to the Developer so its code can emit periodic progress to stay alive.\n eval_stall_timeout_s: float = Field(default=1800.0, ge=0)\n # Sandbox tier (ADR-13): \"trusted_local\" (subprocess, no Docker) for the CLI;\n # \"untrusted\" (Docker --network none, shared-kernel runtime) for hosted/multi-tenant UI;\n # \"hostile\" (untrusted + a true-isolation OCI runtime, gVisor `runsc` by default / Kata) for\n # running untrusted code. See runtime/sandbox.py::make_sandbox for the tier selection.\n trust_mode: str = \"trusted_local\"\n # Image for the untrusted command-eval tier (RepoTask, Phase 4): the framework's deps\n # should be baked in (the container runs --network none, so a pip setup can't fetch).\n docker_image: str = \"python:3.12-slim\"\n # Resource caps for the untrusted/hostile Docker tier (passed to `docker run --memory/--cpus`).\n # The default 4g bound protects other tenants from an OOM, but a model-training MLE-bench eval\n # routinely needs more — raise it (e.g. \"16g\") for such runs. \"\" disables the cap (unbounded).\n # Ignored by the trusted_local (subprocess) tier.\n sandbox_memory: str = \"4g\"\n sandbox_cpus: str = \"\"\n # Best-effort host-OOM guard for the TRUSTED-LOCAL (subprocess) tier. When set (e.g. \"8g\"), each\n # eval child gets an RLIMIT_AS address-space cap so a runaway trainer hits MemoryError instead of\n # OOM-killing the whole host (and the engine). POSIX only; a no-op on Windows. Default \"\" = OFF:\n # RLIMIT_AS bounds VIRTUAL memory, and CUDA/torch reserve tens of GB of virtual without using it,\n # so a default cap would spuriously kill GPU evals — leave it off for those and use the Docker\n # tier's real --memory cgroup bound (sandbox_memory) instead. Ideal for CPU/numpy/sklearn runs.\n sandbox_memory_local: str = \"\"\n # Best-effort disk-fill guard for the TRUSTED-LOCAL tier (P1-5): an RLIMIT_FSIZE cap on the size of\n # any single file an eval child writes (e.g. \"2g\"), so a runaway that writes an unbounded file gets\n # SIGXFSZ instead of filling the host disk. POSIX only. Default \"\" = OFF (large model checkpoints\n # need big files); set it for tasks that write only small artifacts.\n sandbox_fsize_local: str = \"\"\n # Search policy (ADR-2): \"greedy\" | \"evolutionary\" | \"mcts\" | \"asha\" | \"bohb\".\n # `bohb` is a registered policy (`search/policy.py::_REGISTRY` aliases it to the ASHA factory,\n # which BOHB reuses as its racing half) and is advertised by `appconfig.render_template` and the\n # `bohb` template kind; this comment used to omit it and undersell the valid set. The value is\n # not validated at config time, but `make_policy` raises on an unknown name — loud-late rather\n # than silent, unlike `developer_backend` above.\n policy: str = \"greedy\"\n # Ablation-driven refinement (I7): every N improves, ablate the best to find the\n # highest-impact parameter and refine it. 0 = off. (GreedyTree only.)\n ablate_every: int = 0\n # A0a (MLE-STAR): when ablating, treat each generated pipeline *code block* (not just a\n # numeric param) as an ablation unit — neutralize a block, measure the metric delta, refine\n # the highest-impact block. Off => the classic numeric-param ablation. Config-first knob.\n ablate_code_blocks: bool = False\n # A0b: real merge/ensembling. \"mean\" = legacy mean-param merge; \"ensemble\" = the Developer\n # writes a code-recombination ensemble over the two parents' solutions (verified: agent-proposed\n # ensembling 37.9%->43.9%). \"auto\" (default) = ensemble whenever the Developer actually\n # generates code (LLM/agent backends; declared via `is_code_generating`), mean for the\n # templated/toy developers where a code ensemble is meaningless — the evidence says code\n # recombination is the strongest single merge (removing it costs ~9 pp), so it is the default\n # wherever it can work. Falls back to mean when the Developer can't ensemble.\n merge_mode: str = \"auto\"\n # A0d (AIRA): inject a dynamic complexity hint into the draft/improve prompt keyed on the\n # node's child count (few children -> keep minimal; many -> escalate to ensembling/HPO).\n complexity_cue: bool = False\n # I1 (CAAFE-style) LLM feature-engineering: instruct the proposer/developer to add\n # semantically-meaningful engineered features (ratios, interactions, aggregations) as code. The\n # existing cross-validation eval + best-selection is the CV GATE — a feature set that doesn't\n # improve CV is never selected (feature engineering is non-universal, so this gate is mandatory).\n # Tabular tasks (those exposing column data). Off by default.\n feature_engineering: bool = False\n # A5: surface the REMAINING eval-compute budget into the proposal prompt so the Researcher can\n # reason over how much compute is left (explore broad while flush, exploit/cheapen when low).\n # No-op unless a max_eval_seconds budget is set.\n budget_aware: bool = False\n # A4 (LATS-style): feed a summary of the most recent FAILED branches (operator + error reason)\n # back into the proposal prompt so the proposer reflects on and avoids repeating them. ON by\n # default: it is SELECTIVE by construction (injects only when recent failures exist — the\n # evidenced form of reflection; reflecting at every step shows no gains, arXiv:2506.12928).\n failure_reflection: bool = True\n # Feed the LIVE-WATCHDOG observations (train-monitor health verdicts + ASHA intermediate-rank flags)\n # of recent experiments back into the proposal prompt, so the Researcher avoids re-proposing a config\n # whose TRAINING was already observed to be weak — even when the watchdog kills are off (default) and\n # the node ran to completion (those diagnostics are fold-ignored, so failure_reflection never sees\n # them). ON by default and SELECTIVE like failure_reflection (injects only when a recent flag exists).\n watchdog_reflection: bool = True\n # C3 deep test-driven repair: hand the Developer the failure taxonomy + a structured \"reproduce\n # then fix\" directive on debug, not just the raw stderr tail. ON by default (product surface); the\n # conservative library default in EngineOptions stays off, per that module's contract.\n deep_repair: bool = True\n # === Inline crash repair ==============================================================\n # Hybrid in-node crash repair: when an LLM-generated node CRASHES at runtime (mechanical errors\n # — bad import, removed kwarg, typo), the agent triages it and may repair the code IN PLACE within\n # the same eval (cheap; does NOT consume max_nodes and does NOT add a node to the search tree).\n # Semantic failures still flow to a new idea/debug node via the policy. ON by default; set False\n # to restore the prior behavior (every crash waits for a budgeted inter-node debug node).\n inline_repair: bool = True\n # Max in-place repair attempts per node before it fails normally. Default 0 = UNLIMITED: the same\n # agent keeps fixing + re-evaluating IN THE SAME node until it produces a clean metric — no new\n # node just to retry. Runaway is prevented not by a count but by the anti-stuck guard\n # (`inline_repair_stuck_repeat`): when the SAME error signature repeats with no progress, or the\n # agent's crash-triage says \"abandon\", the node fails. Set a positive N to cap the retries instead.\n inline_repair_attempts: int = Field(default=0, ge=0)\n # Anti-stuck: abandon in-node repair when the SAME error recurs this many times in a row (no\n # progress). Keeps \"unlimited\" repair from looping forever on an unfixable error.\n inline_repair_stuck_repeat: int = Field(default=4, ge=2)\n # Which failure reasons (_failure_reason: crash|timeout|oom|setup|no_metric|drift) are eligible for\n # inline repair. Default: mechanical crashes, timeouts AND OOM-kills — a timeout/OOM means the code\n # was too slow / too memory-hungry for the budget (or the pod's cgroup limit), not that the idea is\n # wrong, so the agent repairs it by reducing compute/memory (fewer estimators/epochs/folds/seeds, a\n # lighter model, a smaller batch, subsampling) instead of letting the node die unrecovered. Env\n # override expects a JSON array.\n inline_repair_reasons: tuple[str, ...] = (\"crash\", \"timeout\", \"oom\")\n # Cost ceiling for in-node repair of a MULTI-STAGE eval: the max number of FULL pipeline re-runs\n # (i.e. re-trains from the first stage) the inline-repair loop may do before abandoning to the\n # inter-node debug operator. A late-stage failure (a broken `score`/eval script that didn't touch\n # `train`) REUSES the completed train checkpoint and re-runs only from the failed stage — that is\n # CHEAP and NOT counted here; only a repair that changes an EARLIER stage's code (train.py/loss.py/…)\n # forces a full re-train, which each costs the whole training time. Without this bound, a repair that\n # keeps changing training code could burn many full trains (the anti-stuck guard is error-signature\n # based, not cost based). 0 = unlimited (legacy behavior). Single-command evals ignore it.\n inline_repair_retrain_cap: int = Field(default=2, ge=0)\n # Environment self-prep: when a solution crashes purely because a KNOWN library isn't installed\n # (ModuleNotFoundError), the engine pip-installs it into the eval interpreter and re-runs — so a\n # torch/XGBoost/CatBoost idea (e.g. a GRU model) actually runs on a fresh box instead of being\n # thrown away as `idea_rejected`. Trusted_local tier only (the untrusted/hostile Docker tiers run\n # --network none). A typo'd or local helper module is NOT installed (that's a code bug to repair).\n auto_install_deps: bool = True\n # Per-package wall-clock budget for an auto-install (seconds). Generous: large wheels like torch\n # can take minutes to download/build.\n dep_install_timeout: float = Field(default=900.0, gt=0)\n # --- Agent governance: who may change a setting at runtime (per-setting allow-list of roles) ---\n # Roles: \"strategist\" (A7 meta-controller; run-wide), \"boss\" (run-chat operator-proxy; run-wide),\n # \"researcher\" (per-experiment proposer; PER-NODE — e.g. set a longer eval timeout for a neural-net\n # idea). A setting ABSENT from this map is LOCKED — no agent may change it, only a human via the\n # snapshot/UI. The sole conditional exception is Strategy-only `card_scoring`: Card mode grants\n # it implicitly without changing the default flag-off snapshot, while an explicit [] revokes it.\n # The gate is ENFORCED at every AGENT seam via `_agent_may` — the strategist's whole\n # control surface (policy/operators/novelty_stance/prefer_sweep/developer/fidelity/timeout/\n # eval_parallel/llm_parallel) is checked in `_apply_strategy`, so removing a role from a knob locks it,\n # not just greys out the UI pill. The boss/max_* grants document boss-relevant knobs, but a\n # `budget_extend` is a HUMAN control intent (the boss action-builder can only emit `add_nodes`), so\n # it is applied as-is — the human always wins via the UI/snapshot. Defaults are conservative: resource/\n # search-shape knobs the agents already reason about; provider infra (llm_model/base_url/api_key,\n # docker_image) stays locked. The UI shows S/B/R pills per setting. `timeout` granted to the\n # researcher IS the \"auto\" per-node mode — the researcher sizes each experiment's wall-clock\n # (Idea.eval_timeout); the config `timeout` is the fallback for nodes it doesn't size. Env override\n # expects a JSON object. (`fidelity`/`novelty_stance`/`prefer_sweep`/`developer` are governance keys\n # for the strategist's per-node dials — not 1:1 Settings fields, but gated the same way.) The default\n # lives in the module-level DEFAULT_AGENT_CONTROL so the Engine's direct-construction default matches.\n agent_control: dict[str, list[str]] = Field(default_factory=default_agent_control)\n # C1 (Agentless localization): for repo tasks, rank the source files most relevant to the most\n # recent failure (traceback + identifiers) and surface them in the proposal/repair prompt so the\n # Developer edits the right place. Off by default; repo tasks only.\n localize_faults: bool = False\n # A2 surrogate-guided proposal (BO-lite): fit a k-NN surrogate over (params->metric) and propose\n # by acquisition instead of random/hill-climb. Numeric-bounds tasks only; bootstraps via the\n # base Researcher. Off by default. `surrogate_explore` = UCB-style exploration weight.\n surrogate_proposer: bool = False\n surrogate_explore: float = 0.1\n # E2 researcher panel: generate K candidate ideas per proposal and keep the one ranked best by a\n # cheap empirical surrogate over the (params->metric) history (NOT an LLM-judge). 1 = off.\n researcher_panel: int = 1\n # A1 ASHA / successive-halving: reduction factor (keep top 1/eta per rung) and rung budget.\n asha_eta: int = Field(default=3, ge=2)\n # Rung-0 width for ASHA/BOHB (the wide base of cheap drafts). 0 = use n_seeds (default,\n # preserving prior behavior); set >0 to seed a wider/narrower base independent of n_seeds.\n asha_rung_nodes: int = Field(default=0, ge=0)\n # A6 proxy/predictive scoring: cheaply rank a candidate's potential from early-stage signals\n # and skip a full eval for the doomed bottom fraction. 0.0 = off (no candidate skipped).\n proxy_scoring: bool = False\n proxy_kill_fraction: float = Field(default=0.0, ge=0.0, le=0.9)\n # === Novelty / dedup ==================================================================\n # E1 novelty/dedup gate: reject near-duplicate proposals (normalized param-space distance below\n # `novelty_epsilon`) by deterministically nudging them off the duplicate — stops the search\n # wasting evals re-trying the same idea. Audit event `novelty_rejected`. Off by default.\n # Novelty/dedup MODE selector: how a fresh proposal is checked against already-tried experiments.\n # \"off\" — no explicit gate; the agentic Researcher's own read-the-history judgment is trusted.\n # \"algo\" — the deterministic gate: numeric param-distance (`novelty_epsilon`) + optional embedding\n # similarity (`novelty_semantic`). Cheap, no LLM call, but can't explain itself.\n # \"llm\" — an LLM adjudicates (reads the real experiments via tools) whether the idea is a\n # near-duplicate and, if so, asks the Researcher once more for something different. One\n # extra LLM call per proposal — highest quality, follows the \"let the LLM decide\" line.\n novelty_mode: str = \"llm\"\n # Legacy sub-toggles, honored by the \"algo\" mode (and back-compat: novelty_gate=True forces \"algo\"):\n novelty_gate: bool = False\n novelty_epsilon: float = 0.05\n # T5 semantic novelty (active whenever the deterministic gate runs — novelty_mode=algo,\n # novelty_gate=true which is the legacy alias for algo, OR the Strategist novelty stance is\n # \"explore\"; a no-op only under novelty_mode=llm/off with a non-explore stance): reject a proposal\n # whose idea TEXT embeds within\n # `novelty_semantic_threshold` cosine of an existing node's. OFF BY DEFAULT: novelty is the agentic\n # Researcher's call, made by READING the actual prior experiments (read_experiment /\n # find_analogous_across_runs / list_experiments), NOT an embedding-similarity auto-reject that can't\n # explain itself and mis-fires on paraphrases. Semantic/param search stays available as TOOLS in the\n # Researcher's context; the LLM decides whether an idea is a true duplicate.\n novelty_semantic: bool = False\n novelty_semantic_threshold: float = Field(default=0.92, ge=0.5, le=1.0)\n # T10: debug-lineage depth bound for every policy — how many error-feedback repairs a failing\n # lineage gets before it is abandoned. The old default (1) abandoned lineages after ONE repair;\n # multi-turn/deeper debugging is a verified lever (AIRA2 ReAct debug +5.5 percentile points).\n debug_depth: int = Field(default=2, ge=1)\n # P4 operator bandit (GreedyTree): replace the fixed merge/ablate cadences with a\n # deterministic UCB over per-operator yield (Δmetric per eval-second, folded from the run).\n # Off by default (the cadences are well-tested and the bandit has no direct published\n # ablation); `thorough` turns it on.\n operator_bandit: bool = False\n # P1 hypothesis ledger: ask the Researcher to state the one-line hypothesis each experiment tests,\n # register deep-research directions as hypotheses, and track them to a verdict on the board. ON by\n # default. The board is shown to the Researcher and can be foresight-ordered, so it can\n # steer later proposals even though it never directly re-ranks already-evaluated nodes. Set False\n # to drop the prompt nudge + registration.\n track_hypotheses: bool = True\n # E4/M2/M3 reflection-memory -> priors (gradient-free cross-run meta-learning): at run end distill\n # the winner into `<memory_dir>/meta_notes.jsonl` AND structured lessons (incl. NEGATIVE results:\n # tested/abandoned hypotheses + failure themes) into `lessons.jsonl` with a task fingerprint; at run\n # start, inject exact-task notes + fingerprint-matched lessons from SIMILAR tasks into the proposal\n # prompt. ON by default — but a NO-OP until `memory_dir` is set (that's where cross-run memory lives).\n reflection_priors: bool = True\n # M6 comparative lessons (MARS \"comparative reflective memory\", doc 13 §7 item 2): distill\n # credit-assigned lessons from PAIRS of solutions — which SPECIFIC change made a child beat\n # (or regress from) its parent, and what fixed a failure — on top of the one-shot run-end\n # reflection. At most one extra LLM call per distillation (all pairs batched); offline a\n # deterministic param-diff credit stands in. Gated on reflection_priors + memory_dir like the\n # rest of cross-run memory — and since memory_dir has a default (~/.looplab/memory), this is\n # ACTIVE out of the box; clear memory_dir to disable cross-run memory wholesale.\n comparative_lessons: bool = True\n # M6 mid-run cadences (doc 13 §7 item 5 — the AgentRxiv live-share pattern): every N created\n # nodes, WRITE comparative lessons to the shared cross-run store during the run (lessons_every\n # — one batched LLM call per firing) and RE-READ the store so lessons distilled by CONCURRENT\n # runs reach this run's proposals (lessons_refresh_every; file re-read, no LLM call, skipped\n # when the store is unchanged). ON by default (every 4 nodes) and, like comparative_lessons\n # above, live out of the box because memory_dir defaults on; 0 = off — run-end write /\n # run-start read only, the pre-M6 behavior.\n lessons_every: int = Field(default=4, ge=0)\n lessons_refresh_every: int = Field(default=4, ge=0)\n # B3 output redaction: mask credentials (known key shapes + high-entropy tokens) in the\n # stdout/stderr tail before it is persisted to the event log / spans / UI — a leaked secret in a\n # print()/traceback must not land in the durable log. Off by default; recommend on for untrusted.\n redact_output: bool = False\n # B5 reward-hacking detector: a host-side monitor that flags suspicious wins (grader/answer-key\n # access, runtime writes to frozen files, suspiciously-perfect metrics) as a `reward_hack_suspected`\n # audit event in the Trust panel. Off by default. Whether a flag CHANGES selection is governed by\n # `trust_gate` below (default: audit-only, never changes selection).\n reward_hack_detect: bool = False\n # Trust enforcement (T2): what a reward-hack / data-leakage flag actually DOES to selection.\n # \"audit\" (default) = surface it in the Trust panel, never change selection (today's behavior);\n # \"gate\" = a flagged node can no longer be selected as BEST **and isn't bred/confirmed from**\n # (breed_excluded — see replay._apply_trust_gate / models.breedable_nodes), but stays\n # FEASIBLE so it still counts for diversity/audit — closes the \"a hacked/leaky node can\n # win OR seed its lineage\" hole;\n # \"block\" = additionally mark it fully INFEASIBLE (removed from feasible_nodes entirely).\n # Deliberately gates only high-precision CHEATING/LEAKAGE signals. Broad `critic:*` findings stay\n # advisory; `critic:hardcoded_metric` is the one narrow high-precision critic exception\n # and gates because it requires a literal metric with no computed assignment anywhere.\n trust_gate: str = \"audit\"\n # I3 data-centric: static code-leakage scan of each evaluated solution (fit-before-split,\n # fit-on-test) surfaced into the Trust panel alongside reward-hack flags. Off by default.\n code_leakage_detect: bool = False\n # 4.4 sandbox instrumentation (needs reward_hack_detect): after each eval, flag a RUNTIME write\n # to a protected/frozen file (an answer key or grader tampered mid-run) — behavioral evidence a\n # static code scan misses. ON by default; only fires when the reward-hack detector is on.\n workdir_audit: bool = True\n # D8 decoupled research Verifier: check every Deep-Research memo's CLAIMS against their cited\n # evidence (node ids / urls) before recording — synthesis is the documented weak link (Kosmos\n # 57.9% accurate). Deterministic layer always; an LLM rubric pass when the DeepResearcher has a\n # client. The verdict cannot change this run's node/champion, but finalize uses aligned supported\n # verdicts as the evidence gate for persisted D8 cross-run claims; unavailable/misaligned stays\n # unverified. ON by default (no-op with no memo/claims).\n research_verify: bool = True\n # C4 independent critic: an execution-free critic of each solution (stub / hardcoded-metric /\n # params-ignored; on host-graded tasks the metric checks become a submission-output check)\n # surfaced in the Trust panel. Broad findings are advisory; `critic:hardcoded_metric` can gate under\n # trust_gate=gate/block. Off by default.\n critic_check: bool = False\n # A7 Strategist (NEW, user-requested): optional LLM/rule meta-controller that picks the search\n # policy/allocator + operator mix + fidelity (+ Developer backend) per situation. Config-first:\n # every choice the Strategist makes is also a direct knob. Defaults to \"agent\" so the agent adapts\n # the search policy/operators/fidelity per situation; set \"off\" for static config-driven search.\n # \"agent\" = a tool-using Strategist that READS the run/data/sibling-runs/KB/memory (B1-guarded)\n # before deciding, instead of the single-shot \"llm\" call over aggregate stats.\n strategist_backend: str = \"agent\" # \"off\" | \"rule\" | \"llm\" | \"agent\" — default AGENTIC: the\n # Strategist READS the run/data/sibling-runs/KB/memory with tools before deciding, not a single-shot\n # call over aggregate stats. \"llm\" = the old non-agentic single-shot; \"rule\"/\"off\" = no LLM.\n strategist_every: int = Field(default=3, ge=1) # consult cadence (created nodes)\n # PART V (F1): concept CLASSIFIER re-tag + consolidation cadence, DECOUPLED from strategist_every. The\n # LLM concept map is heavier and slower-moving than a strategy consult, so it refreshes on its own\n # (sparser) interval. Researcher-authored idea.concepts still fold immediately at node_created — this\n # only paces the classifier-evidence + consolidation refresh (and the concept-coverage pivot snapshot).\n concept_retag_every: int = Field(default=30, ge=1)\n # === Confirmation & holdout ===========================================================\n # Multi-seed confirmation (I12, ADR-15): confirm the top-k under N seeds before\n # finishing. 0 disables (default). Only meaningful when eval has variance.\n confirm_top_k: int = 0\n confirm_seeds: int = 0\n # D1 seed-holdout discipline: the FIRST seed the confirm phase uses. Search evals run with the\n # implicit seed 0 (LOOPLAB_EVAL_SEED unset -> \"0\"), so a base of 1 keeps every confirm split\n # DISJOINT from anything the search optimized against — the confirm metric is then a\n # generalization signal, not a re-measurement (AIRA: selecting on a signal the search saw\n # overfits by 9-13 pp). Set 0 to restore the legacy overlapping seeds 0..N-1.\n confirm_seed_base: int = Field(default=1, ge=0)\n # D1 holdout-gated promotion (B6, Arbor-style): for host-graded tasks, reserve this fraction of\n # the held-out labels as a FINAL holdout partition the search never sees — every search/confirm\n # eval is scored on the remaining rows only, and at finish the val-top-k are re-scored on the\n # holdout partition (free: the predictions already exist). 0.0 = off (legacy full-label scoring).\n holdout_fraction: float = Field(default=0.25, ge=0.0, le=0.9)\n # Champion selection prefers the HOLDOUT metric among the nodes that have one (the val-top-k).\n # Recorded in run_started so replay applies the same rule; old logs fold to False (legacy pick).\n # The per-node val-holdout `generalization_gap` folds into the Trust panel either way.\n holdout_select: bool = True\n # How many val-leaders get a holdout evaluation at finish (host-graded tasks).\n holdout_top_k: int = Field(default=3, ge=1)\n # R1-c / Part IV — calibrated §12-verifier tie-break in best-selection. When a fresh node's robust\n # metric EXACTLY TIES an already-eligible node, the engine runs the §12 verifier (grounded on each\n # node's realized result, `selection_criteria`) and records a soundness score per node. The tie-break\n # then applies across the WHOLE selection path: the mean pick breaks a robust_metric tie by the score,\n # and — when `holdout_select` is on — the holdout-slot ranking prefers the sounder node so it isn't\n # denied a holdout eval, and the final holdout override breaks a holdout_metric tie by it too. Strictly\n # a TIE-BREAK — it can NEVER move a node ahead of a strictly-better metric (§21.7 advisory-never-\n # overrides). Best-effort: verification is LAZY (only fires on an actual tie) and bounded per cadence,\n # so a large/late tie stays on deterministic metric+id order unless one atomic treatment scores every\n # member. Recorded in run_started so\n # replay applies the same rule; old logs fold to False (legacy pick). Opt-in (default off); needs a\n # reachable LLM client. Enable only after validating calibration offline with `verifier.calibrate`.\n # See trust/verifier.py + core/fitness.py.\n select_verifier: bool = False\n # R1-d (§21.19): widen the verifier tie-break from EXACT-metric to a conservative STATISTICAL tie.\n # The band is capped by both the metric leader's SE and the pooled SE-of-difference, so challenger\n # variance cannot widen it and a significant difference is never a tie (§21.7). Off -> exact only.\n verifier_ci_tie: bool = False\n # Verifier sample count for `select_verifier` (the §12 repeated-sampling expectation). 3 tames the\n # single-shot variance; 1 = single-shot (cheaper, noisier).\n select_verifier_samples: int = Field(default=3, ge=1, le=32)\n # Budget (I13): hard wall-clock ceiling; the run aborts cleanly when exceeded.\n max_seconds: float | None = None\n # Eval-compute budget (#2): hard ceiling on cumulative time spent INSIDE evals (training\n # runs), separate from wall-clock. Survives resume (summed from the event log). The real\n # guard against a silent multi-hour sweep when an eval is a minutes-hours training run.\n max_eval_seconds: float | None = None\n # Cross-run memory (I19, ADR-10): if set, the best result of each run is stored as\n # a case here, and the cases become retrievable knowledge for future runs.\n memory_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"memory\"))\n # HITL (I21, ADR-11): pause for human approval of the final best before finishing.\n require_approval: bool = False\n # Diversity archive (I22): niche bucket width in parameter space.\n archive_resolution: float = 1.0\n # Coverage read-model (narrowing signal): at the strategist cadence, compute a breadth summary\n # (themes / param-niches / theme entropy / dominant-theme fraction) from the folded run, record\n # it as a `coverage_snapshot` audit event, and surface it into the Strategist's decision context.\n # Deterministic, cheap, and purely additive context — no search-behavior change on its own. On by\n # default (like the rest of the always-on situational context). See search/coverage.py.\n coverage_context: bool = True\n # PART IV Phase 2a live steering (§21.11/§21.13). When on, the `concept_retag_every` cadence (NOT\n # `strategist_every` — the producer gates on `_should_consult_concepts`, which uses the seed boundary\n # + `concept_retag_every`, default 30) records a\n # concept-graph coverage + uncovered-region snapshot (deterministic heuristic tagger over the\n # task-type skeleton) and, on an `explore` stance, the Researcher's novelty hint names the exact\n # uncovered regions (\"0 coverage in {negatives/external-mining, distillation} — go there\") instead\n # of the vague \"broaden\". ON by default in the product Settings (ce4a379), EngineOptions off: it\n # only enriches an already-mode-gated explore hint, never forces exploration, and no-ops for a task\n # with no curated concept skeleton. The snapshot is auditable, but its prompt cue can change future\n # candidate generation; it does not directly rewrite best-metric selection. See search/concept_graph.py.\n # DESIGN NOTE (2026-07-17 critique): now ON unconditionally, but a toy / non-ML task yields EMPTY concept\n # tags while still paying the per-node LLM concept cost (~10 calls/node with the full Part IV/V bundle).\n # Consider AUTO-QUIESCING: if the heuristic tagger returns empty for the first N nodes (no concept signal),\n # skip the LLM concept work for the run — keeps it on for ML tasks, free on toy/degenerate ones.\n concept_pivot: bool = True\n # PART IV Phase 2b — D3 graded novelty into the LIVE novelty gate (§21.4/§21.13). When on and the\n # task has a curated concept skeleton, the novelty gate first grades a proposal over the concept\n # graph: a level-4 \"same direction, DIFFERENT implementation\" or a level-5 \"re-opens a\n # wrongly-abandoned FAILED direction\" proposal is ALLOWED through unchanged — the flat LLM/semantic\n # dedup gate can't tell \"this DCL tweak\" from \"the whole DCL branch\" and would wrongly reject a\n # legitimate variant or never re-open a sound-but-killed direction (the node_63 archetype). Levels\n # 1/2/3 (identical / near-dup / prior-run) still fall through to the existing gate. Deterministic\n # (heuristic tagger, no LLM), audit event `novelty_graded`, replay-safe. ON by default in the product\n # Settings (ce4a379), EngineOptions off; no-ops for a task with no skeleton. See search/graded_novelty.py.\n graded_novelty: bool = True\n # PART IV Phase 2b — D7 capability-expansion forced-jump DIRECTIVE (§21.8/§21.13, issue #7). When on\n # and the concept-graph cadence detects action-space LOCK-IN (the search has stayed inside one D5\n # branch for a long consecutive streak) on an `explore` stance, the Researcher's novelty hint\n # ESCALATES from \"broaden\" to \"expand the ACTION SPACE — build the missing infra (external mining,\n # a new data pipeline, a different eval), do NOT swap another {locked} lever.\" The same\n # gate also stamps the proposal's authoritative operator as `expand`; that node competes normally\n # under SearchFitness and its separate operator yield is measured. It does not force a winner.\n # Reads the 2a concept-coverage snapshot, so it needs `concept_pivot` to record one. OPT-IN\n # (default off). See engine/proposal_cues.py + search/lock_in.py.\n capability_expansion: bool = False\n # PART IV cross-run Step 0 (§21.20.12/§21.20.13). Universal task-fingerprint tokenization: drop the\n # ASCII-only `[a-z0-9]` allowlist on goal keywords so a non-Latin goal (Russian, CJK, …) is NOT\n # silently dropped from its cross-run fingerprint (today such a goal keys only on kind/dir/metric and\n # never reaches SIMILAR-task priors/lessons/cases). Uses `[^\\W_]+`/`.casefold()` — same splitting as\n # before, any script. ON by default in the product Settings (ce4a379); the bare-library EngineOptions\n # default stays off, so a run pinned to it won't silently re-key a portfolio mid-flight. See engine/memory.py.\n fingerprint_universal: bool = True\n # PART IV cross-run Step 2 (§21.20). Fill graded-novelty's `prior_concepts` from the cross-run\n # ConceptCapsuleStore: at run end write a per-run concept capsule (the shipped `node_concepts` tags +\n # best outcome, keyed by task_fingerprint) to `memory_dir`; when a later SIMILAR run proposes an idea\n # whose concept was tried before, SURFACE the prior outcome as a `cross_run_prior` audit event (never\n # reject — D3 level 3). Audit-only, off the selection path; ON by default in the product Settings\n # (ce4a379), EngineOptions off. Needs a `memory_dir` to share capsules.\n # See engine/memory.py (ConceptCapsuleStore) + engine/novelty.py.\n # PREREQUISITES (this flag is NOT standalone): the WRITE needs per-run concept tags\n # (`node_concepts`, produced by `concept_pivot`/F1 tagging) — no tags => no capsule; the SURFACE path\n # runs inside the graded-novelty precheck, so it also needs `graded_novelty` on. With only this flag +\n # `memory_dir` it persists/surfaces nothing. Enable `concept_pivot` + `graded_novelty` alongside it.\n # A non-Latin (Russian/CJK) portfolio should ALSO set `fingerprint_universal`, else the capsule's goal\n # tokens are dropped and its fingerprint over-matches on kind/dir/metric alone (the novelty direction\n # gate narrows this, but goal-word similarity is still lost).\n cross_run_concepts: bool = True\n # PART V (B): run-base + node-DELTA concept authoring. When on, once the first evaluated node has\n # authored concepts the engine seeds RunState.run_base_concepts from them (a one-shot EV_RUN_CONCEPTS,\n # idempotent because it only fires while the base is empty), and the Researcher's proposal hint tells it\n # to author only `concepts_added`/`concepts_removed` vs the run base + its parent — minimal per-node\n # annotations that inherit down the DAG (a swap propagates). Off -> every node authors its full concept\n # set as before. Product-on; the bare EngineOptions mirror defaults off (test_options_divergence).\n concept_run_base: bool = True\n # PART IV cross-run Step 5 advisory (§21.20.5). Fold the bounded cross-run CONTEXT PACK — evidence-\n # grounded claims with BOTH support and counter-evidence (Step 4) + a portfolio-coverage line (Step 3) —\n # into the Researcher's proposal prompt, exactly like the E4 cross-run prior note. Advisory ONLY: it is\n # prompt-grounding: it can change the Researcher's later candidates, but does not directly rewrite\n # best-metric selection (§21.7). Reads `memory_dir` (lessons.jsonl + concept_capsules.jsonl); \"\" when\n # empty. ON by explicit experimental product choice; no frozen A/B validation is claimed. The\n # bare-library EngineOptions default stays off (engine/options.py, frozen in\n # test_options_divergence). See engine/proposal_cues.py.\n cross_run_advisory: bool = True\n # PART IV cross-run §21.20.13 (full CR of the lean fuzzy claim merge). Switch the claim read-model to the\n # SCOPE+POLARITY-safe STRUCTURED claim key (engine/claim_key.py): claims from different tasks never\n # merge, opposite polarity (\"X helps\" vs \"X never helps\") is surfaced as a CONTRADICTION instead of being\n # collapsed, paraphrase/inflection variants group by exact structured key (no transitive over-merge), and\n # operator governance is scope-precise (a decision in task A cannot reach a same-worded claim in task B).\n # Affects the `cross_run_advisory` context pack only; ON by default in the product Settings (ce4a379);\n # the bare-library EngineOptions default stays off (engine/options.py). See engine/claims.py.\n cross_run_structured_claims: bool = True\n # PART IV cross-run §22.4 (AGENTIC taxonomy steward). At finalize, when an LLM client is available, let\n # the concept steward (engine/concept_steward.py) review the freshly-updated portfolio concept graph and\n # PROPOSE a curation — merge duplicate slugs / split conflated ones / purge noise. Proposals are LOGGED to\n # `concept_curation_log.jsonl` for operator ratification (via the serve/CLI governance surface); the LLM\n # never mutates fold state. The calls run synchronously during finalize, so they add latency and may add\n # paid inference; a caught steward failure does not prevent terminal completion or trigger a retry.\n # ON by explicit experimental product choice when `memory_dir` + an LLM backend are present; the\n # bare-library EngineOptions default stays off (engine/options.py). See engine/lessons.py.\n cross_run_curation: bool = True\n # Deprecated compatibility flag. Steward output is untrusted and finalize is proposal-only regardless\n # of this value; retained so old snapshots still validate and logs can disclose that auto was requested.\n cross_run_curation_auto: bool = False\n # Role backend (ADR-7/14): \"toy\" (offline optimizer) | \"llm\" (live model). Validated in\n # `_check_trust_gate` alongside the other enum-ish strings — every consumer tests `== \"llm\"`\n # exactly (cli/__init__.py, adapters/tasks.py), so an untyped `--set`/file/env typo (or a mis-cased\n # \"LLM\") used to fall through to the OFFLINE toy roles and quietly produce a run that never called\n # the model. Left as `str`, not `Literal`, so old snapshots still validate through the same path.\n backend: str = \"toy\"\n # Developer backend (ADR-7): \"default\" (templated/LLM from the task) or an external\n # CLI coding agent: \"opencode\" | \"aider\" | \"goose\" | \"continue\". A CLOSED enum (\"default\" +\n # cli_agent.PRESETS keys), validated loudly in `_check_trust_gate` — an unknown value used to reach\n # adapters/tasks.py (`developer_backend not in PRESETS`) and silently wire the DEFAULT in-house\n # developer, the same silent downgrade the `backend` guard closes.\n developer_backend: str = \"default\"\n # C2 best-of-N: generate N candidate implementations per node and keep the best by an\n # execution-free reward (static validity + metric-print). 1 = off. In-house LLM developer only\n # (not external agents — ADR-7 cost rule). The top SWE-bench reliability lever for weak models.\n best_of_n: int = 1\n # D10 list-wise best-of-N selection: when the top static-scorers TIE, break the tie with a\n # comparative LLM selection over the candidates presented together (+~3 pts vs pointwise on\n # GAIA, arXiv:2506.12928). The static score stays the primary filter — the LLM is a weak\n # comparative prior, never the sole oracle. ON by default (no-op when best_of_n==1).\n best_of_n_listwise: bool = True\n # === Foresight / predict-before-execute ===============================================\n # FOREAGENT predict-before-execute (arXiv:2601.05930): use the LLM as an IMPLICIT WORLD MODEL to\n # predict — WITHOUT executing — which candidate / hypothesis will score best, primed with a\n # Verified Data Analysis Report (data profile / brief) + the accumulated experiment memory. Master\n # switch, ON by default; a no-op wherever there is nothing to rank (best_of_n==1 and\n # foresight_panel==1) or no LLM client. See `looplab/search/foresight.py`.\n foresight: bool = True\n # Hypothesis foresight breadth: generate K candidate ideas per proposal and keep the one PREDICTED\n # best pre-execution by the world model — it ranks the STRUCTURAL / text ideas the numeric k-NN\n # surrogate (researcher_panel) is blind to, primed with the data profile + memory (the synergy).\n # >1 enables it (LLM backend only; needs `foresight` on); 2 = on by default at modest cost, 1 = off.\n foresight_panel: int = 2\n # AGENTIC foresight: run the ranking (hypothesis-board prioritization + K-idea pick) as a TOOL-USING\n # loop that can pull actual experiment results / data facts before deciding, instead of a one-shot\n # prediction from a pre-baked report. ON by default (needs foresight + a client; falls back to the\n # one-shot predictor on any hiccup). A few extra LLM calls per proposal; set False for the cheaper\n # single-call ranker.\n foresight_agentic: bool = True\n # §1 foresight confidence gate: the minimum predicted confidence at which a predict-before-execute\n # pick is ACTED ON. Below it the ranker abstains — the K-idea panel falls back to the first\n # proposal, best-of-N falls through to the D10 tie-break — instead of committing a low-confidence\n # choice. 0.0 (default) = OFF (act on every pick, the historical behavior); raise toward ~0.5 to\n # make the world model defer when it isn't sure. Pairs with the foresight track record (§1) the\n # predictor is now primed with. Range 0.0-1.0.\n # BOUNDED like its confidence/fraction siblings (train_monitor_kill_confidence,\n # asha_live_quantile). Unbounded, a typo'd 5.0 validated silently into a gate no score can ever\n # clear, so every foresight pick abstained forever with nothing in the snapshot to say why.\n foresight_min_confidence: float = Field(default=0.0, ge=0.0, le=1.0)\n # PART IV Phase 2c — replace the world model's SELF-REPORTED confidence (measured Pearson≈0 with the\n # realized outcome, §21.12) with a CALIBRATED §12-verifier score. When on and a client is available,\n # after the K-idea ranker picks the predicted-best candidate the §12 verifier scores it (grounded +\n # repeated + criteria-decomposed: `foresight_criteria` — likely to improve the objective, and\n # sound/feasible) and THAT calibrated score becomes the confidence the `foresight_min_confidence` gate\n # and the telemetry use. Degrades to the self-reported confidence without a client or on any verifier\n # error (the telemetry records which source was used). ON by default in the product Settings (ce4a379),\n # EngineOptions off; a few extra LLM calls per acted-on proposal. See looplab/trust/verifier.py + search/foresight.py.\n # REVISIT (cost/quality, not yet resolved): a live classification run measured ~10 LLM calls & ~3.8 min/node\n # on deepseek-v4-flash with the full Part IV/V default bundle; the calibrated verifier is part of that per-node\n # tax. Kept ON deliberately — it raises decision quality (self-reported foresight confidence has ≈0 Pearson\n # with realized outcome), and lowering quality to save calls is the wrong trade. TODO: run a frozen quality/cost\n # A/B (verifier-on vs -off) to quantify the recall@objective gain per extra call before considering a default flip.\n foresight_verify: bool = True\n # Verifier sample count for `foresight_verify` (the §12 repeated-sampling expectation on a no-logprob\n # backend). 3 tames the single-shot variance §21.12 measured; 1 = single-shot (cheaper, noisier).\n # RESUME-critical: this value is persisted in config.snapshot.json, so a hard `le` schema bound would\n # make an old run started with a larger LOOPLAB_FORESIGHT_VERIFY_SAMPLES un-resumable (Settings(**snapshot)\n # would raise on `looplab resume` / finalize-recovery / serve replay). CLAMP into [1, MAX] via a validator\n # instead of rejecting — the runtime cap is preserved without breaking the snapshots/env-compat rule.\n foresight_verify_samples: int = 3\n\n @field_validator(\"foresight_verify_samples\")\n @classmethod\n def _clamp_foresight_verify_samples(cls, v: int) -> int:\n # Coerce a persisted/env value into [1, MAX] rather than reject (see field note above).\n try:\n v = int(v)\n except (TypeError, ValueError):\n return 3\n return max(1, min(v, MAX_FORESIGHT_VERIFY_SAMPLES))\n\n # T7 LLM response cache: serve an identical DETERMINISTIC (temperature 0) request from an\n # in-process content-addressed cache instead of re-hitting the model — cuts cost on\n # retry/panel/verify flows. NEVER caches sampling calls (temp>0: best-of-N / panel / novelty\n # re-propose must vary). Off by default (most role calls run at temperature>0 anyway).\n llm_cache: bool = False\n agent_cmd: str | None = None # override the agent's launcher (path / wrapper)\n # External-agent validation (ADR-7): wrap a CLI-agent Developer with a validator that\n # audits each output (no-op/syntax/crash/timeout), retries with feedback, and falls\n # back to the in-house LLM Developer. Off -> use the raw agent output unchecked.\n validate_agent: bool = True\n agent_max_retries: int = 1 # re-prompts of the agent on an invalid result\n # Patch-gated multi-file agent (ADR-7 Rule 3): run the CLI agent in a git worktree and\n # accept only edits whose paths match `agent_surface` (reject-not-strip out-of-surface\n # touches). Lets the agent create helper modules, not just solution.py. Degrades to\n # whole-file readback if git is unavailable.\n agent_patch_gate: bool = True\n agent_surface: list[str] = [\"*.py\"] # edit-surface allow-list (globs)\n # Trust policy for an agent-authored eval/metric adapter (RepoTask onboarding, Phase 3):\n # \"ratify_freeze\" waits for human approval, \"autonomous\" accepts the proposed spec without that\n # gate, and \"ratify_freeze_drift\" also enforces the configured independent drift cross-check.\n eval_trust_mode: str = \"ratify_freeze\"\n # RepoTask node seeding policy (run-wide fallback; a task/editable `seed_mode` overrides):\n # \"auto\" (default) copies git-tracked files when the editable is a git repo (so a tree bloated\n # with untracked artifacts isn't deep-copied per node), else a full copy; \"tracked\" forces\n # code-only; \"all\" forces a full recursive copy (legacy). Genesis authors per-task from the\n # user's words; this is the global default when unspecified.\n seed_mode: str = \"auto\"\n llm_model: str = \"qwen3:8b\"\n # === LLM / transport ==================================================================\n llm_base_url: str = \"http://localhost:11434/v1\" # Ollama OpenAI-compatible endpoint\n llm_temperature: float = 0.6\n # H3 per-role model presets (5090 recipe): optionally run the Researcher and Developer on\n # DIFFERENT models/endpoints (e.g. Developer=Qwen3-Coder-30B for code, a fast model for breadth).\n # Blank = use the shared llm_model/llm_base_url. Generalizes the role-backend swap to per-role.\n researcher_model: str | None = None\n developer_model: str | None = None\n researcher_base_url: str | None = None\n developer_base_url: str | None = None\n # Per-role sampling temperature (§4.1): the Researcher wants breadth (higher temp = more diverse\n # ideas), the Developer wants determinism (lower temp = fewer syntax slips), the Strategist a\n # steady hand. Each overrides the shared `llm_temperature` for that role only; None = use the\n # shared value (byte-identical to before). Flat + nullable, mirroring the per-role model fields.\n researcher_temperature: float | None = None\n developer_temperature: float | None = None\n strategist_temperature: float | None = None\n # The Strategist had per-role TEMPERATURE but no model/endpoint of its own, so in split mode it\n # always ran on the shared llm_model however the other roles were pointed. These close that gap\n # with the same names/shape as the pair above; blank = its profile, else the shared value.\n strategist_model: str | None = None\n strategist_base_url: str | None = None\n # === Connection profiles (multi-provider) =============================================\n # A \"profile\" is a NAMED connection: {model, base_url, temperature, api_key_env, provider}. Roles\n # point at one by name through `role_profiles`; `llm_profile` is the default for every role that\n # doesn't. All three are EMPTY by default and the resolver short-circuits to the shared\n # llm_model/llm_base_url/llm_api_key path when they are — an operator running one model never\n # needs to know the word \"profile\".\n #\n # A profile stores `api_key_env`, the NAME of an environment variable, never the key itself. That\n # is ADR-11's \"a secret is a reference, not a value\" turned from a convention into a data type:\n # the whole profile map is then safe to write into config.snapshot.json, hand back over HTTP and\n # render into LOOPLAB_* — there is nothing in it to mask. It is also what makes per-role\n # CREDENTIALS expressible at all: two roles on one provider can carry different keys, which a\n # key-per-endpoint map could not say.\n llm_profiles: dict[str, dict] = Field(default_factory=dict)\n llm_profile: str | None = None\n role_profiles: dict[str, str] = Field(default_factory=dict)\n # Unified self-driving agent: one LLM identity that plays Researcher + Developer (+ Strategist)\n # across pipeline stages, choosing its own model/toolset per stage. ON by default — the agent\n # drives the loop (incl. crash triage: repair/abandon/reject_idea) and, via\n # `agent_drives_actions`, picks the next macro action within a pure legal-action gate. Set both\n # to False for the legacy byte-identical split-role behavior. (No-op unless backend=\"llm\".)\n unified_agent: bool = True\n agent_drives_actions: bool = True\n # Layer 3 Card queue ownership. False preserves the existing policy/unified-pilot action path.\n # True is pinned in run_started because changing the selector on resume would mix two search\n # treatments in one run. When both this and agent_drives_actions are true, Card selection wins.\n card_driven_selection: bool = False\n # Layer 5 bounded speculative Card buffer. Zero is a hard OFF switch and preserves the\n # historical alternating build/eval spine; positive values are pinned by run_started so a\n # resume cannot silently mix search treatments after a snapshot/default edit.\n speculation_depth: int = Field(default=0, ge=0, le=64)\n # Local evidence receipt produced by ``looplab speculation-gate``. This is deliberately absent\n # from the curated Settings UI: positive-depth execution is a calibrated rollout decision, not a\n # casual tuning knob. The Engine validates the receipt against the current scorer,\n # implementation, GPU inventory and raw paired-run evidence before it enables speculation.\n speculation_gate_receipt: str | None = Field(default=None, max_length=4096)\n # Per-stage model/endpoint overrides for the unified agent. Recognized keys (see\n # tasks.build_unified_agent): `propose` (researcher), `implement`/`repair` (developer),\n # `strategy`, `pilot`. Unlisted keys are ignored. Empty = the per-role researcher_*/developer_*\n # models, then the shared llm_model. Explicit map wins. No-op unless `unified_agent` is true.\n agent_stage_models: dict = {}\n agent_stage_base_urls: dict = {}\n llm_parser: str = \"tool_call\"\n # Reasoning/thinking toggle sent IN the request (providers differ — see `llm.reasoning_body`):\n # Qwen3 on vLLM/SGLang -> chat_template_kwargs.enable_thinking (bool)\n # OpenAI/Ollama-v1/DeepSeek -> reasoning_effort (low|medium|high|none)\n # `llm_reasoning`: \"\" = send nothing (server default — current behavior); off = actively disable;\n # on = enable at default depth; low|medium|high = enable at that effort.\n # Defaults to \"high\" so the agent reasons before proposing/repairing/triaging (better code, fewer\n # crashes); set \"\" to send nothing (server default) or \"off\" to disable. The model's chain-of-thought\n # is CAPTURED either way (reasoning_content field or inline <think>). NOTE: to get a SEPARATE\n # reasoning_content field from SGLang/vLLM, the server also needs `--reasoning-parser qwen3`.\n llm_reasoning: str = \"high\"\n llm_reasoning_style: str = \"auto\" # auto | qwen | effort | none — how to shape the request param\n llm_reasoning_extra: dict = {} # raw fields merged into the body (escape hatch, e.g. Anthropic thinking)\n # H1: drive structured calls with the endpoint's constrained decoding (vLLM/SGLang guided_json +\n # response_format json_schema) so weak models can't emit invalid JSON. Off for Ollama (default).\n llm_guided_json: bool = False\n # Stream every LLM request (SSE) and reassemble it, so the request `timeout` is an INTER-TOKEN\n # idle timeout rather than a whole-request deadline: a long-but-alive generation is never cut off\n # (tokens keep resetting the timer), while a genuinely STALLED endpoint (no token for `timeout` s)\n # is detected and retried on a fresh connection — the fix for silent multi-minute hangs. Set False\n # to use one blocking read (old per-op timeout semantics) if an endpoint streams badly.\n llm_stream: bool = True\n # LLM IDLE timeout (seconds). Stream mode: the INTER-TOKEN stall limit — a steady generation is\n # never cut off, only a silent endpoint. Non-stream: bounds the whole read. Raise for endpoints\n # with a long prefill on huge prompts; lower to fail fast on a flaky shared endpoint.\n llm_timeout: float = Field(default=180.0, gt=0)\n # First-byte (response-headers) window for STREAM attempts, seconds: an SSE response sends its\n # headers on admission, so no-headers ≈ a black-holed request — fail over to a fresh connection\n # fast instead of waiting the full idle timeout. Clamped to llm_timeout. Non-stream attempts are\n # NOT bounded by this (their headers arrive only after the whole generation). The default mirrors\n # `DEFAULT_HEADER_TIMEOUT_S` in looplab/core/llm.py is the single source (imported above).\n llm_header_timeout: float = Field(default=DEFAULT_HEADER_TIMEOUT_S, gt=0)\n # Honour HTTP(S)_PROXY / NO_PROXY / SSL_CERT_FILE env vars for LLM calls. Default False: the\n # transport connects DIRECTLY (many internal/OLAP endpoints are reachable only by bypassing an\n # ambient proxy — a picked-up proxy yields \"connection refused\"). Set true when the endpoint is\n # reachable ONLY via a corporate proxy or needs a custom CA bundle from the environment.\n llm_trust_env: bool = False\n # H4/C2: compact the growing agentic tool-call history once it exceeds this many chars (auto_summary\n # LLM-summarizes the stale middle; else middle-truncate). Sized to the model's real context window so\n # files read earlier STAY in context instead of being compacted away and re-read: ~1,000,000 chars ≈\n # 250k tokens (≈4 chars/token), matching the configured deepseek-v4-flash (≥200k tokens confirmed).\n # The old 120k-char (~30k-token) default was ~8× too aggressive and drove a read-thrash loop. 0 = off.\n context_budget_chars: int = 1_000_000\n # M5: the Researcher's always-on experiments digest budget (chars). 0 = AUTO — scales with the\n # run size (60 chars/node, bounded [1200, 6000]) instead of one flat cap for an 8-node toy run\n # and a 200-node benchmark run alike. Set a positive value to pin it.\n digest_char_cap: int = Field(default=0, ge=0)\n # D11 compression model slot (open_deep_research's four-slot pattern): a dedicated CHEAP model\n # for agent-history summarization (C2 auto-summary), so compression doesn't pay the main\n # model's price. Blank = use each loop's own model (legacy). `compressor_base_url` blank =\n # the shared llm_base_url.\n compressor_model: str | None = None\n compressor_base_url: str | None = None\n # === Agentic tool-loop ================================================================\n # Agentic tool-loop limits — apply to EVERY tool-using agent (the LLM Researcher, the unified\n # agent's pilot + crash-triage, Deep-Research, the run-chat Boss, the genesis repo-scout, and the\n # cross-run report synthesizer). The loop lets the model call read-only tools across turns before\n # emitting its structured result. Both default to UNLIMITED so the agent takes as many turns / as\n # long as it needs — it is never cut off mid-reasoning; set a positive value here (or per-run, or\n # in the UI settings) to bound latency/cost. These used to be hardcoded per call site (e.g. the\n # boss at max_turns=3 / 45s), which silently dropped a slow reasoning model to a no-op reply.\n agent_max_turns: int = 0 # max tool turns before the emit is forced (0 = unlimited)\n agent_time_budget_s: float = 0.0 # wall-clock ceiling across the loop's turns (0 = no cap)\n # G · Soft convergence SAFETY NET (not a research cap). Two thresholds on the agent's tool-turn count:\n # at `agent_emit_after` the loop NUDGES the agent once (\"you've investigated enough — emit now\"); at\n # `agent_emit_force` it FORCES the emit from what was gathered. Set GENEROUSLY — a focused researcher\n # may legitimately read many files/experiments before it can propose a grounded idea; these only catch\n # a genuine runaway that keeps issuing DIFFERENT calls forever (which stuck-detection, keyed on\n # repeats, misses). NB: the old 499-read runaway (node 63) was 79% REDUNDANT re-reads caused by a\n # broken read_file — fixing read_file (pagination) is the real cure; this is just insurance. 0 = off.\n agent_emit_after: int = 300\n agent_emit_force: int = 500\n # B1 · No-progress / stuck detection — the safety net that makes \"unlimited turns\" safe. The\n # turn/time ceilings above are only BACKSTOPS; this is what actually stops a runaway loop, on the\n # cheapest signal: when the model repeats the SAME tool call (or ping-pongs between two, or keeps\n # hitting the SAME error) with no progress, the loop forces the final emit and finishes. ON by\n # default; reading DIFFERENT files or running ONE long command never trips it. (OpenHands-style.)\n agent_stuck_detection: bool = True\n agent_stuck_repeat: int = 4 # identical calls in a row that count as \"stuck\" (>=2)\n agent_stuck_alternate: int = 4 # ping-pong cycles between two calls that count as \"stuck\" (>=2)\n # C1 · Self-plan (TodoWrite-style): expose an `update_plan` tool so a long-running agent keeps its\n # OWN working TODO and re-surfaces it every `agent_plan_reinject_every` turns — keeps the goal in\n # view across a long loop so the agent \"writes its own context\" (Claude Code style). ON by default.\n agent_self_plan: bool = True\n agent_plan_reinject_every: int = 5\n # C2 · Auto-summary (ON by default): when the tool-loop history grows long, LLM-summarize the\n # stale middle instead of only middle-truncating it. The trigger is `context_budget_chars` if set,\n # else a built-in high-water mark (context_budget.DEFAULT_SUMMARY_CHARS ≈ 120k chars) so short\n # loops are untouched. Falls back to truncation on any summarizer error; one extra model call per\n # over-budget turn.\n agent_auto_summary: bool = True\n # C4 · Repo-developer plan decomposition. A big feature attempted in ONE developer session made a\n # non-converging reasoning model loop indefinitely — it kept writing + exploring without ever\n # emitting `done` (10k+ LLM calls / hours; agentic stuck-detection can't catch VARIED exploration,\n # only literal repeats). Fix: the developer first proposes an ordered plan of ATOMIC steps; a\n # multi-step plan is executed step-by-step, each in a FRESH bounded session with only that step's\n # scope + the files accumulated so far, syntax-validated per write. Every session stays\n # small-context and convergent. ON by default for repo tasks (a 1-step plan == the old single pass).\n developer_plan_decompose: bool = True\n developer_plan_min_steps: int = 2 # a proposed plan with >= this many steps runs step-by-step\n developer_plan_max_steps: int = 8 # cap on plan length (a runaway planner can't spawn 100 steps)\n # Hard per-session backstop for the repo developer — the ONE write-heavy agent that can run away\n # even with stuck-detection on (varied writes/reads never trip the repeat/ping-pong signal). Bounds\n # the plan phase, EACH step, and the single-session fallback. Unlike the global agent_* limits\n # (0 = unlimited, meant for read-only agents), the developer always gets a FINITE ceiling so a\n # model that never emits `done` fails cleanly with the code it wrote so far, instead of looping.\n developer_session_max_turns: int = 500\n developer_session_time_budget_s: float = 1200.0 # 20 min wall-clock per developer session\n # Phase-handoff summaries. Each LLM phase in a node build (Researcher·propose → Developer·stages →\n # plan → implement) ends with ONE extra LLM call that distills its transcript — the repo structure\n # it mapped, files/data confirmed, decisions made — into a brief injected into the NEXT phase (even\n # across the role boundary). The next phase TRUSTS that instead of re-reading the same files, which\n # cuts the biggest source of tool-call thrash (stages/plan/implement each re-exploring the repo).\n # One summary call per phase in exchange for many fewer read calls downstream. ON by default.\n phase_handoff_summary: bool = True\n # Observability (ADR-17): capture each LLM call's full prompt + completion into the active\n # span (spans.jsonl) so the UI can show exactly what the model read and wrote per node.\n # Diagnostics only — `replay.fold` never reads spans. Default on for local single-user; set\n # LOOPLAB_TRACE_LLM_IO=0 to suppress (e.g. to keep spans.jsonl small or avoid storing prompts).\n trace_llm_io: bool = True\n # Run-introspection tools (ON by default): make the LLM Researcher (and DeepResearcher) a\n # tool-using agent that can read its OWN experiments (list/read/find-analogous/themes/code) and\n # the task data (schema/profile/asset) mid-loop, instead of seeing only best+parent. Advisory —\n # never changes best-selection. Off = the legacy single-shot Researcher (richer digest still added).\n researcher_tools: bool = True\n # Cross-run introspection: give the agentic Researcher / pilot read-only tools to look at\n # SIBLING runs (same task_id, same run-root) — list them, read an experiment / its code, and\n # find analogous configs across runs — so a run can build on what neighbouring runs already\n # learned instead of rediscovering it. Advisory; never changes best-selection. Needs the run's\n # own dir wired through (no-op for unit-built roles). Off = the legacy single-run view only.\n cross_run_tools: bool = True\n # Read-only access to EVERY run on this machine ACROSS ALL TASKS (not just same-task siblings):\n # the Developer/Researcher get list_all_runs + read_run_code + read_run_experiment so they can read\n # the code + result of ANY past experiment anywhere and reuse an approach. Broader than\n # `cross_run_tools` (same-task only); the agent decides when a foreign run is relevant. Advisory;\n # never changes best-selection. Needs the run's own dir wired through (no-op for unit-built roles).\n all_runs_tools: bool = True\n # PART V §22 — read-only CROSS-RUN KNOWLEDGE tool for the reasoning roles (Researcher, Strategist,\n # deep-research, and a Developer-scoped variant). Adds `cross_run_prior_attempts` / `cross_run_claims`\n # / `cross_run_atlas` over the §21.20 read-models (concept overview + claim assessments + atlas), read\n # from `memory_dir` (lessons.jsonl + concept_capsules.jsonl). Read-only is a storage-authority\n # boundary, not a behavioral no-op: retrieved content can steer later proposals, and a tool-using\n # reasoning loop can consume additional model calls. The tools never mutate cross-run truth (facts\n # are engine-written, verdicts operator-ratified, §22.4) or directly select a winner. ON by explicit\n # experimental product choice, EngineOptions off; stores only exist once Part-IV features have run.\n cross_run_read_tools: bool = True\n # Agentic retrieval (ADR-16): if set, the LLM Researcher gets grep/kb_search/read\n # tools over this directory of markdown notes and chooses when to use them.\n knowledge_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"knowledge\"))\n # T4 real embeddings: model id for an OpenAI-compatible `/embeddings` endpoint used for\n # kb_search / case retrieval. Blank (default) = the dependency-free lexical `hash_embed` (today's\n # behavior). Set e.g. \"nomic-embed-text\" (Ollama) or \"text-embedding-3-small\" for SEMANTIC\n # retrieval; a misconfigured/offline endpoint degrades back to hash_embed at call time (never\n # crashes a run). `embed_base_url` blank = reuse `llm_base_url`; the shared `llm_api_key` is used.\n embed_model: str | None = None\n embed_base_url: str | None = None\n # Harmonic memory (Memora, ICML'26 — idea import). When ON, cross-run cases and knowledge notes are\n # indexed by a short ABSTRACTION + cue ANCHORS instead of their raw text, near-duplicate memories are\n # CONSOLIDATED into one entry under a matching abstraction, and `kb_search`/case retrieval EXPAND\n # through the top hits' anchors to surface related-but-not-similar memories. ON by default. Set\n # `memora=false` to restore the pre-Memora raw-text index. Never a source of truth — abstractions\n # live only in the derived, rebuildable retrieval index (never the event log or canonical cases.jsonl).\n memora: bool = True\n # Write abstractions with the wired chat model (richer than the deterministic lexical fallback). ON\n # by default; results are CACHED by content hash (see `memora_cache`) so a re-built index doesn't\n # re-call the model on unchanged notes/cases, and any endpoint failure degrades to lexical at call\n # time — so an offline box just gets lexical abstractions, never a crash. Set false to force lexical.\n memora_llm: bool = True\n # === Cross-run memory / Memora ========================================================\n memora_anchors: int = 6 # max cue anchors kept per memory\n # Cosine similarity at/above which a new case is CONSOLIDATED into an existing one (same evolving\n # topic) rather than stored as a separate entry. Only used when `memora` is on.\n # BOUNDED like its 0..1 sibling `novelty_semantic_threshold`: consumers compare\n # `cosine(...) >= consolidate_threshold` (engine/memory.py, tools/knowledge_tools.py) and cosine\n # cannot exceed 1.0, so an unbounded typo above 1.0 validated cleanly and silently disabled\n # consolidation forever — while a negative one consolidated everything. Fail loud instead.\n memora_consolidate_threshold: float = Field(default=0.86, ge=0.0, le=1.0)\n # Where the LLM-abstraction cache lives (JSON). Blank (default) derives it from `memory_dir`\n # (`<memory_dir>/memora_cache.json`) or else `knowledge_dir` (`<knowledge_dir>/.memora_cache.json`);\n # with neither set the cache is in-memory only (per process). No-op unless `memora_llm` is on.\n memora_cache: str | None = None\n # E3 literature-grounded ideation (network-OPTIONAL): give the agentic Researcher an arXiv search\n # tool to ground ideas in real techniques. Off by default (egress is unreliable on some boxes);\n # fails gracefully if the network is blocked.\n literature_search: bool = False\n # Deep-Research stage (network-OPTIONAL): give the DeepResearcher a general web search/fetch\n # tool (DuckDuckGo) on top of arXiv to read across results + the web before steering the next\n # batch. Off by default (egress is unreliable on some boxes); fails gracefully when blocked.\n web_search: bool = False\n # Cadence for the Deep-Research stage: run it automatically every N created nodes. Zero disables\n # only the cadence; manual `deep_research` and Strategist `request_research` triggers still fire.\n # Default 3: deep research analyzes the accumulating results and steers the next batch (its\n # directions become hints + open hypotheses); paired with `concurrent_research` it overlaps the\n # think with the GPU-bound eval, so the cadence costs LLM tokens but no wall-clock on the search.\n deep_research_every: int = 3\n # Overlap a DUE deep-research \"think\" with the GPU-bound eval instead of running it in its own\n # serial step (the agent is otherwise idle while a node trains). research() computes from a state\n # snapshot; the joined background task records only allowlisted, order-tolerant\n # research/hint/hypothesis events as soon as the memo finishes. ON by default: the LLM is typically\n # remote (no GPU contention with eval), so overlapping hides latency behind long training.\n concurrent_research: bool = True\n # Concurrent-research REPEAT — don't idle a multi-DAY eval. The overlapped \"think\" fires ONCE per\n # window by default; ON here, it RE-RUNS on an adaptive time cadence for the WHOLE eval window\n # (a two-day training must not leave the reasoning agents idle after one memo). Self-paced: it\n # only records a memo whose CONTENT is new (identical re-runs are skipped, so the log/board don't\n # bloat) and backs off geometrically as the analysis converges, capped so it always re-checks.\n # The appended records never rewrite the current metric champion, but their hints/open hypotheses\n # deliberately steer later proposals and replay reconstructs that advice. The library default is\n # one-shot (== today, byte-identical); this product default turns the repetition on.\n concurrent_research_repeat: bool = True\n # Base cadence (seconds) between repeated concurrent-research passes. Research is EXPENSIVE\n # (multi-turn LLM + web/arXiv), so this is a FLOOR, not a ceiling: the effective cadence is\n # max(this, ~5% of the per-experiment time budget) — a two-day eval is re-researched about hourly,\n # a short eval not at all (the first tick outlasts it). Only matters when the repeat is on.\n concurrent_research_interval_s: float = Field(default=1800.0, gt=0)\n # Per-eval-window backstop on repeated-research LLM calls (0 = cadence-only, no cap). The adaptive\n # cadence + convergence backoff are the primary budget control; this bounds a pathological\n # never-converging window. Past it the loop stops calling the LLM (the health monitor still runs).\n concurrent_research_max_calls: int = Field(default=40, ge=0)\n # Concurrent hypothesis-board CONSOLIDATION during the eval window. Repeated research keeps ADDING\n # near-duplicate directions to the open board (each memo registers up to 5 as open hypotheses), so a\n # long eval's board bloats before the between-nodes merge runs. This dedups/merges it on the SAME\n # background loop so the board stays clean for the next proposal. In Card-driven mode the same merge\n # changes ownership/readiness, so it is deferred to the joined main-task cadence instead. Needs the\n # reflect client + `track_hypotheses`.\n # Only active while `concurrent_research` runs the overlap loop. Library default off (== today).\n concurrent_consolidate: bool = True\n # Cadence for the agent-authored run report: regenerate the conclusion-first narrative every N\n # created nodes (0 = off; it still regenerates on a manual `report_refresh` from the UI). The\n # deterministic report always renders from the node set regardless of this knob.\n report_every: int = 3\n # Agent Skills (I18, ADR-9): dir of SKILL.md the Researcher can list/load as tools.\n skills_dir: str | None = None\n # Prompt store (I18, ADR-8): dir of editable, hot-reloaded role prompt .md files.\n prompt_dir: str | None = None\n # API key as a reference, never serialized as a value (ADR-11). Local servers\n # ignore it; default to a placeholder.\n llm_api_key: SecretStr | None = None\n # Runtime-only endpoint binding for the shared credential. A key is usable only when this\n # canonical URL exactly matches the transport target. It is deliberately absent from run/UI\n # snapshots: the owner-side secret store persists key + binding together, while an operator who\n # supplies LOOPLAB_LLM_API_KEY directly must also supply LOOPLAB_LLM_API_KEY_BASE_URL.\n llm_api_key_base_url: str | None = None\n # Set only by a trusted source-aware resolver after it has selected key+binding as one record.\n # Config files, task payloads and --set cannot populate PrivateAttr.\n _llm_credential_pair_trusted: bool = PrivateAttr(default=False)\n\n @model_validator(mode=\"before\")\n @classmethod\n def _reject_bool_for_numeric_fields(cls, data):\n \"\"\"A JSON boolean is a TYPE ERROR for a numeric setting, not the number 1.\n\n Pydantic runs in lax mode here (`model_config` sets no `strict`), and `isinstance(True, int)`\n is True — so `{\"max_nodes\": true}` from a UI/API type slip validated cleanly as a budget of\n ONE node, and `{\"eval_parallel\": true}` collapsed the width to one. The run then looked\n configured and simply did almost nothing, with no 422 and nothing in the snapshot to show why.\n Rejecting at the boundary covers every numeric field at once, including the many that carry no\n `Field(ge=...)` bound of their own to trip over.\n \"\"\"\n if not isinstance(data, dict):\n return data\n for key, val in data.items():\n if type(val) is bool and _is_numeric_annotation(\n getattr(cls.model_fields.get(key), \"annotation\", None)):\n raise ValueError(f\"{key} must be a number, not a boolean (got {val!r})\")\n return data\n\n @model_validator(mode=\"before\")\n @classmethod\n def _apply_profile(cls, data):\n \"\"\"Expand `profile` into a bundle of setting defaults BEFORE validation.\n\n pydantic-settings hands this validator the fully-merged input from ALL explicit sources\n (init kwargs + LOOPLAB_* env + .env), with fields the user left unset simply ABSENT (they\n fall to field defaults afterward). So a profile value is applied only when its key is not\n already present — i.e. any explicit file/CLI/env setting wins over the profile, and the\n profile wins over the bare field default. This makes `thorough` a true convenience preset,\n never an override of what the user asked for.\"\"\"\n if not isinstance(data, dict):\n return data\n prof = data.get(\"profile\", \"default\")\n # env may deliver the key as \"profile\" (env_prefix stripped) — normalize to a str\n prof = str(prof).strip().lower() if prof is not None else \"default\"\n overrides = PROFILES.get(prof)\n if overrides is None:\n raise ValueError(f\"unknown profile {prof!r}; known: {sorted(PROFILES)}\")\n for key, val in overrides.items():\n if key not in data: # explicit source (file/CLI/env) always wins\n data[key] = val\n return data\n\n @model_validator(mode=\"after\")\n def _check_trust_gate(self):\n if self.trust_gate not in (\"audit\", \"gate\", \"block\"):\n raise ValueError(\n f\"trust_gate must be audit|gate|block, got {self.trust_gate!r}\")\n if self.merge_mode not in (\"auto\", \"mean\", \"ensemble\"):\n raise ValueError(\n f\"merge_mode must be auto|mean|ensemble, got {self.merge_mode!r}\")\n # novelty_mode drives the dedup/novelty gate; an out-of-set value (e.g. a mis-cased\n # LOOPLAB_NOVELTY_MODE=LLM, or \"on\") used to fall through _apply_novelty_gate silently as a\n # NO-OP — turning the gate off with no diagnostic. Fail loudly at config time like trust_gate/\n # merge_mode do. (seed_mode / eval_trust_mode / strategist_backend share the pattern and are\n # validated in this same block, just below.)\n if self.novelty_mode not in (\"off\", \"algo\", \"llm\"):\n raise ValueError(\n f\"novelty_mode must be off|algo|llm, got {self.novelty_mode!r}\")\n # The remaining enum-ish string fields (arch-review §5 P3): a typo used to be accepted at\n # construction and only fail-safe/later-loud downstream (e.g. a mis-cased strategist_backend\n # silently ran the default). Fail loudly here like the fields above.\n if self.strategist_backend not in (\"off\", \"rule\", \"llm\", \"agent\"):\n raise ValueError(\n f\"strategist_backend must be off|rule|llm|agent, got {self.strategist_backend!r}\")\n if self.eval_trust_mode not in (\"ratify_freeze\", \"autonomous\", \"ratify_freeze_drift\"):\n raise ValueError(\n \"eval_trust_mode must be ratify_freeze|autonomous|ratify_freeze_drift, \"\n f\"got {self.eval_trust_mode!r}\")\n if self.seed_mode not in (\"auto\", \"tracked\", \"all\"):\n raise ValueError(f\"seed_mode must be auto|tracked|all, got {self.seed_mode!r}\")\n if self.backend not in (\"toy\", \"llm\"):\n raise ValueError(f\"backend must be toy|llm, got {self.backend!r}\")\n # developer_backend is a CLOSED enum: \"default\" (in-house developer) plus the external\n # coding-agent keys in agents/cli_agent.py::PRESETS. The CLI --developer-backend flag guards it\n # (_DEV_BACKENDS), but a file/env/`--set` value reached here unchecked and adapters/tasks.py then\n # silently wired the DEFAULT developer for anything not in PRESETS. Validate against the SAME\n # authoritative registry the runtime consumer uses (imported lazily to keep config import-light),\n # so a typo fails loud instead of downgrading — mirroring the `backend` guard above.\n from looplab.agents.cli_agent import PRESETS as _DEV_PRESETS\n _dev_backends = (\"default\", *_DEV_PRESETS)\n if self.developer_backend not in _dev_backends:\n raise ValueError(\n f\"developer_backend must be {'|'.join(_dev_backends)}, \"\n f\"got {self.developer_backend!r}\")\n # `parse_structured` resolves an unknown name to the DEFAULT fallback order, so a typo'd\n # llm_parser is indistinguishable from asking for the default. Validate against the parser\n # registry itself (imported here, not at module scope, to keep config import-light).\n from looplab.core.parse import _ORDER as _PARSER_ORDER\n if self.llm_parser not in _PARSER_ORDER:\n raise ValueError(f\"llm_parser must be one of {'|'.join(sorted(_PARSER_ORDER))}, \"\n f\"got {self.llm_parser!r}\")\n self._check_llm_profiles()\n return self\n\n def _check_llm_profiles(self) -> None:\n \"\"\"Validate the connection-profile maps LOUDLY, at construction.\n\n Assignment is not validated anywhere in this project, and these two maps are the kind of\n thing a typo makes into a silent no-op: an unknown role name would simply never be read, and\n a reference to a missing profile would fall back to the shared model while the operator\n believed a second provider was in play. Both are errors here instead.\n\n The `api_key_env` rules exist for a specific reason. It must LOOK like a secret to\n `runtime/sandbox.py::SECRET_ENV`, because that is the filter that strips the operator's keys\n out of the environment handed to generated candidate code — a key parked in a variable named\n `MY_HANDLE` would be invisible to it and ride straight into the sandbox. This pattern is\n deliberately the STRICTER of the two (the sandbox has to recognize whatever already exists;\n this names a variable the operator is about to create), and it is duplicated below rather than\n imported because layering forbids `core` from importing `runtime`.\n `tests/test_secret_env_pattern.py` pins the two together — including the invariant that every\n name accepted here is one the sandbox strips. And a literal key inside a profile is refused\n outright: the profile map is written verbatim into config.snapshot.json and served over HTTP\n precisely because it is supposed to contain no secrets.\"\"\"\n from looplab.core.llm import LLM_ROLE_KEYS\n if not isinstance(self.llm_profiles, dict):\n raise ValueError(\"llm_profiles must be a map of profile name -> profile\")\n for name, entry in self.llm_profiles.items():\n if not isinstance(entry, dict):\n raise ValueError(f\"llm_profiles[{name!r}] must be an object\")\n # BEFORE the unknown-field check: none of these is a known field, so checking them\n # afterwards could never fire and someone who pasted a live key was told it was a typo.\n for banned in (\"api_key\", \"key\", \"token\", \"secret\"):\n if banned in entry:\n raise ValueError(\n f\"llm_profiles[{name!r}] must not contain {banned!r}: a profile stores \"\n \"api_key_env (the NAME of an environment variable), never a key value — \"\n \"the whole map is written to config.snapshot.json and served over HTTP\")\n unknown = set(entry) - _PROFILE_FIELDS\n if unknown:\n raise ValueError(\n f\"llm_profiles[{name!r}] has unknown field(s) {sorted(unknown)}; \"\n f\"known: {sorted(_PROFILE_FIELDS)}\")\n env = entry.get(\"api_key_env\")\n if env is not None and not _SECRET_ENV_NAME.match(str(env)):\n raise ValueError(\n f\"llm_profiles[{name!r}].api_key_env={env!r} is not a usable variable name: it \"\n \"must be UPPER_SNAKE and contain KEY/SECRET/TOKEN/PASSWORD/PASSWD/CREDENTIAL, so \"\n \"the sandbox's secret-name filter strips it from generated code's environment\")\n temp = entry.get(\"temperature\")\n if temp is not None and (isinstance(temp, bool) or not isinstance(temp, (int, float))):\n raise ValueError(f\"llm_profiles[{name!r}].temperature must be a number\")\n if not isinstance(self.role_profiles, dict):\n raise ValueError(\"role_profiles must be a map of role name -> profile name\")\n for role, profile in self.role_profiles.items():\n if role not in LLM_ROLE_KEYS:\n raise ValueError(\n f\"role_profiles has unknown role {role!r}; known: {sorted(LLM_ROLE_KEYS)}\")\n if profile not in self.llm_profiles:\n raise ValueError(\n f\"role_profiles[{role!r}] names profile {profile!r}, which is not in llm_profiles\")\n if self.llm_profile is not None and self.llm_profile not in self.llm_profiles:\n raise ValueError(\n f\"llm_profile={self.llm_profile!r} is not in llm_profiles\")\n\n def masked_snapshot(self) -> dict:\n # Snapshots are a JSON contract (they are written verbatim to config.snapshot.json), not a\n # Python-object dump. In particular tuple-valued schema fields such as\n # ``inline_repair_reasons`` must become JSON arrays before callers hash the runtime envelope;\n # otherwise the strict speculation-scope canonicalizer sees a different/non-JSON preimage\n # from the one that ``json.dumps`` eventually persists.\n d = self.model_dump(mode=\"json\")\n if self.llm_api_key is not None:\n d[\"llm_api_key\"] = \"***\"\n else:\n d[\"llm_api_key\"] = None\n d.pop(\"llm_api_key_base_url\", None)\n # The snapshot's own FORMAT version — not a Settings field (it is never settable by env or\n # file, and it describes the document, not the run). `settings_from_snapshot` fails closed on\n # a version it does not understand; see CONFIG_SNAPSHOT_SCHEMA.\n d[CONFIG_SNAPSHOT_SCHEMA_KEY] = CONFIG_SNAPSHOT_SCHEMA\n return d\n\n\n# The config snapshot's document-format version, written by `masked_snapshot` and enforced by\n# `settings_from_snapshot`. Bump it when a snapshot written by this binary would be MISREAD by an\n# older one — i.e. when a new field changes paid, concurrency or selection behaviour and silently\n# defaulting it would resume the same event history under different semantics. `Settings` is\n# `extra=\"ignore\"`, so without this an older binary drops what it does not recognize and resumes\n# anyway, with no diagnostic; a version it does not know is now a loud refusal instead.\n# Version 1 is the first VERSIONED format. An absent key means a pre-versioning snapshot, which stays\n# readable through `LEGACY_CONFIG_SNAPSHOT_DEFAULTS` — that historical contract is unchanged.\nCONFIG_SNAPSHOT_SCHEMA = 1\nCONFIG_SNAPSHOT_SCHEMA_KEY = \"config_snapshot_schema\"\n\n\nclass ConfigSnapshotVersionError(ValueError):\n \"\"\"A config snapshot was written by a NEWER binary than the one reading it.\"\"\"\n\n\n# Pre-versioned snapshots are full Settings dumps, so absence is a reliable per-field version marker.\n# Keep their historical effective behavior when newer product defaults become active: re-entry must not\n# silently add paid calls, interventions, concurrency or a different selection policy to an old run.\nLEGACY_CONFIG_SNAPSHOT_DEFAULTS: dict[str, object] = {\n \"parallel_build\": 1,\n \"eval_parallel\": None,\n \"llm_parallel\": None,\n \"train_monitor\": False,\n \"train_monitor_interval_s\": 600.0,\n \"train_monitor_kill\": False,\n \"train_monitor_kill_confidence\": 0.8,\n \"asha_live\": False,\n \"asha_live_kill\": False,\n \"asha_live_quantile\": 0.5,\n \"asha_live_min_siblings\": 3,\n # Before this operator ceiling existed, the sandbox's 24-hour process limit was authoritative.\n \"max_eval_timeout\": 24 * 3600.0,\n \"watchdog_reflection\": False,\n \"card_driven_selection\": False,\n \"speculation_depth\": 0,\n \"speculation_gate_receipt\": None,\n \"concurrent_research_repeat\": False,\n \"concurrent_research_interval_s\": 1800.0,\n # Zero means unlimited; 40 was the first bounded default, and is latent while repeat is disabled.\n \"concurrent_research_max_calls\": 40,\n \"concurrent_consolidate\": False,\n # The map originally covered only the flags added in the days just before it was written\n # (the entries above are all 2026-07-19..07-21; the map itself landed 07-22). Everything below was\n # added 2026-06-24..07-17 — AFTER `config.snapshot.json` existed (it ships in the initial commit,\n # 2026-06-23) and BEFORE this map did — so a snapshot from that window carries none of these keys\n # and, without an entry here, resumed with today's paid product default ACTIVE. That is precisely\n # the \"silently add paid calls ... to an old run\" the paragraph above forbids: the resumed half of\n # the run would make predict-before-execute and cross-run LLM calls the original never made, and\n # admit candidates under a different novelty policy.\n # `setdefault` makes every one of these a no-op for a snapshot that DOES carry the key, so only\n # genuinely older runs are affected. Each value is the behaviour before the field existed: absent\n # feature = off, absent cadence = 0 (the same \"conservative library\" value the frozen\n # Settings-vs-EngineOptions divergence table records for these fields). Each was dated against the\n # tree before being listed — e.g. `report_every` (2026-06-25) is 0 because the run-report writer\n # itself did not exist before that commit, so an older run wrote no report to preserve.\n \"foresight\": False,\n \"foresight_agentic\": False,\n \"foresight_verify\": False,\n \"concept_pivot\": False,\n \"graded_novelty\": False,\n \"cross_run_concepts\": False,\n \"cross_run_advisory\": False,\n \"cross_run_structured_claims\": False,\n \"cross_run_curation\": False,\n \"cross_run_read_tools\": False,\n \"concept_run_base\": False,\n \"fingerprint_universal\": False,\n \"memora_llm\": False,\n \"comparative_lessons\": False,\n \"lessons_every\": 0,\n \"lessons_refresh_every\": 0,\n \"unified_agent\": False,\n \"deep_research_every\": 0,\n \"report_every\": 0,\n \"failure_reflection\": False,\n \"reflection_priors\": False,\n \"agent_drives_actions\": False,\n \"concurrent_research\": False,\n \"deep_repair\": False,\n # `merge_mode` meets all three conditions below, confirmed against the commits: the field was\n # introduced 2026-06-24 (bb421e0f), one day AFTER the cutoff, so a pre-cutoff snapshot carries no\n # such key; it arrived defaulting to \"mean\" and only later flipped to \"auto\" (a8b37944,\n # 2026-07-03), which `orchestrator.__init__` resolves to \"ensemble\" for a code-generating\n # developer — a PAID code-recombination merge producing a DIFFERENT merged solution; and the\n # before-the-field value is pointable and still frozen as the library default in\n # `EngineOptions` / `orchestrator._DEFAULTS`. Without this entry a pre-field snapshot resumed on\n # an LLM backend silently gained ensemble merges it never did.\n \"merge_mode\": \"mean\",\n # WHAT THIS MAP IS NOT. It is a hand-maintained list of FEATURE switches whose before-the-field\n # value is unambiguous, not a complete partition of `Settings`. Two classes stay out on purpose,\n # because for them a wrong entry is worse than a missing one — it would silently REMOVE behaviour\n # the original run really had:\n # * knobs that are latent while their parent feature is off — `foresight_panel`,\n # `foresight_verify_samples`, `memora_anchors`, `holdout_top_k`, `asha_eta`: the parent above\n # already pins them out of play, and their own before-the-field value is not recoverable;\n # * magnitudes and depths for behaviour that ALREADY existed — `debug_depth`,\n # `context_budget_chars`, `developer_plan_max_steps`, `agent_emit_after`: guessing 0/off there\n # disables working machinery rather than preserving history.\n # A new field belongs here only when it (a) postdates 2026-06-23, (b) defaults to adding paid\n # calls / interventions / concurrency / a different selection policy, and (c) has a historical\n # value you can point at a commit for. When (c) fails, leave it out and say so here.\n}\n\n\ndef migrate_config_snapshot(data: dict) -> dict:\n \"\"\"Return an effective copy of one legacy/current snapshot without rewriting evidence bytes.\"\"\"\n if not isinstance(data, dict):\n raise TypeError(\"config snapshot must be a JSON object\")\n migrated = dict(data)\n for key, value in LEGACY_CONFIG_SNAPSHOT_DEFAULTS.items():\n migrated.setdefault(key, value)\n return migrated\n\n\ndef config_snapshot_schema(data: dict) -> int:\n \"\"\"The snapshot's declared format version. 0 = pre-versioning (the key did not exist yet).\n\n A malformed value is treated as UNREADABLE rather than as 0: a snapshot whose own version marker\n is corrupt cannot be assumed to be the oldest format.\"\"\"\n raw = data.get(CONFIG_SNAPSHOT_SCHEMA_KEY, 0) if isinstance(data, dict) else 0\n if isinstance(raw, bool) or not isinstance(raw, int) or raw < 0:\n raise ConfigSnapshotVersionError(\n f\"config snapshot has a malformed {CONFIG_SNAPSHOT_SCHEMA_KEY}: {raw!r}\")\n return raw\n\n\ndef settings_from_snapshot(data: dict) -> Settings:\n \"\"\"Build re-entry Settings from a masked snapshot under its historical missing-field contract.\n\n Fails closed on a snapshot written by a NEWER binary. `Settings` is `extra=\"ignore\"`, so such a\n snapshot would otherwise load with every unrecognized control silently dropped — the run would\n resume on the same event history under different paid/concurrency/selection semantics, with\n nothing to show why. Refusing is the only honest answer: this binary cannot know what it is\n dropping. Older and pre-versioning snapshots keep loading exactly as before.\"\"\"\n found = config_snapshot_schema(data)\n if found > CONFIG_SNAPSHOT_SCHEMA:\n raise ConfigSnapshotVersionError(\n f\"this run's config.snapshot.json declares format v{found}, but this build understands \"\n f\"at most v{CONFIG_SNAPSHOT_SCHEMA}. It was written by a newer LoopLab; resuming here \"\n \"would silently drop settings this build does not know. Upgrade LoopLab to resume it.\")\n migrated = migrate_config_snapshot(data)\n migrated.pop(\"llm_api_key\", None)\n migrated.pop(\"llm_api_key_base_url\", None)\n migrated.pop(CONFIG_SNAPSHOT_SCHEMA_KEY, None) # a document marker, never a Settings field\n return Settings(**migrated)\n"}, "756ad1372f9acb9a66213bceee7a0f152256bf50": {"t": 1785768724.79507, "n": 119480, "h": "a232195bdacd8cad", "c": "\"\"\"Engine config: the schema (what settings exist). (I0, ADR-11)\n\nOne pydantic-settings class declaring every engine knob, its default, and its docs. The loader —\nhow a run file / CLI / env becomes a `Settings` — is `looplab.core.appconfig`, which also owns\nthe config-precedence order. A resolved, secret-masked snapshot is written next to each run for\nreproducibility. (No real secrets in P0, but the masking discipline is in place.)\n\"\"\"\nfrom __future__ import annotations\n\nimport re\nimport types\nimport typing\nfrom pathlib import Path\n\nfrom pydantic import Field, SecretStr, field_validator, model_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n# Single source of the LLM first-byte (response-headers) default — see core/llm.py.\nfrom looplab.core.llm import DEFAULT_HEADER_TIMEOUT_S\n\n# Default home for cross-run memory + the knowledge base (shared across every run). Both are ON by\n# default now (a user asked for it) so a fresh install accumulates + consults knowledge with no setup;\n# set the env var to \"\" to disable, or to a path to relocate. `~/.looplab/` keeps them out of any one\n# project + shared across projects.\n_LL_HOME = Path.home() / \".looplab\"\n\n# What one `llm_profiles` entry may contain. Deliberately closed: an unrecognized field is a typo the\n# operator would otherwise never see fail, and the map is a public artifact (config.snapshot.json,\n# HTTP, LOOPLAB_*), so it must never accrete free-form keys.\n_PROFILE_FIELDS = frozenset({\"model\", \"base_url\", \"temperature\", \"api_key_env\"})\n# A variable name that `runtime/sandbox.py::SECRET_ENV` will recognize as holding a secret and strip\n# from generated code's environment. Duplicated here, not imported: layering forbids `core` from\n# importing `runtime`. `tests/test_secret_env_pattern.py` holds the two in agreement.\n_SECRET_ENV_NAME = re.compile(\n r\"\\A(?=[A-Z][A-Z0-9_]{0,63}\\Z)(?=.*(KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL)).*\\Z\")\n\n\ndef _is_numeric_annotation(ann) -> bool:\n \"\"\"True when a field's annotation admits int/float but NOT bool.\n\n Used by `Settings._reject_bool_for_numeric_fields`: pydantic resolves annotations at class build,\n so `model_fields[...].annotation` is a real type object here even under\n `from __future__ import annotations`. Optionals are numeric when every non-None member is\n (`int | None` yes, `int | str` no), so a boolean can never slip in through a union arm.\"\"\"\n if ann is bool:\n return False # a bool field legitimately takes a bool\n if ann is int or ann is float:\n return True\n args = [a for a in typing.get_args(ann) if a is not type(None)]\n origin = typing.get_origin(ann)\n if args and origin in (typing.Union, types.UnionType):\n return all(_is_numeric_annotation(a) for a in args)\n return False\n\n# Autonomous roles that may be granted per-setting write access (see Settings.agent_control).\n# strategist = A7 meta-controller (run-wide); boss = run-chat operator-proxy (run-wide);\n# researcher = per-experiment proposer (per-node sizing, e.g. eval timeout for a heavy model).\nAGENT_ROLES = (\"strategist\", \"boss\", \"researcher\")\n\n# A foresight score is computed for every acted-on proposal. Keep this lower than the generic\n# verifier's emergency ceiling so a typo cannot multiply paid synchronous calls across a run.\nMAX_FORESIGHT_VERIFY_SAMPLES = 8\n\n\n# Layer-2 parallelism renamed two public settings without removing the durable legacy fields. Keep\n# the equivalence in one place: config-source precedence, governance migration, and live strategy\n# application must all agree which names describe the same authority/value.\nPARALLELISM_ALIASES: dict[str, str] = {\n \"max_parallel\": \"eval_parallel\",\n \"parallel_build\": \"llm_parallel\",\n}\n\n\ndef parallelism_aliases(setting: str) -> tuple[str, ...]:\n \"\"\"Return every spelling in ``setting``'s alias family, canonical name first.\"\"\"\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if setting in (legacy, canonical):\n return canonical, legacy\n return (setting,)\n\n\ndef canonicalize_parallelism_source(\n values: dict, *, promote_build_to_llm_parallel: bool = False,\n) -> dict:\n \"\"\"Promote legacy parallel keys *within one config-precedence source*.\n\n Applying this only after file/CLI/env were flattened would let a lower-priority canonical env\n value shadow a higher-priority legacy file value (or a file canonical value shadow a CLI legacy\n override), so callers with sub-layers normalize each layer independently.\n\n ``max_parallel``/``eval_parallel`` are true aliases (pure eval width, no extra semantics) and are\n always promoted. ``parallel_build``/``llm_parallel`` are NOT: a positive ``llm_parallel`` also flips\n on the finite shared LLM broker (``orchestrator._startup_llm_total``), while legacy ``parallel_build``\n means \"legacy build width, unbounded overlap, no shared total\". So promoting a legacy-only\n ``parallel_build`` layer (e.g. just ``LOOPLAB_PARALLEL_BUILD=1`` in env) would silently serialize\n every provider call — contradicting llm_broker.py's docstring, orchestrator's \"legacy-only preserves\n unbounded overlap\" note, the broker-neutral live-control path, and the configuration.md ``llm_parallel``\n row (\"falls back ... without enabling a shared total\"). Config/startup loads therefore keep broker\n opt-in tied to an EXPLICITLY-spelled canonical ``llm_parallel``; the orchestrator's own width fallback\n (``llm_parallel if not None else parallel_build``) still carries the legacy build width. Only the\n Strategist live-control path (``canonicalize_strategy_parallelism``) opts into\n ``promote_build_to_llm_parallel``, where ``parallel_build`` is a deliberate full alias of the\n canonical knob (see strategy.py ``_apply_strategy``).\n \"\"\"\n if not isinstance(values, dict):\n return values\n out = dict(values)\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if canonical == \"llm_parallel\" and not promote_build_to_llm_parallel:\n continue\n # None is the durable legacy mode for llm_parallel, not a missing value. A normal snapshot\n # contains llm_parallel:null plus parallel_build:1; promoting null to 1 would unexpectedly enable\n # the global one-call broker. Promote aliases only when the canonical key is absent, and pin\n # snapshot round-trip parity. Canonicalize inside each precedence layer, never across the\n # flattened result, so the alias migration cannot invert CLI > file > env precedence.\n if canonical not in out and out.get(legacy) is not None:\n out[canonical] = out[legacy]\n return out\n\n\ndef flatten_parallelism_layers(layers) -> dict:\n \"\"\"Flatten same-tier precedence layers (LOWEST priority first) and resolve the legacy pair.\n\n PRECEDENCE REPAIR for the non-promoted `parallel_build`/`llm_parallel` pair. Declining to promote\n in `canonicalize_parallelism_source` is right (a legacy-only layer must not flip on the shared\n broker), but it let a LOWER-priority canonical value survive a HIGHER-priority legacy override:\n file `llm_parallel: 2` + CLI `-s parallel_build=8` flattened to BOTH keys, and the orchestrator\n prefers `llm_parallel`, so the operator's explicit CLI width was silently discarded. When a higher\n layer names the legacy width over a lower layer's canonical one, mask with the LEGACY SENTINEL\n (`llm_parallel=None` is the durable \"legacy mode — build width from parallel_build, no shared\n total\"), so the higher layer wins with the semantics it asked for.\n\n The mask lives HERE, not in the per-layer canonicalizer, because only a flatten knows the layer\n ORDER. Stamping it per layer meant any layer naming `parallel_build` emitted `llm_parallel=None`\n even when NO layer in the tier set the canonical key — and since pydantic-settings ranks init\n kwargs above env, that sentinel then silently overrode a `LOOPLAB_LLM_PARALLEL` the operator had\n exported as a deliberate provider rate/cost ceiling. Precedence is per FIELD: a tier that never\n spelled `llm_parallel` has not set it, and it must fall through to env/.env/defaults — which is\n exactly what `build_settings`' docstring promises (\"the merged dict wins only where it actually\n sets a value\").\n \"\"\"\n merged: dict = {}\n legacy_at = canonical_at = -1\n for index, layer in enumerate(layers):\n if not isinstance(layer, dict):\n continue\n merged.update(layer)\n if layer.get(\"parallel_build\") is not None:\n legacy_at = index\n if \"llm_parallel\" in layer:\n canonical_at = index\n # Only when this tier DID set the canonical key, at a strictly lower layer than the legacy one.\n if canonical_at >= 0 and legacy_at > canonical_at:\n merged[\"llm_parallel\"] = None\n return merged\n\n# Settings whose semantics are committed by ``run_started`` and restored from the folded event log\n# on every re-entry. Changing one only in ``config.snapshot.json`` would mix incompatible evidence\n# (a different holdout split) or make the live producer disagree with replay's selection policy.\n# The HTTP config editor and the engine both consume this one contract; keep deliberately mutable\n# controls such as ``trust_gate`` out (that field has its own durable change event).\nRUN_START_PINNED_FIELDS = frozenset({\n \"card_driven_selection\",\n \"speculation_depth\",\n \"holdout_fraction\",\n \"holdout_select\",\n \"select_verifier\",\n \"select_verifier_samples\",\n \"verifier_ci_tie\",\n})\n\n\ndef run_start_pinned_settings(state) -> dict:\n \"\"\"Return the effective Settings names committed by a folded run-start record.\n\n Pre-pin legacy logs have no recorded holdout fraction, so their snapshot remains authoritative\n for both holdout knobs. Verifier fields, however, have always been re-pinned from the legacy-safe\n fold defaults whenever a valid ``run_started`` identity exists.\n \"\"\"\n if not getattr(state, \"run_id\", \"\"):\n return {}\n values = {\n # Layer 3 changes which queue owns macro-action selection. Once a run starts, replay and\n # resume must keep the same owner even if the snapshot or ambient default changes later.\n \"card_driven_selection\": bool(getattr(state, \"card_driven_selection\", False)),\n # Layer 5 overlaps Card production with evaluation only when this pinned treatment is\n # positive. Old logs omit it and therefore retain the exact serial build/eval spine.\n \"speculation_depth\": int(getattr(state, \"speculation_depth\", 0)),\n \"select_verifier\": bool(getattr(state, \"select_verifier_tiebreak\", False)),\n \"select_verifier_samples\": int(getattr(state, \"select_verifier_samples\", 3)),\n \"verifier_ci_tie\": bool(getattr(state, \"verifier_ci_tie\", False)),\n }\n holdout_fraction = getattr(state, \"holdout_fraction\", None)\n if holdout_fraction is not None:\n values.update({\n \"holdout_fraction\": holdout_fraction,\n \"holdout_select\": bool(getattr(state, \"holdout_select\", False)),\n })\n if not values.keys() <= RUN_START_PINNED_FIELDS:\n raise RuntimeError(\"folded run-start pinned settings contract drifted\")\n return values\n\n# --- Run profiles (config-first presets) -------------------------------------------------------\n# A `profile` is a NAMED BUNDLE of setting defaults — nothing more. It only fills fields the user\n# did NOT set explicitly (via file / --set / LOOPLAB_* env / init-kwargs), so any explicit knob\n# always wins (see `_apply_profile`). Most engine intelligence lives behind individual flags. The\n# PART IV/V cross-run + concept-graph machinery (concept_pivot, graded_novelty, cross_run_concepts /\n# _advisory / _structured_claims / _curation / _read_tools, fingerprint_universal, foresight_verify)\n# now ships ON in the product `Settings` default. That is an explicit experimental product choice,\n# not a claim that a frozen A/B or workload-wide cost/quality gate has cleared: the bundle can steer\n# prompts and candidate generation, change graded-novelty admission / pre-execution choices, and add\n# paid LLM work when its client and memory prerequisites are present. The bare-library `EngineOptions`\n# default stays lean (see tests/test_options_divergence.py — product side is the aggressive one) so a\n# toy `Engine(...)` in a test doesn't fire cross-run LLM work unasked. The trust/confirm/ablate QUALITY\n# bundle still ships OFF and is turned on by `thorough`. Every value here is reachable by hand — the\n# profile is a convenience, never a hidden mode.\n# keep this rationale behavioral and evidence-based; do not relabel read-only storage\n# access or proposal-only governance as a no-op for agent behavior, cost, or proposal admission.\n#\n# `thorough` deliberately touches only QUALITY/TRUST machinery, not spend: it does NOT raise\n# max_nodes / eval_parallel (those are cost knobs the user owns). It enables multi-seed confirmation\n# (demote seed-lucky leaders), the reward-hack / leakage / critic monitors AND flips them from\n# advisory to *gating* (`trust_gate=\"gate\"` — a\n# flagged win can no longer be selected as best), ablation-driven refinement, and the prompt cues\n# (complexity / budget / failure-reflection / watchdog-reflection). Agentic novelty and the experimental\n# Part IV/V bundle are already product defaults, not profile changes. Effect is large; marginal cost is a\n# few extra evals.\nPROFILES: dict[str, dict] = {\n \"default\": {}, # explicit no-op alias for \"ship-as-is\" (== omitting profile)\n \"fast\": {}, # today's lean defaults, named for symmetry with `thorough`\n \"thorough\": {\n \"confirm_top_k\": 3,\n \"confirm_seeds\": 3,\n # novelty_gate intentionally NOT enabled here: duplicate-detection is the agentic Researcher's\n # job (it reads past experiments + find_analogous_across_runs and DECIDES), not an algorithmic\n # param-distance / embedding auto-reject that mis-fired (it nudged a good fresh idea onto a\n # twice-dead lineage — live node 61). Search stays as tools; the LLM judges novelty.\n \"operator_bandit\": True, # P4: adaptive operator mix over folded yields (GreedyTree)\n \"reward_hack_detect\": True,\n \"code_leakage_detect\": True,\n \"critic_check\": True,\n \"trust_gate\": \"gate\", # detectors stop gating a flagged node from WINNING (not just audit)\n \"ablate_every\": 3,\n \"complexity_cue\": True,\n \"budget_aware\": True,\n \"failure_reflection\": True,\n \"watchdog_reflection\": True, # feed recent live-watchdog (train-monitor/ASHA) flags to proposals\n \"reflection_priors\": True, # no-op unless memory_dir is set (cross-run priors)\n },\n}\n\n\n# The shipped agent-governance matrix (who may change which setting at runtime). Defined as a module\n# constant so the Engine's direct-construction default matches this shipped default exactly (the\n# EngineOptions \"Engine() == shipped defaults\" invariant) instead of diverging to an all-locked map.\nDEFAULT_AGENT_CONTROL: dict[str, list[str]] = {\n \"timeout\": [\"researcher\", \"strategist\"],\n # Layer-2 canonical names are the presented/default governance surface. `_agent_may` falls back\n # to an old snapshot's legacy grant only when its canonical counterpart is absent, so removing\n # duplicate defaults preserves resume authority while an explicit canonical revocation still wins.\n \"eval_parallel\": [\"strategist\"],\n \"llm_parallel\": [\"strategist\"],\n # Strategy-only pseudo-setting: the closed per-lane allocation inside the canonical LLM total.\n # It is not a launch Settings field; operators pin it through `set_strategy` and the Strategist\n # may reallocate it only while this grant is present.\n \"llm_lane_limits\": [\"strategist\"],\n \"max_nodes\": [\"strategist\", \"boss\"],\n \"max_eval_seconds\": [\"strategist\", \"boss\"],\n \"policy\": [\"strategist\"],\n # The policy NAME and its PARAMS are gated independently in `_apply_strategy`; grant both by default\n # so the Strategist that may switch to (say) MCTS may also apply the `c`/`eta` it decided — else its\n # tuned params are silently dropped and the recorded `active_strategy` diverges from the live engine.\n \"policy_params\": [\"strategist\"],\n \"n_seeds\": [\"strategist\"],\n \"ablate_every\": [\"strategist\"],\n \"merge_mode\": [\"strategist\"],\n \"complexity_cue\": [\"strategist\"],\n \"ablate_code_blocks\": [\"strategist\"],\n \"prefer_sweep\": [\"strategist\"],\n \"novelty_stance\": [\"strategist\"],\n \"developer\": [\"strategist\"],\n \"fidelity\": [\"strategist\", \"researcher\"],\n}\n\n\ndef default_agent_control() -> dict[str, list[str]]:\n \"\"\"A FRESH deep-ish copy of the shipped governance matrix (each role list copied so a caller can\n mutate its own map without touching the module constant). The SINGLE constructor of the default,\n called by both `Settings.agent_control`'s default_factory AND the Engine's direct-construction\n default — so \"Engine() == shipped defaults\" can't drift if the copy depth ever needs to change\n (e.g. nested values requiring copy.deepcopy), instead of two verbatim copy expressions.\"\"\"\n return {k: list(v) for k, v in DEFAULT_AGENT_CONTROL.items()}\n\n\nclass Settings(BaseSettings):\n \"\"\"The engine settings schema (every knob a run accepts).\n\n Timeout family — six distinct knobs, each owned by a different subsystem:\n - `timeout`: per-eval wall-clock budget for ONE experiment's evaluation (engine/eval).\n - `max_eval_timeout`: hard ceiling for a governed per-node Researcher timeout override.\n - `llm_timeout`: LLM request idle timeout — inter-token stall limit in stream mode\n (core.llm OpenAICompatibleClient).\n - `llm_header_timeout`: LLM first-byte (response-headers) window for stream attempts (core.llm).\n - `agent_time_budget_s`: wall-clock ceiling across a tool-using agent loop's turns (agents).\n - `dep_install_timeout`: per-package budget for auto-installing a missing dep (env self-prep).\n\n Config-source precedence is owned by `looplab.core.appconfig` (the loader) — see its module\n docstring for the one canonical order.\n \"\"\"\n # Config precedence: see appconfig.py — the loader owns the full order. The `.env` is read\n # so `looplab run`/`looplab ui` pick up LOOPLAB_LLM_BASE_URL etc. without exporting them by hand\n # (keys MUST carry the LOOPLAB_ prefix, same as the env vars). utf-8-sig tolerates a BOM from\n # Windows editors. The test suite disables this (tests/conftest.py) so a dev's real .env in the\n # repo root can't leak into assertions.\n model_config = SettingsConfigDict(\n env_prefix=\"LOOPLAB_\",\n env_file=\".env\",\n env_file_encoding=\"utf-8-sig\",\n extra=\"ignore\",\n allow_inf_nan=False,\n )\n\n @classmethod\n def settings_customise_sources(\n cls, settings_cls, init_settings, env_settings, dotenv_settings,\n file_secret_settings):\n \"\"\"Preserve source precedence across canonical/legacy parallelism names.\n\n Pydantic ranks init > env > dotenv correctly, but it ranks field names rather than semantic\n alias families. Promoting a legacy value inside each source before the merge makes an old\n explicit snapshot/file value beat a lower-priority canonical env value. ``appconfig`` also\n promotes its file/typed/--set sub-layers before combining them into the init source.\n \"\"\"\n def _with_parallel_aliases(source):\n return lambda: canonicalize_parallelism_source(source())\n\n return (\n _with_parallel_aliases(init_settings),\n _with_parallel_aliases(env_settings),\n _with_parallel_aliases(dotenv_settings),\n _with_parallel_aliases(file_secret_settings),\n )\n\n # Run profile (config-first preset, see PROFILES above). \"default\"/\"fast\" = today's lean\n # defaults; \"thorough\" turns the built quality/trust machinery ON in one word. A profile only\n # fills fields the user did NOT set — every knob it touches stays individually overridable.\n profile: str = \"default\"\n\n # Bounds (review C/⚪): a non-positive node/seed budget or timeout is never valid. Upper bounds are\n # equally load-bearing — the UI /start path writes these straight into the engine's env, and an\n # unbounded `eval_parallel` (or legacy `max_parallel`; e.g. 100000 from a crafted preflight) would\n # fan out that many sandboxes → resource exhaustion. Reject at config time, not mid-run. Ceilings\n # are generous; tests top out near 50.\n # Pydantic's lax mode coerces a JSON boolean into any numeric field (`true` -> 1), so a UI/API type\n # error used to silently collapse a run budget to ONE node instead of returning 422. Rejected for\n # EVERY numeric field by `_reject_bool_for_numeric_fields` below, not just the ones bounded here.\n n_seeds: int = Field(default=3, ge=1, le=1024)\n max_nodes: int = Field(default=8, ge=1, le=1_000_000)\n # legacy evaluation-width input. `eval_parallel` below is canonical and wins when set;\n # keep this field for old env/config/snapshots and direct Engine call sites. A width > 1 admits\n # concurrent evals, whose resource reservations may be CPU-only or span one/more GPUs — there is no\n # one-node/one-GPU promise. `max_parallel=0` is launch-time AUTO (detected GPU count, at least 1).\n max_parallel: int = Field(default=1, ge=0, le=1024)\n # legacy build-width input. `llm_parallel` below is canonical and also controls the\n # shared provider-call total when positive. A width > 1 uses fresh pooled researcher/developer pairs;\n # it says nothing about the later eval's CPU/GPU reservation. Startup `0` derives build fan-out from\n # resolved eval width; a live Strategist/operator zero settles to 1. Without role_factory it clamps to 1.\n parallel_build: int = Field(default=1, ge=0, le=64)\n # Hypothesis-card Kanban re-architecture (docs/23, Layer 2). Two INDEPENDENT concurrency axes,\n # decoupled from each other so cost (LLM work) can be tuned separately from throughput (GPU evals).\n # These are the CANONICAL names going forward; `max_parallel`/`parallel_build` above are kept as thin\n # back-compat aliases (env vars / snapshots / ~100 Engine(...) call sites depend on the exact names,\n # so they cannot be renamed — CLAUDE.md). Resolution (orchestrator.__init__): a NEW name, when set,\n # WINS over its legacy alias; None => fall back to the legacy field => byte-identical to today.\n # `eval_parallel`: concurrent EVALS (the GPU/experiment consumer). None -> max_parallel; 0 = AUTO\n # (one experiment per detected GPU). This is the in-Run eval-concurrency ceiling; the resource\n # scheduler bin-packs declared footprints separately. Sibling local GPU-owning Runs serialize\n # through the conservative host-pool lease; external execution needs its own admission boundary.\n # `llm_parallel`: total concurrent LLM provider calls + BUILD fan-out. None -> legacy\n # parallel_build semantics without a shared total; 0 = startup AUTO (= resolved eval_parallel\n # for build fan-out, historical research overlap unbounded). A positive canonical value enables\n # the named-lane broker the Strategist can reallocate at cadence.\n eval_parallel: int | None = Field(default=None, ge=0, le=1024)\n llm_parallel: int | None = Field(default=None, ge=0, le=64)\n # Training-log monitor (I-series watchdog family): a per-eval background observer that tails the live\n # training log while a (often multi-hour) declared stage runs in a worker thread. ON in the product\n # surface (Settings). Its alert event is fold-ignored and cannot directly change lifecycle/champion;\n # with watchdog_reflection on, that alert is nevertheless advice in a later Researcher\n # prompt and can change future proposals. It only fires on the command-eval path with an LLM client;\n # bare-library EngineOptions stays OFF. See test_options_divergence.\n # `_interval_s` is the BASE tick cadence (the effective one adapts to the per-experiment budget).\n train_monitor: bool = True\n train_monitor_interval_s: float = Field(default=600.0, gt=0)\n # Phase 3 — INTERVENTION (opt-in, separate from observation): let the monitor tree-kill a training the\n # LLM judges 'broken' (diverged / silent CPU fallback / not learning) EARLY, instead of burning the\n # whole budget. Off by default — the observer only WATCHES unless this is on. A kill only fires on a\n # 'broken' verdict (a plateau is 'watch', never killed) with confidence >= the threshold; the node then\n # fails normally (reason='monitor_broken'), so replay reconstructs it from that one terminal event.\n train_monitor_kill: bool = False\n train_monitor_kill_confidence: float = Field(default=0.8, ge=0.0, le=1.0)\n # ASHA live-curve watchdog (sibling of the training monitor): reads the latest INTERMEDIATE value of\n # the objective metric off the live log (reusing the eval's OWN metric reader). Finished-endpoint rank\n # remains advisory. An opt-in KILL additionally requires an operator-declared metric.resource_key and\n # enough sibling observations at exactly that resource; absent comparable evidence cannot stop a run.\n # Records a fold-ignored `asha_rank` diagnostic + trace span; the library default is OFF (off == today).\n asha_live: bool = True\n # Opt-in INTERVENTION (separate from the advisory signal): let the watchdog tree-kill a node whose\n # intermediate metric stays below the SAME-RESOURCE bar past a short grace window. Off by default —\n # it only surfaces endpoint rank unless this is on. A kill fails normally (asha_underperforming).\n asha_live_kill: bool = False\n # The bar sits at `asha_live_quantile` along a WORST->BEST ordering of the finished siblings' finals:\n # 0.5 = the median (a node worse than the median peer flags); a SMALLER value is more conservative —\n # it lowers the bar toward the WORST finished peer, so only a node worse than nearly all peers flags\n # (0.0 = worse than the worst). `min_siblings` is the floor of finished peers required before it ranks\n # at all, so it never acts on too little evidence.\n asha_live_quantile: float = Field(default=0.5, ge=0.0, le=1.0)\n asha_live_min_siblings: int = Field(default=3, ge=1)\n timeout: float = Field(default=30.0, gt=0)\n # Hard ceiling for the Researcher's governed per-node `Idea.eval_timeout`. One hour preserves the\n # existing heavy/neural-net authoring example while staying well below the sandbox's defensive\n # 24-hour subprocess ceiling. This is operator-owned run config: agents may request less or more,\n # but the accepted action is always clamped here after the `agent_control.timeout` permission gate.\n max_eval_timeout: float = Field(default=3600.0, gt=0, le=24 * 3600.0)\n # Intra-node sweep: a sweep node runs a whole grid in one process, so it gets this multiple of\n # `timeout` as its wall-clock budget (solution.py path; RepoTasks use their per-profile timeout).\n sweep_timeout_mult: float = Field(default=8.0, ge=1)\n # Eval STALL watchdog CAP (seconds): a stage that is completely SILENT on stdout/stderr for this\n # long — while still alive and BELOW its wall-clock deadline — is tree-killed early with a STALLED\n # marker (a hung dataloader/deadlock dies in minutes instead of burning a multi-hour timeout). The\n # per-stage window is `min(this, the stage's own timeout)`, so a short scorer's deadline fires first.\n # `0` DISABLES the watchdog entirely (only the hard deadline applies) — for a legitimately quiet\n # non-Python stage (block-buffered stdout, a script that logs only to its own file). Threaded into\n # the eval and surfaced to the Developer so its code can emit periodic progress to stay alive.\n eval_stall_timeout_s: float = Field(default=1800.0, ge=0)\n # Sandbox tier (ADR-13): \"trusted_local\" (subprocess, no Docker) for the CLI;\n # \"untrusted\" (Docker --network none, shared-kernel runtime) for hosted/multi-tenant UI;\n # \"hostile\" (untrusted + a true-isolation OCI runtime, gVisor `runsc` by default / Kata) for\n # running untrusted code. See runtime/sandbox.py::make_sandbox for the tier selection.\n trust_mode: str = \"trusted_local\"\n # Image for the untrusted command-eval tier (RepoTask, Phase 4): the framework's deps\n # should be baked in (the container runs --network none, so a pip setup can't fetch).\n docker_image: str = \"python:3.12-slim\"\n # Resource caps for the untrusted/hostile Docker tier (passed to `docker run --memory/--cpus`).\n # The default 4g bound protects other tenants from an OOM, but a model-training MLE-bench eval\n # routinely needs more — raise it (e.g. \"16g\") for such runs. \"\" disables the cap (unbounded).\n # Ignored by the trusted_local (subprocess) tier.\n sandbox_memory: str = \"4g\"\n sandbox_cpus: str = \"\"\n # Best-effort host-OOM guard for the TRUSTED-LOCAL (subprocess) tier. When set (e.g. \"8g\"), each\n # eval child gets an RLIMIT_AS address-space cap so a runaway trainer hits MemoryError instead of\n # OOM-killing the whole host (and the engine). POSIX only; a no-op on Windows. Default \"\" = OFF:\n # RLIMIT_AS bounds VIRTUAL memory, and CUDA/torch reserve tens of GB of virtual without using it,\n # so a default cap would spuriously kill GPU evals — leave it off for those and use the Docker\n # tier's real --memory cgroup bound (sandbox_memory) instead. Ideal for CPU/numpy/sklearn runs.\n sandbox_memory_local: str = \"\"\n # Best-effort disk-fill guard for the TRUSTED-LOCAL tier (P1-5): an RLIMIT_FSIZE cap on the size of\n # any single file an eval child writes (e.g. \"2g\"), so a runaway that writes an unbounded file gets\n # SIGXFSZ instead of filling the host disk. POSIX only. Default \"\" = OFF (large model checkpoints\n # need big files); set it for tasks that write only small artifacts.\n sandbox_fsize_local: str = \"\"\n # Search policy (ADR-2): \"greedy\" | \"evolutionary\" | \"mcts\" | \"asha\" | \"bohb\".\n # `bohb` is a registered policy (`search/policy.py::_REGISTRY` aliases it to the ASHA factory,\n # which BOHB reuses as its racing half) and is advertised by `appconfig.render_template` and the\n # `bohb` template kind; this comment used to omit it and undersell the valid set. The value is\n # not validated at config time, but `make_policy` raises on an unknown name — loud-late rather\n # than silent, unlike `developer_backend` above.\n policy: str = \"greedy\"\n # Ablation-driven refinement (I7): every N improves, ablate the best to find the\n # highest-impact parameter and refine it. 0 = off. (GreedyTree only.)\n ablate_every: int = 0\n # A0a (MLE-STAR): when ablating, treat each generated pipeline *code block* (not just a\n # numeric param) as an ablation unit — neutralize a block, measure the metric delta, refine\n # the highest-impact block. Off => the classic numeric-param ablation. Config-first knob.\n ablate_code_blocks: bool = False\n # A0b: real merge/ensembling. \"mean\" = legacy mean-param merge; \"ensemble\" = the Developer\n # writes a code-recombination ensemble over the two parents' solutions (verified: agent-proposed\n # ensembling 37.9%->43.9%). \"auto\" (default) = ensemble whenever the Developer actually\n # generates code (LLM/agent backends; declared via `is_code_generating`), mean for the\n # templated/toy developers where a code ensemble is meaningless — the evidence says code\n # recombination is the strongest single merge (removing it costs ~9 pp), so it is the default\n # wherever it can work. Falls back to mean when the Developer can't ensemble.\n merge_mode: str = \"auto\"\n # A0d (AIRA): inject a dynamic complexity hint into the draft/improve prompt keyed on the\n # node's child count (few children -> keep minimal; many -> escalate to ensembling/HPO).\n complexity_cue: bool = False\n # I1 (CAAFE-style) LLM feature-engineering: instruct the proposer/developer to add\n # semantically-meaningful engineered features (ratios, interactions, aggregations) as code. The\n # existing cross-validation eval + best-selection is the CV GATE — a feature set that doesn't\n # improve CV is never selected (feature engineering is non-universal, so this gate is mandatory).\n # Tabular tasks (those exposing column data). Off by default.\n feature_engineering: bool = False\n # A5: surface the REMAINING eval-compute budget into the proposal prompt so the Researcher can\n # reason over how much compute is left (explore broad while flush, exploit/cheapen when low).\n # No-op unless a max_eval_seconds budget is set.\n budget_aware: bool = False\n # A4 (LATS-style): feed a summary of the most recent FAILED branches (operator + error reason)\n # back into the proposal prompt so the proposer reflects on and avoids repeating them. ON by\n # default: it is SELECTIVE by construction (injects only when recent failures exist — the\n # evidenced form of reflection; reflecting at every step shows no gains, arXiv:2506.12928).\n failure_reflection: bool = True\n # Feed the LIVE-WATCHDOG observations (train-monitor health verdicts + ASHA intermediate-rank flags)\n # of recent experiments back into the proposal prompt, so the Researcher avoids re-proposing a config\n # whose TRAINING was already observed to be weak — even when the watchdog kills are off (default) and\n # the node ran to completion (those diagnostics are fold-ignored, so failure_reflection never sees\n # them). ON by default and SELECTIVE like failure_reflection (injects only when a recent flag exists).\n watchdog_reflection: bool = True\n # C3 deep test-driven repair: hand the Developer the failure taxonomy + a structured \"reproduce\n # then fix\" directive on debug, not just the raw stderr tail. ON by default (product surface); the\n # conservative library default in EngineOptions stays off, per that module's contract.\n deep_repair: bool = True\n # === Inline crash repair ==============================================================\n # Hybrid in-node crash repair: when an LLM-generated node CRASHES at runtime (mechanical errors\n # — bad import, removed kwarg, typo), the agent triages it and may repair the code IN PLACE within\n # the same eval (cheap; does NOT consume max_nodes and does NOT add a node to the search tree).\n # Semantic failures still flow to a new idea/debug node via the policy. ON by default; set False\n # to restore the prior behavior (every crash waits for a budgeted inter-node debug node).\n inline_repair: bool = True\n # Max in-place repair attempts per node before it fails normally. Default 0 = UNLIMITED: the same\n # agent keeps fixing + re-evaluating IN THE SAME node until it produces a clean metric — no new\n # node just to retry. Runaway is prevented not by a count but by the anti-stuck guard\n # (`inline_repair_stuck_repeat`): when the SAME error signature repeats with no progress, or the\n # agent's crash-triage says \"abandon\", the node fails. Set a positive N to cap the retries instead.\n inline_repair_attempts: int = Field(default=0, ge=0)\n # Anti-stuck: abandon in-node repair when the SAME error recurs this many times in a row (no\n # progress). Keeps \"unlimited\" repair from looping forever on an unfixable error.\n inline_repair_stuck_repeat: int = Field(default=4, ge=2)\n # Which failure reasons (_failure_reason: crash|timeout|oom|setup|no_metric|drift) are eligible for\n # inline repair. Default: mechanical crashes, timeouts AND OOM-kills — a timeout/OOM means the code\n # was too slow / too memory-hungry for the budget (or the pod's cgroup limit), not that the idea is\n # wrong, so the agent repairs it by reducing compute/memory (fewer estimators/epochs/folds/seeds, a\n # lighter model, a smaller batch, subsampling) instead of letting the node die unrecovered. Env\n # override expects a JSON array.\n inline_repair_reasons: tuple[str, ...] = (\"crash\", \"timeout\", \"oom\")\n # Cost ceiling for in-node repair of a MULTI-STAGE eval: the max number of FULL pipeline re-runs\n # (i.e. re-trains from the first stage) the inline-repair loop may do before abandoning to the\n # inter-node debug operator. A late-stage failure (a broken `score`/eval script that didn't touch\n # `train`) REUSES the completed train checkpoint and re-runs only from the failed stage — that is\n # CHEAP and NOT counted here; only a repair that changes an EARLIER stage's code (train.py/loss.py/…)\n # forces a full re-train, which each costs the whole training time. Without this bound, a repair that\n # keeps changing training code could burn many full trains (the anti-stuck guard is error-signature\n # based, not cost based). 0 = unlimited (legacy behavior). Single-command evals ignore it.\n inline_repair_retrain_cap: int = Field(default=2, ge=0)\n # Environment self-prep: when a solution crashes purely because a KNOWN library isn't installed\n # (ModuleNotFoundError), the engine pip-installs it into the eval interpreter and re-runs — so a\n # torch/XGBoost/CatBoost idea (e.g. a GRU model) actually runs on a fresh box instead of being\n # thrown away as `idea_rejected`. Trusted_local tier only (the untrusted/hostile Docker tiers run\n # --network none). A typo'd or local helper module is NOT installed (that's a code bug to repair).\n auto_install_deps: bool = True\n # Per-package wall-clock budget for an auto-install (seconds). Generous: large wheels like torch\n # can take minutes to download/build.\n dep_install_timeout: float = Field(default=900.0, gt=0)\n # --- Agent governance: who may change a setting at runtime (per-setting allow-list of roles) ---\n # Roles: \"strategist\" (A7 meta-controller; run-wide), \"boss\" (run-chat operator-proxy; run-wide),\n # \"researcher\" (per-experiment proposer; PER-NODE — e.g. set a longer eval timeout for a neural-net\n # idea). A setting ABSENT from this map is LOCKED — no agent may change it, only a human via the\n # snapshot/UI. The sole conditional exception is Strategy-only `card_scoring`: Card mode grants\n # it implicitly without changing the default flag-off snapshot, while an explicit [] revokes it.\n # The gate is ENFORCED at every AGENT seam via `_agent_may` — the strategist's whole\n # control surface (policy/operators/novelty_stance/prefer_sweep/developer/fidelity/timeout/\n # eval_parallel/llm_parallel) is checked in `_apply_strategy`, so removing a role from a knob locks it,\n # not just greys out the UI pill. The boss/max_* grants document boss-relevant knobs, but a\n # `budget_extend` is a HUMAN control intent (the boss action-builder can only emit `add_nodes`), so\n # it is applied as-is — the human always wins via the UI/snapshot. Defaults are conservative: resource/\n # search-shape knobs the agents already reason about; provider infra (llm_model/base_url/api_key,\n # docker_image) stays locked. The UI shows S/B/R pills per setting. `timeout` granted to the\n # researcher IS the \"auto\" per-node mode — the researcher sizes each experiment's wall-clock\n # (Idea.eval_timeout); the config `timeout` is the fallback for nodes it doesn't size. Env override\n # expects a JSON object. (`fidelity`/`novelty_stance`/`prefer_sweep`/`developer` are governance keys\n # for the strategist's per-node dials — not 1:1 Settings fields, but gated the same way.) The default\n # lives in the module-level DEFAULT_AGENT_CONTROL so the Engine's direct-construction default matches.\n agent_control: dict[str, list[str]] = Field(default_factory=default_agent_control)\n # C1 (Agentless localization): for repo tasks, rank the source files most relevant to the most\n # recent failure (traceback + identifiers) and surface them in the proposal/repair prompt so the\n # Developer edits the right place. Off by default; repo tasks only.\n localize_faults: bool = False\n # A2 surrogate-guided proposal (BO-lite): fit a k-NN surrogate over (params->metric) and propose\n # by acquisition instead of random/hill-climb. Numeric-bounds tasks only; bootstraps via the\n # base Researcher. Off by default. `surrogate_explore` = UCB-style exploration weight.\n surrogate_proposer: bool = False\n surrogate_explore: float = 0.1\n # E2 researcher panel: generate K candidate ideas per proposal and keep the one ranked best by a\n # cheap empirical surrogate over the (params->metric) history (NOT an LLM-judge). 1 = off.\n researcher_panel: int = 1\n # A1 ASHA / successive-halving: reduction factor (keep top 1/eta per rung) and rung budget.\n asha_eta: int = Field(default=3, ge=2)\n # Rung-0 width for ASHA/BOHB (the wide base of cheap drafts). 0 = use n_seeds (default,\n # preserving prior behavior); set >0 to seed a wider/narrower base independent of n_seeds.\n asha_rung_nodes: int = Field(default=0, ge=0)\n # A6 proxy/predictive scoring: cheaply rank a candidate's potential from early-stage signals\n # and skip a full eval for the doomed bottom fraction. 0.0 = off (no candidate skipped).\n proxy_scoring: bool = False\n proxy_kill_fraction: float = Field(default=0.0, ge=0.0, le=0.9)\n # === Novelty / dedup ==================================================================\n # E1 novelty/dedup gate: reject near-duplicate proposals (normalized param-space distance below\n # `novelty_epsilon`) by deterministically nudging them off the duplicate — stops the search\n # wasting evals re-trying the same idea. Audit event `novelty_rejected`. Off by default.\n # Novelty/dedup MODE selector: how a fresh proposal is checked against already-tried experiments.\n # \"off\" — no explicit gate; the agentic Researcher's own read-the-history judgment is trusted.\n # \"algo\" — the deterministic gate: numeric param-distance (`novelty_epsilon`) + optional embedding\n # similarity (`novelty_semantic`). Cheap, no LLM call, but can't explain itself.\n # \"llm\" — an LLM adjudicates (reads the real experiments via tools) whether the idea is a\n # near-duplicate and, if so, asks the Researcher once more for something different. One\n # extra LLM call per proposal — highest quality, follows the \"let the LLM decide\" line.\n novelty_mode: str = \"llm\"\n # Legacy sub-toggles, honored by the \"algo\" mode (and back-compat: novelty_gate=True forces \"algo\"):\n novelty_gate: bool = False\n novelty_epsilon: float = 0.05\n # T5 semantic novelty (active whenever the deterministic gate runs — novelty_mode=algo,\n # novelty_gate=true which is the legacy alias for algo, OR the Strategist novelty stance is\n # \"explore\"; a no-op only under novelty_mode=llm/off with a non-explore stance): reject a proposal\n # whose idea TEXT embeds within\n # `novelty_semantic_threshold` cosine of an existing node's. OFF BY DEFAULT: novelty is the agentic\n # Researcher's call, made by READING the actual prior experiments (read_experiment /\n # find_analogous_across_runs / list_experiments), NOT an embedding-similarity auto-reject that can't\n # explain itself and mis-fires on paraphrases. Semantic/param search stays available as TOOLS in the\n # Researcher's context; the LLM decides whether an idea is a true duplicate.\n novelty_semantic: bool = False\n novelty_semantic_threshold: float = Field(default=0.92, ge=0.5, le=1.0)\n # T10: debug-lineage depth bound for every policy — how many error-feedback repairs a failing\n # lineage gets before it is abandoned. The old default (1) abandoned lineages after ONE repair;\n # multi-turn/deeper debugging is a verified lever (AIRA2 ReAct debug +5.5 percentile points).\n debug_depth: int = Field(default=2, ge=1)\n # P4 operator bandit (GreedyTree): replace the fixed merge/ablate cadences with a\n # deterministic UCB over per-operator yield (Δmetric per eval-second, folded from the run).\n # Off by default (the cadences are well-tested and the bandit has no direct published\n # ablation); `thorough` turns it on.\n operator_bandit: bool = False\n # P1 hypothesis ledger: ask the Researcher to state the one-line hypothesis each experiment tests,\n # register deep-research directions as hypotheses, and track them to a verdict on the board. ON by\n # default. The board is shown to the Researcher and can be foresight-ordered, so it can\n # steer later proposals even though it never directly re-ranks already-evaluated nodes. Set False\n # to drop the prompt nudge + registration.\n track_hypotheses: bool = True\n # E4/M2/M3 reflection-memory -> priors (gradient-free cross-run meta-learning): at run end distill\n # the winner into `<memory_dir>/meta_notes.jsonl` AND structured lessons (incl. NEGATIVE results:\n # tested/abandoned hypotheses + failure themes) into `lessons.jsonl` with a task fingerprint; at run\n # start, inject exact-task notes + fingerprint-matched lessons from SIMILAR tasks into the proposal\n # prompt. ON by default — but a NO-OP until `memory_dir` is set (that's where cross-run memory lives).\n reflection_priors: bool = True\n # M6 comparative lessons (MARS \"comparative reflective memory\", doc 13 §7 item 2): distill\n # credit-assigned lessons from PAIRS of solutions — which SPECIFIC change made a child beat\n # (or regress from) its parent, and what fixed a failure — on top of the one-shot run-end\n # reflection. At most one extra LLM call per distillation (all pairs batched); offline a\n # deterministic param-diff credit stands in. Gated on reflection_priors + memory_dir like the\n # rest of cross-run memory — and since memory_dir has a default (~/.looplab/memory), this is\n # ACTIVE out of the box; clear memory_dir to disable cross-run memory wholesale.\n comparative_lessons: bool = True\n # M6 mid-run cadences (doc 13 §7 item 5 — the AgentRxiv live-share pattern): every N created\n # nodes, WRITE comparative lessons to the shared cross-run store during the run (lessons_every\n # — one batched LLM call per firing) and RE-READ the store so lessons distilled by CONCURRENT\n # runs reach this run's proposals (lessons_refresh_every; file re-read, no LLM call, skipped\n # when the store is unchanged). ON by default (every 4 nodes) and, like comparative_lessons\n # above, live out of the box because memory_dir defaults on; 0 = off — run-end write /\n # run-start read only, the pre-M6 behavior.\n lessons_every: int = Field(default=4, ge=0)\n lessons_refresh_every: int = Field(default=4, ge=0)\n # B3 output redaction: mask credentials (known key shapes + high-entropy tokens) in the\n # stdout/stderr tail before it is persisted to the event log / spans / UI — a leaked secret in a\n # print()/traceback must not land in the durable log. Off by default; recommend on for untrusted.\n redact_output: bool = False\n # B5 reward-hacking detector: a host-side monitor that flags suspicious wins (grader/answer-key\n # access, runtime writes to frozen files, suspiciously-perfect metrics) as a `reward_hack_suspected`\n # audit event in the Trust panel. Off by default. Whether a flag CHANGES selection is governed by\n # `trust_gate` below (default: audit-only, never changes selection).\n reward_hack_detect: bool = False\n # Trust enforcement (T2): what a reward-hack / data-leakage flag actually DOES to selection.\n # \"audit\" (default) = surface it in the Trust panel, never change selection (today's behavior);\n # \"gate\" = a flagged node can no longer be selected as BEST **and isn't bred/confirmed from**\n # (breed_excluded — see replay._apply_trust_gate / models.breedable_nodes), but stays\n # FEASIBLE so it still counts for diversity/audit — closes the \"a hacked/leaky node can\n # win OR seed its lineage\" hole;\n # \"block\" = additionally mark it fully INFEASIBLE (removed from feasible_nodes entirely).\n # Deliberately gates only high-precision CHEATING/LEAKAGE signals. Broad `critic:*` findings stay\n # advisory; `critic:hardcoded_metric` is the one narrow high-precision critic exception\n # and gates because it requires a literal metric with no computed assignment anywhere.\n trust_gate: str = \"audit\"\n # I3 data-centric: static code-leakage scan of each evaluated solution (fit-before-split,\n # fit-on-test) surfaced into the Trust panel alongside reward-hack flags. Off by default.\n code_leakage_detect: bool = False\n # 4.4 sandbox instrumentation (needs reward_hack_detect): after each eval, flag a RUNTIME write\n # to a protected/frozen file (an answer key or grader tampered mid-run) — behavioral evidence a\n # static code scan misses. ON by default; only fires when the reward-hack detector is on.\n workdir_audit: bool = True\n # D8 decoupled research Verifier: check every Deep-Research memo's CLAIMS against their cited\n # evidence (node ids / urls) before recording — synthesis is the documented weak link (Kosmos\n # 57.9% accurate). Deterministic layer always; an LLM rubric pass when the DeepResearcher has a\n # client. The verdict cannot change this run's node/champion, but finalize uses aligned supported\n # verdicts as the evidence gate for persisted D8 cross-run claims; unavailable/misaligned stays\n # unverified. ON by default (no-op with no memo/claims).\n research_verify: bool = True\n # C4 independent critic: an execution-free critic of each solution (stub / hardcoded-metric /\n # params-ignored; on host-graded tasks the metric checks become a submission-output check)\n # surfaced in the Trust panel. Broad findings are advisory; `critic:hardcoded_metric` can gate under\n # trust_gate=gate/block. Off by default.\n critic_check: bool = False\n # A7 Strategist (NEW, user-requested): optional LLM/rule meta-controller that picks the search\n # policy/allocator + operator mix + fidelity (+ Developer backend) per situation. Config-first:\n # every choice the Strategist makes is also a direct knob. Defaults to \"agent\" so the agent adapts\n # the search policy/operators/fidelity per situation; set \"off\" for static config-driven search.\n # \"agent\" = a tool-using Strategist that READS the run/data/sibling-runs/KB/memory (B1-guarded)\n # before deciding, instead of the single-shot \"llm\" call over aggregate stats.\n strategist_backend: str = \"agent\" # \"off\" | \"rule\" | \"llm\" | \"agent\" — default AGENTIC: the\n # Strategist READS the run/data/sibling-runs/KB/memory with tools before deciding, not a single-shot\n # call over aggregate stats. \"llm\" = the old non-agentic single-shot; \"rule\"/\"off\" = no LLM.\n strategist_every: int = Field(default=3, ge=1) # consult cadence (created nodes)\n # PART V (F1): concept CLASSIFIER re-tag + consolidation cadence, DECOUPLED from strategist_every. The\n # LLM concept map is heavier and slower-moving than a strategy consult, so it refreshes on its own\n # (sparser) interval. Researcher-authored idea.concepts still fold immediately at node_created — this\n # only paces the classifier-evidence + consolidation refresh (and the concept-coverage pivot snapshot).\n concept_retag_every: int = Field(default=30, ge=1)\n # === Confirmation & holdout ===========================================================\n # Multi-seed confirmation (I12, ADR-15): confirm the top-k under N seeds before\n # finishing. 0 disables (default). Only meaningful when eval has variance.\n confirm_top_k: int = 0\n confirm_seeds: int = 0\n # D1 seed-holdout discipline: the FIRST seed the confirm phase uses. Search evals run with the\n # implicit seed 0 (LOOPLAB_EVAL_SEED unset -> \"0\"), so a base of 1 keeps every confirm split\n # DISJOINT from anything the search optimized against — the confirm metric is then a\n # generalization signal, not a re-measurement (AIRA: selecting on a signal the search saw\n # overfits by 9-13 pp). Set 0 to restore the legacy overlapping seeds 0..N-1.\n confirm_seed_base: int = Field(default=1, ge=0)\n # D1 holdout-gated promotion (B6, Arbor-style): for host-graded tasks, reserve this fraction of\n # the held-out labels as a FINAL holdout partition the search never sees — every search/confirm\n # eval is scored on the remaining rows only, and at finish the val-top-k are re-scored on the\n # holdout partition (free: the predictions already exist). 0.0 = off (legacy full-label scoring).\n holdout_fraction: float = Field(default=0.25, ge=0.0, le=0.9)\n # Champion selection prefers the HOLDOUT metric among the nodes that have one (the val-top-k).\n # Recorded in run_started so replay applies the same rule; old logs fold to False (legacy pick).\n # The per-node val-holdout `generalization_gap` folds into the Trust panel either way.\n holdout_select: bool = True\n # How many val-leaders get a holdout evaluation at finish (host-graded tasks).\n holdout_top_k: int = Field(default=3, ge=1)\n # R1-c / Part IV — calibrated §12-verifier tie-break in best-selection. When a fresh node's robust\n # metric EXACTLY TIES an already-eligible node, the engine runs the §12 verifier (grounded on each\n # node's realized result, `selection_criteria`) and records a soundness score per node. The tie-break\n # then applies across the WHOLE selection path: the mean pick breaks a robust_metric tie by the score,\n # and — when `holdout_select` is on — the holdout-slot ranking prefers the sounder node so it isn't\n # denied a holdout eval, and the final holdout override breaks a holdout_metric tie by it too. Strictly\n # a TIE-BREAK — it can NEVER move a node ahead of a strictly-better metric (§21.7 advisory-never-\n # overrides). Best-effort: verification is LAZY (only fires on an actual tie) and bounded per cadence,\n # so a large/late tie stays on deterministic metric+id order unless one atomic treatment scores every\n # member. Recorded in run_started so\n # replay applies the same rule; old logs fold to False (legacy pick). Opt-in (default off); needs a\n # reachable LLM client. Enable only after validating calibration offline with `verifier.calibrate`.\n # See trust/verifier.py + core/fitness.py.\n select_verifier: bool = False\n # R1-d (§21.19): widen the verifier tie-break from EXACT-metric to a conservative STATISTICAL tie.\n # The band is capped by both the metric leader's SE and the pooled SE-of-difference, so challenger\n # variance cannot widen it and a significant difference is never a tie (§21.7). Off -> exact only.\n verifier_ci_tie: bool = False\n # Verifier sample count for `select_verifier` (the §12 repeated-sampling expectation). 3 tames the\n # single-shot variance; 1 = single-shot (cheaper, noisier).\n select_verifier_samples: int = Field(default=3, ge=1, le=32)\n # Budget (I13): hard wall-clock ceiling; the run aborts cleanly when exceeded.\n max_seconds: float | None = None\n # Eval-compute budget (#2): hard ceiling on cumulative time spent INSIDE evals (training\n # runs), separate from wall-clock. Survives resume (summed from the event log). The real\n # guard against a silent multi-hour sweep when an eval is a minutes-hours training run.\n max_eval_seconds: float | None = None\n # Cross-run memory (I19, ADR-10): if set, the best result of each run is stored as\n # a case here, and the cases become retrievable knowledge for future runs.\n memory_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"memory\"))\n # HITL (I21, ADR-11): pause for human approval of the final best before finishing.\n require_approval: bool = False\n # Diversity archive (I22): niche bucket width in parameter space.\n archive_resolution: float = 1.0\n # Coverage read-model (narrowing signal): at the strategist cadence, compute a breadth summary\n # (themes / param-niches / theme entropy / dominant-theme fraction) from the folded run, record\n # it as a `coverage_snapshot` audit event, and surface it into the Strategist's decision context.\n # Deterministic, cheap, and purely additive context — no search-behavior change on its own. On by\n # default (like the rest of the always-on situational context). See search/coverage.py.\n coverage_context: bool = True\n # PART IV Phase 2a live steering (§21.11/§21.13). When on, the `concept_retag_every` cadence (NOT\n # `strategist_every` — the producer gates on `_should_consult_concepts`, which uses the seed boundary\n # + `concept_retag_every`, default 30) records a\n # concept-graph coverage + uncovered-region snapshot (deterministic heuristic tagger over the\n # task-type skeleton) and, on an `explore` stance, the Researcher's novelty hint names the exact\n # uncovered regions (\"0 coverage in {negatives/external-mining, distillation} — go there\") instead\n # of the vague \"broaden\". ON by default in the product Settings (ce4a379), EngineOptions off: it\n # only enriches an already-mode-gated explore hint, never forces exploration, and no-ops for a task\n # with no curated concept skeleton. The snapshot is auditable, but its prompt cue can change future\n # candidate generation; it does not directly rewrite best-metric selection. See search/concept_graph.py.\n # DESIGN NOTE (2026-07-17 critique): now ON unconditionally, but a toy / non-ML task yields EMPTY concept\n # tags while still paying the per-node LLM concept cost (~10 calls/node with the full Part IV/V bundle).\n # Consider AUTO-QUIESCING: if the heuristic tagger returns empty for the first N nodes (no concept signal),\n # skip the LLM concept work for the run — keeps it on for ML tasks, free on toy/degenerate ones.\n concept_pivot: bool = True\n # PART IV Phase 2b — D3 graded novelty into the LIVE novelty gate (§21.4/§21.13). When on and the\n # task has a curated concept skeleton, the novelty gate first grades a proposal over the concept\n # graph: a level-4 \"same direction, DIFFERENT implementation\" or a level-5 \"re-opens a\n # wrongly-abandoned FAILED direction\" proposal is ALLOWED through unchanged — the flat LLM/semantic\n # dedup gate can't tell \"this DCL tweak\" from \"the whole DCL branch\" and would wrongly reject a\n # legitimate variant or never re-open a sound-but-killed direction (the node_63 archetype). Levels\n # 1/2/3 (identical / near-dup / prior-run) still fall through to the existing gate. Deterministic\n # (heuristic tagger, no LLM), audit event `novelty_graded`, replay-safe. ON by default in the product\n # Settings (ce4a379), EngineOptions off; no-ops for a task with no skeleton. See search/graded_novelty.py.\n graded_novelty: bool = True\n # PART IV Phase 2b — D7 capability-expansion forced-jump DIRECTIVE (§21.8/§21.13, issue #7). When on\n # and the concept-graph cadence detects action-space LOCK-IN (the search has stayed inside one D5\n # branch for a long consecutive streak) on an `explore` stance, the Researcher's novelty hint\n # ESCALATES from \"broaden\" to \"expand the ACTION SPACE — build the missing infra (external mining,\n # a new data pipeline, a different eval), do NOT swap another {locked} lever.\" The same\n # gate also stamps the proposal's authoritative operator as `expand`; that node competes normally\n # under SearchFitness and its separate operator yield is measured. It does not force a winner.\n # Reads the 2a concept-coverage snapshot, so it needs `concept_pivot` to record one. OPT-IN\n # (default off). See engine/proposal_cues.py + search/lock_in.py.\n capability_expansion: bool = False\n # PART IV cross-run Step 0 (§21.20.12/§21.20.13). Universal task-fingerprint tokenization: drop the\n # ASCII-only `[a-z0-9]` allowlist on goal keywords so a non-Latin goal (Russian, CJK, …) is NOT\n # silently dropped from its cross-run fingerprint (today such a goal keys only on kind/dir/metric and\n # never reaches SIMILAR-task priors/lessons/cases). Uses `[^\\W_]+`/`.casefold()` — same splitting as\n # before, any script. ON by default in the product Settings (ce4a379); the bare-library EngineOptions\n # default stays off, so a run pinned to it won't silently re-key a portfolio mid-flight. See engine/memory.py.\n fingerprint_universal: bool = True\n # PART IV cross-run Step 2 (§21.20). Fill graded-novelty's `prior_concepts` from the cross-run\n # ConceptCapsuleStore: at run end write a per-run concept capsule (the shipped `node_concepts` tags +\n # best outcome, keyed by task_fingerprint) to `memory_dir`; when a later SIMILAR run proposes an idea\n # whose concept was tried before, SURFACE the prior outcome as a `cross_run_prior` audit event (never\n # reject — D3 level 3). Audit-only, off the selection path; ON by default in the product Settings\n # (ce4a379), EngineOptions off. Needs a `memory_dir` to share capsules.\n # See engine/memory.py (ConceptCapsuleStore) + engine/novelty.py.\n # PREREQUISITES (this flag is NOT standalone): the WRITE needs per-run concept tags\n # (`node_concepts`, produced by `concept_pivot`/F1 tagging) — no tags => no capsule; the SURFACE path\n # runs inside the graded-novelty precheck, so it also needs `graded_novelty` on. With only this flag +\n # `memory_dir` it persists/surfaces nothing. Enable `concept_pivot` + `graded_novelty` alongside it.\n # A non-Latin (Russian/CJK) portfolio should ALSO set `fingerprint_universal`, else the capsule's goal\n # tokens are dropped and its fingerprint over-matches on kind/dir/metric alone (the novelty direction\n # gate narrows this, but goal-word similarity is still lost).\n cross_run_concepts: bool = True\n # PART V (B): run-base + node-DELTA concept authoring. When on, once the first evaluated node has\n # authored concepts the engine seeds RunState.run_base_concepts from them (a one-shot EV_RUN_CONCEPTS,\n # idempotent because it only fires while the base is empty), and the Researcher's proposal hint tells it\n # to author only `concepts_added`/`concepts_removed` vs the run base + its parent — minimal per-node\n # annotations that inherit down the DAG (a swap propagates). Off -> every node authors its full concept\n # set as before. Product-on; the bare EngineOptions mirror defaults off (test_options_divergence).\n concept_run_base: bool = True\n # PART IV cross-run Step 5 advisory (§21.20.5). Fold the bounded cross-run CONTEXT PACK — evidence-\n # grounded claims with BOTH support and counter-evidence (Step 4) + a portfolio-coverage line (Step 3) —\n # into the Researcher's proposal prompt, exactly like the E4 cross-run prior note. Advisory ONLY: it is\n # prompt-grounding: it can change the Researcher's later candidates, but does not directly rewrite\n # best-metric selection (§21.7). Reads `memory_dir` (lessons.jsonl + concept_capsules.jsonl); \"\" when\n # empty. ON by explicit experimental product choice; no frozen A/B validation is claimed. The\n # bare-library EngineOptions default stays off (engine/options.py, frozen in\n # test_options_divergence). See engine/proposal_cues.py.\n cross_run_advisory: bool = True\n # PART IV cross-run §21.20.13 (full CR of the lean fuzzy claim merge). Switch the claim read-model to the\n # SCOPE+POLARITY-safe STRUCTURED claim key (engine/claim_key.py): claims from different tasks never\n # merge, opposite polarity (\"X helps\" vs \"X never helps\") is surfaced as a CONTRADICTION instead of being\n # collapsed, paraphrase/inflection variants group by exact structured key (no transitive over-merge), and\n # operator governance is scope-precise (a decision in task A cannot reach a same-worded claim in task B).\n # Affects the `cross_run_advisory` context pack only; ON by default in the product Settings (ce4a379);\n # the bare-library EngineOptions default stays off (engine/options.py). See engine/claims.py.\n cross_run_structured_claims: bool = True\n # PART IV cross-run §22.4 (AGENTIC taxonomy steward). At finalize, when an LLM client is available, let\n # the concept steward (engine/concept_steward.py) review the freshly-updated portfolio concept graph and\n # PROPOSE a curation — merge duplicate slugs / split conflated ones / purge noise. Proposals are LOGGED to\n # `concept_curation_log.jsonl` for operator ratification (via the serve/CLI governance surface); the LLM\n # never mutates fold state. The calls run synchronously during finalize, so they add latency and may add\n # paid inference; a caught steward failure does not prevent terminal completion or trigger a retry.\n # ON by explicit experimental product choice when `memory_dir` + an LLM backend are present; the\n # bare-library EngineOptions default stays off (engine/options.py). See engine/lessons.py.\n cross_run_curation: bool = True\n # Deprecated compatibility flag. Steward output is untrusted and finalize is proposal-only regardless\n # of this value; retained so old snapshots still validate and logs can disclose that auto was requested.\n cross_run_curation_auto: bool = False\n # Role backend (ADR-7/14): \"toy\" (offline optimizer) | \"llm\" (live model). Validated in\n # `_check_trust_gate` alongside the other enum-ish strings — every consumer tests `== \"llm\"`\n # exactly (cli/__init__.py, adapters/tasks.py), so an untyped `--set`/file/env typo (or a mis-cased\n # \"LLM\") used to fall through to the OFFLINE toy roles and quietly produce a run that never called\n # the model. Left as `str`, not `Literal`, so old snapshots still validate through the same path.\n backend: str = \"toy\"\n # Developer backend (ADR-7): \"default\" (templated/LLM from the task) or an external\n # CLI coding agent: \"opencode\" | \"aider\" | \"goose\" | \"continue\". A CLOSED enum (\"default\" +\n # cli_agent.PRESETS keys), validated loudly in `_check_trust_gate` — an unknown value used to reach\n # adapters/tasks.py (`developer_backend not in PRESETS`) and silently wire the DEFAULT in-house\n # developer, the same silent downgrade the `backend` guard closes.\n developer_backend: str = \"default\"\n # C2 best-of-N: generate N candidate implementations per node and keep the best by an\n # execution-free reward (static validity + metric-print). 1 = off. In-house LLM developer only\n # (not external agents — ADR-7 cost rule). The top SWE-bench reliability lever for weak models.\n best_of_n: int = 1\n # D10 list-wise best-of-N selection: when the top static-scorers TIE, break the tie with a\n # comparative LLM selection over the candidates presented together (+~3 pts vs pointwise on\n # GAIA, arXiv:2506.12928). The static score stays the primary filter — the LLM is a weak\n # comparative prior, never the sole oracle. ON by default (no-op when best_of_n==1).\n best_of_n_listwise: bool = True\n # === Foresight / predict-before-execute ===============================================\n # FOREAGENT predict-before-execute (arXiv:2601.05930): use the LLM as an IMPLICIT WORLD MODEL to\n # predict — WITHOUT executing — which candidate / hypothesis will score best, primed with a\n # Verified Data Analysis Report (data profile / brief) + the accumulated experiment memory. Master\n # switch, ON by default; a no-op wherever there is nothing to rank (best_of_n==1 and\n # foresight_panel==1) or no LLM client. See `looplab/search/foresight.py`.\n foresight: bool = True\n # Hypothesis foresight breadth: generate K candidate ideas per proposal and keep the one PREDICTED\n # best pre-execution by the world model — it ranks the STRUCTURAL / text ideas the numeric k-NN\n # surrogate (researcher_panel) is blind to, primed with the data profile + memory (the synergy).\n # >1 enables it (LLM backend only; needs `foresight` on); 2 = on by default at modest cost, 1 = off.\n foresight_panel: int = 2\n # AGENTIC foresight: run the ranking (hypothesis-board prioritization + K-idea pick) as a TOOL-USING\n # loop that can pull actual experiment results / data facts before deciding, instead of a one-shot\n # prediction from a pre-baked report. ON by default (needs foresight + a client; falls back to the\n # one-shot predictor on any hiccup). A few extra LLM calls per proposal; set False for the cheaper\n # single-call ranker.\n foresight_agentic: bool = True\n # §1 foresight confidence gate: the minimum predicted confidence at which a predict-before-execute\n # pick is ACTED ON. Below it the ranker abstains — the K-idea panel falls back to the first\n # proposal, best-of-N falls through to the D10 tie-break — instead of committing a low-confidence\n # choice. 0.0 (default) = OFF (act on every pick, the historical behavior); raise toward ~0.5 to\n # make the world model defer when it isn't sure. Pairs with the foresight track record (§1) the\n # predictor is now primed with. Range 0.0-1.0.\n # BOUNDED like its confidence/fraction siblings (train_monitor_kill_confidence,\n # asha_live_quantile). Unbounded, a typo'd 5.0 validated silently into a gate no score can ever\n # clear, so every foresight pick abstained forever with nothing in the snapshot to say why.\n foresight_min_confidence: float = Field(default=0.0, ge=0.0, le=1.0)\n # PART IV Phase 2c — replace the world model's SELF-REPORTED confidence (measured Pearson≈0 with the\n # realized outcome, §21.12) with a CALIBRATED §12-verifier score. When on and a client is available,\n # after the K-idea ranker picks the predicted-best candidate the §12 verifier scores it (grounded +\n # repeated + criteria-decomposed: `foresight_criteria` — likely to improve the objective, and\n # sound/feasible) and THAT calibrated score becomes the confidence the `foresight_min_confidence` gate\n # and the telemetry use. Degrades to the self-reported confidence without a client or on any verifier\n # error (the telemetry records which source was used). ON by default in the product Settings (ce4a379),\n # EngineOptions off; a few extra LLM calls per acted-on proposal. See looplab/trust/verifier.py + search/foresight.py.\n # REVISIT (cost/quality, not yet resolved): a live classification run measured ~10 LLM calls & ~3.8 min/node\n # on deepseek-v4-flash with the full Part IV/V default bundle; the calibrated verifier is part of that per-node\n # tax. Kept ON deliberately — it raises decision quality (self-reported foresight confidence has ≈0 Pearson\n # with realized outcome), and lowering quality to save calls is the wrong trade. TODO: run a frozen quality/cost\n # A/B (verifier-on vs -off) to quantify the recall@objective gain per extra call before considering a default flip.\n foresight_verify: bool = True\n # Verifier sample count for `foresight_verify` (the §12 repeated-sampling expectation on a no-logprob\n # backend). 3 tames the single-shot variance §21.12 measured; 1 = single-shot (cheaper, noisier).\n # RESUME-critical: this value is persisted in config.snapshot.json, so a hard `le` schema bound would\n # make an old run started with a larger LOOPLAB_FORESIGHT_VERIFY_SAMPLES un-resumable (Settings(**snapshot)\n # would raise on `looplab resume` / finalize-recovery / serve replay). CLAMP into [1, MAX] via a validator\n # instead of rejecting — the runtime cap is preserved without breaking the snapshots/env-compat rule.\n foresight_verify_samples: int = 3\n\n @field_validator(\"foresight_verify_samples\")\n @classmethod\n def _clamp_foresight_verify_samples(cls, v: int) -> int:\n # Coerce a persisted/env value into [1, MAX] rather than reject (see field note above).\n try:\n v = int(v)\n except (TypeError, ValueError):\n return 3\n return max(1, min(v, MAX_FORESIGHT_VERIFY_SAMPLES))\n\n # T7 LLM response cache: serve an identical DETERMINISTIC (temperature 0) request from an\n # in-process content-addressed cache instead of re-hitting the model — cuts cost on\n # retry/panel/verify flows. NEVER caches sampling calls (temp>0: best-of-N / panel / novelty\n # re-propose must vary). Off by default (most role calls run at temperature>0 anyway).\n llm_cache: bool = False\n agent_cmd: str | None = None # override the agent's launcher (path / wrapper)\n # External-agent validation (ADR-7): wrap a CLI-agent Developer with a validator that\n # audits each output (no-op/syntax/crash/timeout), retries with feedback, and falls\n # back to the in-house LLM Developer. Off -> use the raw agent output unchecked.\n validate_agent: bool = True\n agent_max_retries: int = 1 # re-prompts of the agent on an invalid result\n # Patch-gated multi-file agent (ADR-7 Rule 3): run the CLI agent in a git worktree and\n # accept only edits whose paths match `agent_surface` (reject-not-strip out-of-surface\n # touches). Lets the agent create helper modules, not just solution.py. Degrades to\n # whole-file readback if git is unavailable.\n agent_patch_gate: bool = True\n agent_surface: list[str] = [\"*.py\"] # edit-surface allow-list (globs)\n # Trust policy for an agent-authored eval/metric adapter (RepoTask onboarding, Phase 3):\n # \"ratify_freeze\" waits for human approval, \"autonomous\" accepts the proposed spec without that\n # gate, and \"ratify_freeze_drift\" also enforces the configured independent drift cross-check.\n eval_trust_mode: str = \"ratify_freeze\"\n # RepoTask node seeding policy (run-wide fallback; a task/editable `seed_mode` overrides):\n # \"auto\" (default) copies git-tracked files when the editable is a git repo (so a tree bloated\n # with untracked artifacts isn't deep-copied per node), else a full copy; \"tracked\" forces\n # code-only; \"all\" forces a full recursive copy (legacy). Genesis authors per-task from the\n # user's words; this is the global default when unspecified.\n seed_mode: str = \"auto\"\n llm_model: str = \"qwen3:8b\"\n # === LLM / transport ==================================================================\n llm_base_url: str = \"http://localhost:11434/v1\" # Ollama OpenAI-compatible endpoint\n llm_temperature: float = 0.6\n # H3 per-role model presets (5090 recipe): optionally run the Researcher and Developer on\n # DIFFERENT models/endpoints (e.g. Developer=Qwen3-Coder-30B for code, a fast model for breadth).\n # Blank = use the shared llm_model/llm_base_url. Generalizes the role-backend swap to per-role.\n researcher_model: str | None = None\n developer_model: str | None = None\n researcher_base_url: str | None = None\n developer_base_url: str | None = None\n # Per-role sampling temperature (§4.1): the Researcher wants breadth (higher temp = more diverse\n # ideas), the Developer wants determinism (lower temp = fewer syntax slips), the Strategist a\n # steady hand. Each overrides the shared `llm_temperature` for that role only; None = use the\n # shared value (byte-identical to before). Flat + nullable, mirroring the per-role model fields.\n researcher_temperature: float | None = None\n developer_temperature: float | None = None\n strategist_temperature: float | None = None\n # The Strategist had per-role TEMPERATURE but no model/endpoint of its own, so in split mode it\n # always ran on the shared llm_model however the other roles were pointed. These close that gap\n # with the same names/shape as the pair above; blank = its profile, else the shared value.\n strategist_model: str | None = None\n strategist_base_url: str | None = None\n # === Connection profiles (multi-provider) =============================================\n # A \"profile\" is a NAMED connection: {model, base_url, temperature, api_key_env, provider}. Roles\n # point at one by name through `role_profiles`; `llm_profile` is the default for every role that\n # doesn't. All three are EMPTY by default and the resolver short-circuits to the shared\n # llm_model/llm_base_url/llm_api_key path when they are — an operator running one model never\n # needs to know the word \"profile\".\n #\n # A profile stores `api_key_env`, the NAME of an environment variable, never the key itself. That\n # is ADR-11's \"a secret is a reference, not a value\" turned from a convention into a data type:\n # the whole profile map is then safe to write into config.snapshot.json, hand back over HTTP and\n # render into LOOPLAB_* — there is nothing in it to mask. It is also what makes per-role\n # CREDENTIALS expressible at all: two roles on one provider can carry different keys, which a\n # key-per-endpoint map could not say.\n llm_profiles: dict[str, dict] = Field(default_factory=dict)\n llm_profile: str | None = None\n role_profiles: dict[str, str] = Field(default_factory=dict)\n # Unified self-driving agent: one LLM identity that plays Researcher + Developer (+ Strategist)\n # across pipeline stages, choosing its own model/toolset per stage. ON by default — the agent\n # drives the loop (incl. crash triage: repair/abandon/reject_idea) and, via\n # `agent_drives_actions`, picks the next macro action within a pure legal-action gate. Set both\n # to False for the legacy byte-identical split-role behavior. (No-op unless backend=\"llm\".)\n unified_agent: bool = True\n agent_drives_actions: bool = True\n # Layer 3 Card queue ownership. False preserves the existing policy/unified-pilot action path.\n # True is pinned in run_started because changing the selector on resume would mix two search\n # treatments in one run. When both this and agent_drives_actions are true, Card selection wins.\n card_driven_selection: bool = False\n # Layer 5 bounded speculative Card buffer. Zero is a hard OFF switch and preserves the\n # historical alternating build/eval spine; positive values are pinned by run_started so a\n # resume cannot silently mix search treatments after a snapshot/default edit.\n speculation_depth: int = Field(default=0, ge=0, le=64)\n # Local evidence receipt produced by ``looplab speculation-gate``. This is deliberately absent\n # from the curated Settings UI: positive-depth execution is a calibrated rollout decision, not a\n # casual tuning knob. The Engine validates the receipt against the current scorer,\n # implementation, GPU inventory and raw paired-run evidence before it enables speculation.\n speculation_gate_receipt: str | None = Field(default=None, max_length=4096)\n # Per-stage model/endpoint overrides for the unified agent. Recognized keys (see\n # tasks.build_unified_agent): `propose` (researcher), `implement`/`repair` (developer),\n # `strategy`, `pilot`. Unlisted keys are ignored. Empty = the per-role researcher_*/developer_*\n # models, then the shared llm_model. Explicit map wins. No-op unless `unified_agent` is true.\n agent_stage_models: dict = {}\n agent_stage_base_urls: dict = {}\n llm_parser: str = \"tool_call\"\n # Reasoning/thinking toggle sent IN the request (providers differ — see `llm.reasoning_body`):\n # Qwen3 on vLLM/SGLang -> chat_template_kwargs.enable_thinking (bool)\n # OpenAI/Ollama-v1/DeepSeek -> reasoning_effort (low|medium|high|none)\n # `llm_reasoning`: \"\" = send nothing (server default — current behavior); off = actively disable;\n # on = enable at default depth; low|medium|high = enable at that effort.\n # Defaults to \"high\" so the agent reasons before proposing/repairing/triaging (better code, fewer\n # crashes); set \"\" to send nothing (server default) or \"off\" to disable. The model's chain-of-thought\n # is CAPTURED either way (reasoning_content field or inline <think>). NOTE: to get a SEPARATE\n # reasoning_content field from SGLang/vLLM, the server also needs `--reasoning-parser qwen3`.\n llm_reasoning: str = \"high\"\n llm_reasoning_style: str = \"auto\" # auto | qwen | effort | none — how to shape the request param\n llm_reasoning_extra: dict = {} # raw fields merged into the body (escape hatch, e.g. Anthropic thinking)\n # H1: drive structured calls with the endpoint's constrained decoding (vLLM/SGLang guided_json +\n # response_format json_schema) so weak models can't emit invalid JSON. Off for Ollama (default).\n llm_guided_json: bool = False\n # Stream every LLM request (SSE) and reassemble it, so the request `timeout` is an INTER-TOKEN\n # idle timeout rather than a whole-request deadline: a long-but-alive generation is never cut off\n # (tokens keep resetting the timer), while a genuinely STALLED endpoint (no token for `timeout` s)\n # is detected and retried on a fresh connection — the fix for silent multi-minute hangs. Set False\n # to use one blocking read (old per-op timeout semantics) if an endpoint streams badly.\n llm_stream: bool = True\n # LLM IDLE timeout (seconds). Stream mode: the INTER-TOKEN stall limit — a steady generation is\n # never cut off, only a silent endpoint. Non-stream: bounds the whole read. Raise for endpoints\n # with a long prefill on huge prompts; lower to fail fast on a flaky shared endpoint.\n llm_timeout: float = Field(default=180.0, gt=0)\n # First-byte (response-headers) window for STREAM attempts, seconds: an SSE response sends its\n # headers on admission, so no-headers ≈ a black-holed request — fail over to a fresh connection\n # fast instead of waiting the full idle timeout. Clamped to llm_timeout. Non-stream attempts are\n # NOT bounded by this (their headers arrive only after the whole generation). The default mirrors\n # `DEFAULT_HEADER_TIMEOUT_S` in looplab/core/llm.py is the single source (imported above).\n llm_header_timeout: float = Field(default=DEFAULT_HEADER_TIMEOUT_S, gt=0)\n # Honour HTTP(S)_PROXY / NO_PROXY / SSL_CERT_FILE env vars for LLM calls. Default False: the\n # transport connects DIRECTLY (many internal/OLAP endpoints are reachable only by bypassing an\n # ambient proxy — a picked-up proxy yields \"connection refused\"). Set true when the endpoint is\n # reachable ONLY via a corporate proxy or needs a custom CA bundle from the environment.\n llm_trust_env: bool = False\n # H4/C2: compact the growing agentic tool-call history once it exceeds this many chars (auto_summary\n # LLM-summarizes the stale middle; else middle-truncate). Sized to the model's real context window so\n # files read earlier STAY in context instead of being compacted away and re-read: ~1,000,000 chars ≈\n # 250k tokens (≈4 chars/token), matching the configured deepseek-v4-flash (≥200k tokens confirmed).\n # The old 120k-char (~30k-token) default was ~8× too aggressive and drove a read-thrash loop. 0 = off.\n context_budget_chars: int = 1_000_000\n # M5: the Researcher's always-on experiments digest budget (chars). 0 = AUTO — scales with the\n # run size (60 chars/node, bounded [1200, 6000]) instead of one flat cap for an 8-node toy run\n # and a 200-node benchmark run alike. Set a positive value to pin it.\n digest_char_cap: int = Field(default=0, ge=0)\n # D11 compression model slot (open_deep_research's four-slot pattern): a dedicated CHEAP model\n # for agent-history summarization (C2 auto-summary), so compression doesn't pay the main\n # model's price. Blank = use each loop's own model (legacy). `compressor_base_url` blank =\n # the shared llm_base_url.\n compressor_model: str | None = None\n compressor_base_url: str | None = None\n # === Agentic tool-loop ================================================================\n # Agentic tool-loop limits — apply to EVERY tool-using agent (the LLM Researcher, the unified\n # agent's pilot + crash-triage, Deep-Research, the run-chat Boss, the genesis repo-scout, and the\n # cross-run report synthesizer). The loop lets the model call read-only tools across turns before\n # emitting its structured result. Both default to UNLIMITED so the agent takes as many turns / as\n # long as it needs — it is never cut off mid-reasoning; set a positive value here (or per-run, or\n # in the UI settings) to bound latency/cost. These used to be hardcoded per call site (e.g. the\n # boss at max_turns=3 / 45s), which silently dropped a slow reasoning model to a no-op reply.\n agent_max_turns: int = 0 # max tool turns before the emit is forced (0 = unlimited)\n agent_time_budget_s: float = 0.0 # wall-clock ceiling across the loop's turns (0 = no cap)\n # G · Soft convergence SAFETY NET (not a research cap). Two thresholds on the agent's tool-turn count:\n # at `agent_emit_after` the loop NUDGES the agent once (\"you've investigated enough — emit now\"); at\n # `agent_emit_force` it FORCES the emit from what was gathered. Set GENEROUSLY — a focused researcher\n # may legitimately read many files/experiments before it can propose a grounded idea; these only catch\n # a genuine runaway that keeps issuing DIFFERENT calls forever (which stuck-detection, keyed on\n # repeats, misses). NB: the old 499-read runaway (node 63) was 79% REDUNDANT re-reads caused by a\n # broken read_file — fixing read_file (pagination) is the real cure; this is just insurance. 0 = off.\n agent_emit_after: int = 300\n agent_emit_force: int = 500\n # B1 · No-progress / stuck detection — the safety net that makes \"unlimited turns\" safe. The\n # turn/time ceilings above are only BACKSTOPS; this is what actually stops a runaway loop, on the\n # cheapest signal: when the model repeats the SAME tool call (or ping-pongs between two, or keeps\n # hitting the SAME error) with no progress, the loop forces the final emit and finishes. ON by\n # default; reading DIFFERENT files or running ONE long command never trips it. (OpenHands-style.)\n agent_stuck_detection: bool = True\n agent_stuck_repeat: int = 4 # identical calls in a row that count as \"stuck\" (>=2)\n agent_stuck_alternate: int = 4 # ping-pong cycles between two calls that count as \"stuck\" (>=2)\n # C1 · Self-plan (TodoWrite-style): expose an `update_plan` tool so a long-running agent keeps its\n # OWN working TODO and re-surfaces it every `agent_plan_reinject_every` turns — keeps the goal in\n # view across a long loop so the agent \"writes its own context\" (Claude Code style). ON by default.\n agent_self_plan: bool = True\n agent_plan_reinject_every: int = 5\n # C2 · Auto-summary (ON by default): when the tool-loop history grows long, LLM-summarize the\n # stale middle instead of only middle-truncating it. The trigger is `context_budget_chars` if set,\n # else a built-in high-water mark (context_budget.DEFAULT_SUMMARY_CHARS ≈ 120k chars) so short\n # loops are untouched. Falls back to truncation on any summarizer error; one extra model call per\n # over-budget turn.\n agent_auto_summary: bool = True\n # C4 · Repo-developer plan decomposition. A big feature attempted in ONE developer session made a\n # non-converging reasoning model loop indefinitely — it kept writing + exploring without ever\n # emitting `done` (10k+ LLM calls / hours; agentic stuck-detection can't catch VARIED exploration,\n # only literal repeats). Fix: the developer first proposes an ordered plan of ATOMIC steps; a\n # multi-step plan is executed step-by-step, each in a FRESH bounded session with only that step's\n # scope + the files accumulated so far, syntax-validated per write. Every session stays\n # small-context and convergent. ON by default for repo tasks (a 1-step plan == the old single pass).\n developer_plan_decompose: bool = True\n developer_plan_min_steps: int = 2 # a proposed plan with >= this many steps runs step-by-step\n developer_plan_max_steps: int = 8 # cap on plan length (a runaway planner can't spawn 100 steps)\n # Hard per-session backstop for the repo developer — the ONE write-heavy agent that can run away\n # even with stuck-detection on (varied writes/reads never trip the repeat/ping-pong signal). Bounds\n # the plan phase, EACH step, and the single-session fallback. Unlike the global agent_* limits\n # (0 = unlimited, meant for read-only agents), the developer always gets a FINITE ceiling so a\n # model that never emits `done` fails cleanly with the code it wrote so far, instead of looping.\n developer_session_max_turns: int = 500\n developer_session_time_budget_s: float = 1200.0 # 20 min wall-clock per developer session\n # Phase-handoff summaries. Each LLM phase in a node build (Researcher·propose → Developer·stages →\n # plan → implement) ends with ONE extra LLM call that distills its transcript — the repo structure\n # it mapped, files/data confirmed, decisions made — into a brief injected into the NEXT phase (even\n # across the role boundary). The next phase TRUSTS that instead of re-reading the same files, which\n # cuts the biggest source of tool-call thrash (stages/plan/implement each re-exploring the repo).\n # One summary call per phase in exchange for many fewer read calls downstream. ON by default.\n phase_handoff_summary: bool = True\n # Observability (ADR-17): capture each LLM call's full prompt + completion into the active\n # span (spans.jsonl) so the UI can show exactly what the model read and wrote per node.\n # Diagnostics only — `replay.fold` never reads spans. Default on for local single-user; set\n # LOOPLAB_TRACE_LLM_IO=0 to suppress (e.g. to keep spans.jsonl small or avoid storing prompts).\n trace_llm_io: bool = True\n # Run-introspection tools (ON by default): make the LLM Researcher (and DeepResearcher) a\n # tool-using agent that can read its OWN experiments (list/read/find-analogous/themes/code) and\n # the task data (schema/profile/asset) mid-loop, instead of seeing only best+parent. Advisory —\n # never changes best-selection. Off = the legacy single-shot Researcher (richer digest still added).\n researcher_tools: bool = True\n # Cross-run introspection: give the agentic Researcher / pilot read-only tools to look at\n # SIBLING runs (same task_id, same run-root) — list them, read an experiment / its code, and\n # find analogous configs across runs — so a run can build on what neighbouring runs already\n # learned instead of rediscovering it. Advisory; never changes best-selection. Needs the run's\n # own dir wired through (no-op for unit-built roles). Off = the legacy single-run view only.\n cross_run_tools: bool = True\n # Read-only access to EVERY run on this machine ACROSS ALL TASKS (not just same-task siblings):\n # the Developer/Researcher get list_all_runs + read_run_code + read_run_experiment so they can read\n # the code + result of ANY past experiment anywhere and reuse an approach. Broader than\n # `cross_run_tools` (same-task only); the agent decides when a foreign run is relevant. Advisory;\n # never changes best-selection. Needs the run's own dir wired through (no-op for unit-built roles).\n all_runs_tools: bool = True\n # PART V §22 — read-only CROSS-RUN KNOWLEDGE tool for the reasoning roles (Researcher, Strategist,\n # deep-research, and a Developer-scoped variant). Adds `cross_run_prior_attempts` / `cross_run_claims`\n # / `cross_run_atlas` over the §21.20 read-models (concept overview + claim assessments + atlas), read\n # from `memory_dir` (lessons.jsonl + concept_capsules.jsonl). Read-only is a storage-authority\n # boundary, not a behavioral no-op: retrieved content can steer later proposals, and a tool-using\n # reasoning loop can consume additional model calls. The tools never mutate cross-run truth (facts\n # are engine-written, verdicts operator-ratified, §22.4) or directly select a winner. ON by explicit\n # experimental product choice, EngineOptions off; stores only exist once Part-IV features have run.\n cross_run_read_tools: bool = True\n # Agentic retrieval (ADR-16): if set, the LLM Researcher gets grep/kb_search/read\n # tools over this directory of markdown notes and chooses when to use them.\n knowledge_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"knowledge\"))\n # T4 real embeddings: model id for an OpenAI-compatible `/embeddings` endpoint used for\n # kb_search / case retrieval. Blank (default) = the dependency-free lexical `hash_embed` (today's\n # behavior). Set e.g. \"nomic-embed-text\" (Ollama) or \"text-embedding-3-small\" for SEMANTIC\n # retrieval; a misconfigured/offline endpoint degrades back to hash_embed at call time (never\n # crashes a run). `embed_base_url` blank = reuse `llm_base_url`; the shared `llm_api_key` is used.\n embed_model: str | None = None\n embed_base_url: str | None = None\n # Harmonic memory (Memora, ICML'26 — idea import). When ON, cross-run cases and knowledge notes are\n # indexed by a short ABSTRACTION + cue ANCHORS instead of their raw text, near-duplicate memories are\n # CONSOLIDATED into one entry under a matching abstraction, and `kb_search`/case retrieval EXPAND\n # through the top hits' anchors to surface related-but-not-similar memories. ON by default. Set\n # `memora=false` to restore the pre-Memora raw-text index. Never a source of truth — abstractions\n # live only in the derived, rebuildable retrieval index (never the event log or canonical cases.jsonl).\n memora: bool = True\n # Write abstractions with the wired chat model (richer than the deterministic lexical fallback). ON\n # by default; results are CACHED by content hash (see `memora_cache`) so a re-built index doesn't\n # re-call the model on unchanged notes/cases, and any endpoint failure degrades to lexical at call\n # time — so an offline box just gets lexical abstractions, never a crash. Set false to force lexical.\n memora_llm: bool = True\n # === Cross-run memory / Memora ========================================================\n memora_anchors: int = 6 # max cue anchors kept per memory\n # Cosine similarity at/above which a new case is CONSOLIDATED into an existing one (same evolving\n # topic) rather than stored as a separate entry. Only used when `memora` is on.\n # BOUNDED like its 0..1 sibling `novelty_semantic_threshold`: consumers compare\n # `cosine(...) >= consolidate_threshold` (engine/memory.py, tools/knowledge_tools.py) and cosine\n # cannot exceed 1.0, so an unbounded typo above 1.0 validated cleanly and silently disabled\n # consolidation forever — while a negative one consolidated everything. Fail loud instead.\n memora_consolidate_threshold: float = Field(default=0.86, ge=0.0, le=1.0)\n # Where the LLM-abstraction cache lives (JSON). Blank (default) derives it from `memory_dir`\n # (`<memory_dir>/memora_cache.json`) or else `knowledge_dir` (`<knowledge_dir>/.memora_cache.json`);\n # with neither set the cache is in-memory only (per process). No-op unless `memora_llm` is on.\n memora_cache: str | None = None\n # E3 literature-grounded ideation (network-OPTIONAL): give the agentic Researcher an arXiv search\n # tool to ground ideas in real techniques. Off by default (egress is unreliable on some boxes);\n # fails gracefully if the network is blocked.\n literature_search: bool = False\n # Deep-Research stage (network-OPTIONAL): give the DeepResearcher a general web search/fetch\n # tool (DuckDuckGo) on top of arXiv to read across results + the web before steering the next\n # batch. Off by default (egress is unreliable on some boxes); fails gracefully when blocked.\n web_search: bool = False\n # Cadence for the Deep-Research stage: run it automatically every N created nodes. Zero disables\n # only the cadence; manual `deep_research` and Strategist `request_research` triggers still fire.\n # Default 3: deep research analyzes the accumulating results and steers the next batch (its\n # directions become hints + open hypotheses); paired with `concurrent_research` it overlaps the\n # think with the GPU-bound eval, so the cadence costs LLM tokens but no wall-clock on the search.\n deep_research_every: int = 3\n # Overlap a DUE deep-research \"think\" with the GPU-bound eval instead of running it in its own\n # serial step (the agent is otherwise idle while a node trains). research() computes from a state\n # snapshot; the joined background task records only allowlisted, order-tolerant\n # research/hint/hypothesis events as soon as the memo finishes. ON by default: the LLM is typically\n # remote (no GPU contention with eval), so overlapping hides latency behind long training.\n concurrent_research: bool = True\n # Concurrent-research REPEAT — don't idle a multi-DAY eval. The overlapped \"think\" fires ONCE per\n # window by default; ON here, it RE-RUNS on an adaptive time cadence for the WHOLE eval window\n # (a two-day training must not leave the reasoning agents idle after one memo). Self-paced: it\n # only records a memo whose CONTENT is new (identical re-runs are skipped, so the log/board don't\n # bloat) and backs off geometrically as the analysis converges, capped so it always re-checks.\n # The appended records never rewrite the current metric champion, but their hints/open hypotheses\n # deliberately steer later proposals and replay reconstructs that advice. The library default is\n # one-shot (== today, byte-identical); this product default turns the repetition on.\n concurrent_research_repeat: bool = True\n # Base cadence (seconds) between repeated concurrent-research passes. Research is EXPENSIVE\n # (multi-turn LLM + web/arXiv), so this is a FLOOR, not a ceiling: the effective cadence is\n # max(this, ~5% of the per-experiment time budget) — a two-day eval is re-researched about hourly,\n # a short eval not at all (the first tick outlasts it). Only matters when the repeat is on.\n concurrent_research_interval_s: float = Field(default=1800.0, gt=0)\n # Per-eval-window backstop on repeated-research LLM calls (0 = cadence-only, no cap). The adaptive\n # cadence + convergence backoff are the primary budget control; this bounds a pathological\n # never-converging window. Past it the loop stops calling the LLM (the health monitor still runs).\n concurrent_research_max_calls: int = Field(default=40, ge=0)\n # Concurrent hypothesis-board CONSOLIDATION during the eval window. Repeated research keeps ADDING\n # near-duplicate directions to the open board (each memo registers up to 5 as open hypotheses), so a\n # long eval's board bloats before the between-nodes merge runs. This dedups/merges it on the SAME\n # background loop so the board stays clean for the next proposal. In Card-driven mode the same merge\n # changes ownership/readiness, so it is deferred to the joined main-task cadence instead. Needs the\n # reflect client + `track_hypotheses`.\n # Only active while `concurrent_research` runs the overlap loop. Library default off (== today).\n concurrent_consolidate: bool = True\n # Cadence for the agent-authored run report: regenerate the conclusion-first narrative every N\n # created nodes (0 = off; it still regenerates on a manual `report_refresh` from the UI). The\n # deterministic report always renders from the node set regardless of this knob.\n report_every: int = 3\n # Agent Skills (I18, ADR-9): dir of SKILL.md the Researcher can list/load as tools.\n skills_dir: str | None = None\n # Prompt store (I18, ADR-8): dir of editable, hot-reloaded role prompt .md files.\n prompt_dir: str | None = None\n # API key as a reference, never serialized as a value (ADR-11). Local servers\n # ignore it; default to a placeholder.\n llm_api_key: SecretStr | None = None\n\n @model_validator(mode=\"before\")\n @classmethod\n def _reject_bool_for_numeric_fields(cls, data):\n \"\"\"A JSON boolean is a TYPE ERROR for a numeric setting, not the number 1.\n\n Pydantic runs in lax mode here (`model_config` sets no `strict`), and `isinstance(True, int)`\n is True — so `{\"max_nodes\": true}` from a UI/API type slip validated cleanly as a budget of\n ONE node, and `{\"eval_parallel\": true}` collapsed the width to one. The run then looked\n configured and simply did almost nothing, with no 422 and nothing in the snapshot to show why.\n Rejecting at the boundary covers every numeric field at once, including the many that carry no\n `Field(ge=...)` bound of their own to trip over.\n \"\"\"\n if not isinstance(data, dict):\n return data\n for key, val in data.items():\n if type(val) is bool and _is_numeric_annotation(\n getattr(cls.model_fields.get(key), \"annotation\", None)):\n raise ValueError(f\"{key} must be a number, not a boolean (got {val!r})\")\n return data\n\n @model_validator(mode=\"before\")\n @classmethod\n def _apply_profile(cls, data):\n \"\"\"Expand `profile` into a bundle of setting defaults BEFORE validation.\n\n pydantic-settings hands this validator the fully-merged input from ALL explicit sources\n (init kwargs + LOOPLAB_* env + .env), with fields the user left unset simply ABSENT (they\n fall to field defaults afterward). So a profile value is applied only when its key is not\n already present — i.e. any explicit file/CLI/env setting wins over the profile, and the\n profile wins over the bare field default. This makes `thorough` a true convenience preset,\n never an override of what the user asked for.\"\"\"\n if not isinstance(data, dict):\n return data\n prof = data.get(\"profile\", \"default\")\n # env may deliver the key as \"profile\" (env_prefix stripped) — normalize to a str\n prof = str(prof).strip().lower() if prof is not None else \"default\"\n overrides = PROFILES.get(prof)\n if overrides is None:\n raise ValueError(f\"unknown profile {prof!r}; known: {sorted(PROFILES)}\")\n for key, val in overrides.items():\n if key not in data: # explicit source (file/CLI/env) always wins\n data[key] = val\n return data\n\n @model_validator(mode=\"after\")\n def _check_trust_gate(self):\n if self.trust_gate not in (\"audit\", \"gate\", \"block\"):\n raise ValueError(\n f\"trust_gate must be audit|gate|block, got {self.trust_gate!r}\")\n if self.merge_mode not in (\"auto\", \"mean\", \"ensemble\"):\n raise ValueError(\n f\"merge_mode must be auto|mean|ensemble, got {self.merge_mode!r}\")\n # novelty_mode drives the dedup/novelty gate; an out-of-set value (e.g. a mis-cased\n # LOOPLAB_NOVELTY_MODE=LLM, or \"on\") used to fall through _apply_novelty_gate silently as a\n # NO-OP — turning the gate off with no diagnostic. Fail loudly at config time like trust_gate/\n # merge_mode do. (seed_mode / eval_trust_mode / strategist_backend share the pattern and are\n # validated in this same block, just below.)\n if self.novelty_mode not in (\"off\", \"algo\", \"llm\"):\n raise ValueError(\n f\"novelty_mode must be off|algo|llm, got {self.novelty_mode!r}\")\n # The remaining enum-ish string fields (arch-review §5 P3): a typo used to be accepted at\n # construction and only fail-safe/later-loud downstream (e.g. a mis-cased strategist_backend\n # silently ran the default). Fail loudly here like the fields above.\n if self.strategist_backend not in (\"off\", \"rule\", \"llm\", \"agent\"):\n raise ValueError(\n f\"strategist_backend must be off|rule|llm|agent, got {self.strategist_backend!r}\")\n if self.eval_trust_mode not in (\"ratify_freeze\", \"autonomous\", \"ratify_freeze_drift\"):\n raise ValueError(\n \"eval_trust_mode must be ratify_freeze|autonomous|ratify_freeze_drift, \"\n f\"got {self.eval_trust_mode!r}\")\n if self.seed_mode not in (\"auto\", \"tracked\", \"all\"):\n raise ValueError(f\"seed_mode must be auto|tracked|all, got {self.seed_mode!r}\")\n if self.backend not in (\"toy\", \"llm\"):\n raise ValueError(f\"backend must be toy|llm, got {self.backend!r}\")\n # developer_backend is a CLOSED enum: \"default\" (in-house developer) plus the external\n # coding-agent keys in agents/cli_agent.py::PRESETS. The CLI --developer-backend flag guards it\n # (_DEV_BACKENDS), but a file/env/`--set` value reached here unchecked and adapters/tasks.py then\n # silently wired the DEFAULT developer for anything not in PRESETS. Validate against the SAME\n # authoritative registry the runtime consumer uses (imported lazily to keep config import-light),\n # so a typo fails loud instead of downgrading — mirroring the `backend` guard above.\n from looplab.agents.cli_agent import PRESETS as _DEV_PRESETS\n _dev_backends = (\"default\", *_DEV_PRESETS)\n if self.developer_backend not in _dev_backends:\n raise ValueError(\n f\"developer_backend must be {'|'.join(_dev_backends)}, \"\n f\"got {self.developer_backend!r}\")\n # `parse_structured` resolves an unknown name to the DEFAULT fallback order, so a typo'd\n # llm_parser is indistinguishable from asking for the default. Validate against the parser\n # registry itself (imported here, not at module scope, to keep config import-light).\n from looplab.core.parse import _ORDER as _PARSER_ORDER\n if self.llm_parser not in _PARSER_ORDER:\n raise ValueError(f\"llm_parser must be one of {'|'.join(sorted(_PARSER_ORDER))}, \"\n f\"got {self.llm_parser!r}\")\n self._check_llm_profiles()\n return self\n\n def _check_llm_profiles(self) -> None:\n \"\"\"Validate the connection-profile maps LOUDLY, at construction.\n\n Assignment is not validated anywhere in this project, and these two maps are the kind of\n thing a typo makes into a silent no-op: an unknown role name would simply never be read, and\n a reference to a missing profile would fall back to the shared model while the operator\n believed a second provider was in play. Both are errors here instead.\n\n The `api_key_env` rules exist for a specific reason. It must LOOK like a secret to\n `runtime/sandbox.py::SECRET_ENV`, because that is the filter that strips the operator's keys\n out of the environment handed to generated candidate code — a key parked in a variable named\n `MY_HANDLE` would be invisible to it and ride straight into the sandbox. This pattern is\n deliberately the STRICTER of the two (the sandbox has to recognize whatever already exists;\n this names a variable the operator is about to create), and it is duplicated below rather than\n imported because layering forbids `core` from importing `runtime`.\n `tests/test_secret_env_pattern.py` pins the two together — including the invariant that every\n name accepted here is one the sandbox strips. And a literal key inside a profile is refused\n outright: the profile map is written verbatim into config.snapshot.json and served over HTTP\n precisely because it is supposed to contain no secrets.\"\"\"\n from looplab.core.llm import LLM_ROLE_KEYS\n if not isinstance(self.llm_profiles, dict):\n raise ValueError(\"llm_profiles must be a map of profile name -> profile\")\n for name, entry in self.llm_profiles.items():\n if not isinstance(entry, dict):\n raise ValueError(f\"llm_profiles[{name!r}] must be an object\")\n # BEFORE the unknown-field check: none of these is a known field, so checking them\n # afterwards could never fire and someone who pasted a live key was told it was a typo.\n for banned in (\"api_key\", \"key\", \"token\", \"secret\"):\n if banned in entry:\n raise ValueError(\n f\"llm_profiles[{name!r}] must not contain {banned!r}: a profile stores \"\n \"api_key_env (the NAME of an environment variable), never a key value — \"\n \"the whole map is written to config.snapshot.json and served over HTTP\")\n unknown = set(entry) - _PROFILE_FIELDS\n if unknown:\n raise ValueError(\n f\"llm_profiles[{name!r}] has unknown field(s) {sorted(unknown)}; \"\n f\"known: {sorted(_PROFILE_FIELDS)}\")\n env = entry.get(\"api_key_env\")\n if env is not None and not _SECRET_ENV_NAME.match(str(env)):\n raise ValueError(\n f\"llm_profiles[{name!r}].api_key_env={env!r} is not a usable variable name: it \"\n \"must be UPPER_SNAKE and contain KEY/SECRET/TOKEN/PASSWORD/PASSWD/CREDENTIAL, so \"\n \"the sandbox's secret-name filter strips it from generated code's environment\")\n temp = entry.get(\"temperature\")\n if temp is not None and (isinstance(temp, bool) or not isinstance(temp, (int, float))):\n raise ValueError(f\"llm_profiles[{name!r}].temperature must be a number\")\n if not isinstance(self.role_profiles, dict):\n raise ValueError(\"role_profiles must be a map of role name -> profile name\")\n for role, profile in self.role_profiles.items():\n if role not in LLM_ROLE_KEYS:\n raise ValueError(\n f\"role_profiles has unknown role {role!r}; known: {sorted(LLM_ROLE_KEYS)}\")\n if profile not in self.llm_profiles:\n raise ValueError(\n f\"role_profiles[{role!r}] names profile {profile!r}, which is not in llm_profiles\")\n if self.llm_profile is not None and self.llm_profile not in self.llm_profiles:\n raise ValueError(\n f\"llm_profile={self.llm_profile!r} is not in llm_profiles\")\n\n def masked_snapshot(self) -> dict:\n # Snapshots are a JSON contract (they are written verbatim to config.snapshot.json), not a\n # Python-object dump. In particular tuple-valued schema fields such as\n # ``inline_repair_reasons`` must become JSON arrays before callers hash the runtime envelope;\n # otherwise the strict speculation-scope canonicalizer sees a different/non-JSON preimage\n # from the one that ``json.dumps`` eventually persists.\n d = self.model_dump(mode=\"json\")\n if self.llm_api_key is not None:\n d[\"llm_api_key\"] = \"***\"\n else:\n d[\"llm_api_key\"] = None\n # The snapshot's own FORMAT version — not a Settings field (it is never settable by env or\n # file, and it describes the document, not the run). `settings_from_snapshot` fails closed on\n # a version it does not understand; see CONFIG_SNAPSHOT_SCHEMA.\n d[CONFIG_SNAPSHOT_SCHEMA_KEY] = CONFIG_SNAPSHOT_SCHEMA\n return d\n\n\n# The config snapshot's document-format version, written by `masked_snapshot` and enforced by\n# `settings_from_snapshot`. Bump it when a snapshot written by this binary would be MISREAD by an\n# older one — i.e. when a new field changes paid, concurrency or selection behaviour and silently\n# defaulting it would resume the same event history under different semantics. `Settings` is\n# `extra=\"ignore\"`, so without this an older binary drops what it does not recognize and resumes\n# anyway, with no diagnostic; a version it does not know is now a loud refusal instead.\n# Version 1 is the first VERSIONED format. An absent key means a pre-versioning snapshot, which stays\n# readable through `LEGACY_CONFIG_SNAPSHOT_DEFAULTS` — that historical contract is unchanged.\nCONFIG_SNAPSHOT_SCHEMA = 1\nCONFIG_SNAPSHOT_SCHEMA_KEY = \"config_snapshot_schema\"\n\n\nclass ConfigSnapshotVersionError(ValueError):\n \"\"\"A config snapshot was written by a NEWER binary than the one reading it.\"\"\"\n\n\n# Pre-versioned snapshots are full Settings dumps, so absence is a reliable per-field version marker.\n# Keep their historical effective behavior when newer product defaults become active: re-entry must not\n# silently add paid calls, interventions, concurrency or a different selection policy to an old run.\nLEGACY_CONFIG_SNAPSHOT_DEFAULTS: dict[str, object] = {\n \"parallel_build\": 1,\n \"eval_parallel\": None,\n \"llm_parallel\": None,\n \"train_monitor\": False,\n \"train_monitor_interval_s\": 600.0,\n \"train_monitor_kill\": False,\n \"train_monitor_kill_confidence\": 0.8,\n \"asha_live\": False,\n \"asha_live_kill\": False,\n \"asha_live_quantile\": 0.5,\n \"asha_live_min_siblings\": 3,\n # Before this operator ceiling existed, the sandbox's 24-hour process limit was authoritative.\n \"max_eval_timeout\": 24 * 3600.0,\n \"watchdog_reflection\": False,\n \"card_driven_selection\": False,\n \"speculation_depth\": 0,\n \"speculation_gate_receipt\": None,\n \"concurrent_research_repeat\": False,\n \"concurrent_research_interval_s\": 1800.0,\n # Zero means unlimited; 40 was the first bounded default, and is latent while repeat is disabled.\n \"concurrent_research_max_calls\": 40,\n \"concurrent_consolidate\": False,\n # The map originally covered only the flags added in the days just before it was written\n # (the entries above are all 2026-07-19..07-21; the map itself landed 07-22). Everything below was\n # added 2026-06-24..07-17 — AFTER `config.snapshot.json` existed (it ships in the initial commit,\n # 2026-06-23) and BEFORE this map did — so a snapshot from that window carries none of these keys\n # and, without an entry here, resumed with today's paid product default ACTIVE. That is precisely\n # the \"silently add paid calls ... to an old run\" the paragraph above forbids: the resumed half of\n # the run would make predict-before-execute and cross-run LLM calls the original never made, and\n # admit candidates under a different novelty policy.\n # `setdefault` makes every one of these a no-op for a snapshot that DOES carry the key, so only\n # genuinely older runs are affected. Each value is the behaviour before the field existed: absent\n # feature = off, absent cadence = 0 (the same \"conservative library\" value the frozen\n # Settings-vs-EngineOptions divergence table records for these fields). Each was dated against the\n # tree before being listed — e.g. `report_every` (2026-06-25) is 0 because the run-report writer\n # itself did not exist before that commit, so an older run wrote no report to preserve.\n \"foresight\": False,\n \"foresight_agentic\": False,\n \"foresight_verify\": False,\n \"concept_pivot\": False,\n \"graded_novelty\": False,\n \"cross_run_concepts\": False,\n \"cross_run_advisory\": False,\n \"cross_run_structured_claims\": False,\n \"cross_run_curation\": False,\n \"cross_run_read_tools\": False,\n \"concept_run_base\": False,\n \"fingerprint_universal\": False,\n \"memora_llm\": False,\n \"comparative_lessons\": False,\n \"lessons_every\": 0,\n \"lessons_refresh_every\": 0,\n \"unified_agent\": False,\n \"deep_research_every\": 0,\n \"report_every\": 0,\n \"failure_reflection\": False,\n \"reflection_priors\": False,\n \"agent_drives_actions\": False,\n \"concurrent_research\": False,\n \"deep_repair\": False,\n # `merge_mode` meets all three conditions below, confirmed against the commits: the field was\n # introduced 2026-06-24 (bb421e0f), one day AFTER the cutoff, so a pre-cutoff snapshot carries no\n # such key; it arrived defaulting to \"mean\" and only later flipped to \"auto\" (a8b37944,\n # 2026-07-03), which `orchestrator.__init__` resolves to \"ensemble\" for a code-generating\n # developer — a PAID code-recombination merge producing a DIFFERENT merged solution; and the\n # before-the-field value is pointable and still frozen as the library default in\n # `EngineOptions` / `orchestrator._DEFAULTS`. Without this entry a pre-field snapshot resumed on\n # an LLM backend silently gained ensemble merges it never did.\n \"merge_mode\": \"mean\",\n # WHAT THIS MAP IS NOT. It is a hand-maintained list of FEATURE switches whose before-the-field\n # value is unambiguous, not a complete partition of `Settings`. Two classes stay out on purpose,\n # because for them a wrong entry is worse than a missing one — it would silently REMOVE behaviour\n # the original run really had:\n # * knobs that are latent while their parent feature is off — `foresight_panel`,\n # `foresight_verify_samples`, `memora_anchors`, `holdout_top_k`, `asha_eta`: the parent above\n # already pins them out of play, and their own before-the-field value is not recoverable;\n # * magnitudes and depths for behaviour that ALREADY existed — `debug_depth`,\n # `context_budget_chars`, `developer_plan_max_steps`, `agent_emit_after`: guessing 0/off there\n # disables working machinery rather than preserving history.\n # A new field belongs here only when it (a) postdates 2026-06-23, (b) defaults to adding paid\n # calls / interventions / concurrency / a different selection policy, and (c) has a historical\n # value you can point at a commit for. When (c) fails, leave it out and say so here.\n}\n\n\ndef migrate_config_snapshot(data: dict) -> dict:\n \"\"\"Return an effective copy of one legacy/current snapshot without rewriting evidence bytes.\"\"\"\n if not isinstance(data, dict):\n raise TypeError(\"config snapshot must be a JSON object\")\n migrated = dict(data)\n for key, value in LEGACY_CONFIG_SNAPSHOT_DEFAULTS.items():\n migrated.setdefault(key, value)\n return migrated\n\n\ndef config_snapshot_schema(data: dict) -> int:\n \"\"\"The snapshot's declared format version. 0 = pre-versioning (the key did not exist yet).\n\n A malformed value is treated as UNREADABLE rather than as 0: a snapshot whose own version marker\n is corrupt cannot be assumed to be the oldest format.\"\"\"\n raw = data.get(CONFIG_SNAPSHOT_SCHEMA_KEY, 0) if isinstance(data, dict) else 0\n if isinstance(raw, bool) or not isinstance(raw, int) or raw < 0:\n raise ConfigSnapshotVersionError(\n f\"config snapshot has a malformed {CONFIG_SNAPSHOT_SCHEMA_KEY}: {raw!r}\")\n return raw\n\n\ndef settings_from_snapshot(data: dict) -> Settings:\n \"\"\"Build re-entry Settings from a masked snapshot under its historical missing-field contract.\n\n Fails closed on a snapshot written by a NEWER binary. `Settings` is `extra=\"ignore\"`, so such a\n snapshot would otherwise load with every unrecognized control silently dropped — the run would\n resume on the same event history under different paid/concurrency/selection semantics, with\n nothing to show why. Refusing is the only honest answer: this binary cannot know what it is\n dropping. Older and pre-versioning snapshots keep loading exactly as before.\"\"\"\n found = config_snapshot_schema(data)\n if found > CONFIG_SNAPSHOT_SCHEMA:\n raise ConfigSnapshotVersionError(\n f\"this run's config.snapshot.json declares format v{found}, but this build understands \"\n f\"at most v{CONFIG_SNAPSHOT_SCHEMA}. It was written by a newer LoopLab; resuming here \"\n \"would silently drop settings this build does not know. Upgrade LoopLab to resume it.\")\n migrated = migrate_config_snapshot(data)\n migrated.pop(\"llm_api_key\", None)\n migrated.pop(CONFIG_SNAPSHOT_SCHEMA_KEY, None) # a document marker, never a Settings field\n return Settings(**migrated)\n"}, "4e79d5ec205d13959edc585a148481f1d3869d74": {"t": 1785768726.9662516, "n": 119665, "h": "1bd9b2f31354dcc8", "c": "\"\"\"Engine config: the schema (what settings exist). (I0, ADR-11)\n\nOne pydantic-settings class declaring every engine knob, its default, and its docs. The loader —\nhow a run file / CLI / env becomes a `Settings` — is `looplab.core.appconfig`, which also owns\nthe config-precedence order. A resolved, secret-masked snapshot is written next to each run for\nreproducibility. (No real secrets in P0, but the masking discipline is in place.)\n\"\"\"\nfrom __future__ import annotations\n\nimport re\nimport types\nimport typing\nfrom pathlib import Path\n\nfrom pydantic import Field, SecretStr, field_validator, model_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n# Single source of the LLM first-byte (response-headers) default — see core/llm.py.\nfrom looplab.core.llm import DEFAULT_HEADER_TIMEOUT_S\n\n# Default home for cross-run memory + the knowledge base (shared across every run). Both are ON by\n# default now (a user asked for it) so a fresh install accumulates + consults knowledge with no setup;\n# set the env var to \"\" to disable, or to a path to relocate. `~/.looplab/` keeps them out of any one\n# project + shared across projects.\n_LL_HOME = Path.home() / \".looplab\"\n\n# What one `llm_profiles` entry may contain. Deliberately closed: an unrecognized field is a typo the\n# operator would otherwise never see fail, and the map is a public artifact (config.snapshot.json,\n# HTTP, LOOPLAB_*), so it must never accrete free-form keys.\n_PROFILE_FIELDS = frozenset({\"model\", \"base_url\", \"temperature\", \"api_key_env\"})\n# A variable name that `runtime/sandbox.py::SECRET_ENV` will recognize as holding a secret and strip\n# from generated code's environment. Duplicated here, not imported: layering forbids `core` from\n# importing `runtime`. `tests/test_secret_env_pattern.py` holds the two in agreement.\n_SECRET_ENV_NAME = re.compile(\n r\"\\A(?=[A-Z][A-Z0-9_]{0,63}\\Z)(?=.*(KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL)).*\\Z\")\n\n\ndef _is_numeric_annotation(ann) -> bool:\n \"\"\"True when a field's annotation admits int/float but NOT bool.\n\n Used by `Settings._reject_bool_for_numeric_fields`: pydantic resolves annotations at class build,\n so `model_fields[...].annotation` is a real type object here even under\n `from __future__ import annotations`. Optionals are numeric when every non-None member is\n (`int | None` yes, `int | str` no), so a boolean can never slip in through a union arm.\"\"\"\n if ann is bool:\n return False # a bool field legitimately takes a bool\n if ann is int or ann is float:\n return True\n args = [a for a in typing.get_args(ann) if a is not type(None)]\n origin = typing.get_origin(ann)\n if args and origin in (typing.Union, types.UnionType):\n return all(_is_numeric_annotation(a) for a in args)\n return False\n\n# Autonomous roles that may be granted per-setting write access (see Settings.agent_control).\n# strategist = A7 meta-controller (run-wide); boss = run-chat operator-proxy (run-wide);\n# researcher = per-experiment proposer (per-node sizing, e.g. eval timeout for a heavy model).\nAGENT_ROLES = (\"strategist\", \"boss\", \"researcher\")\n\n# A foresight score is computed for every acted-on proposal. Keep this lower than the generic\n# verifier's emergency ceiling so a typo cannot multiply paid synchronous calls across a run.\nMAX_FORESIGHT_VERIFY_SAMPLES = 8\n\n\n# Layer-2 parallelism renamed two public settings without removing the durable legacy fields. Keep\n# the equivalence in one place: config-source precedence, governance migration, and live strategy\n# application must all agree which names describe the same authority/value.\nPARALLELISM_ALIASES: dict[str, str] = {\n \"max_parallel\": \"eval_parallel\",\n \"parallel_build\": \"llm_parallel\",\n}\n\n\ndef parallelism_aliases(setting: str) -> tuple[str, ...]:\n \"\"\"Return every spelling in ``setting``'s alias family, canonical name first.\"\"\"\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if setting in (legacy, canonical):\n return canonical, legacy\n return (setting,)\n\n\ndef canonicalize_parallelism_source(\n values: dict, *, promote_build_to_llm_parallel: bool = False,\n) -> dict:\n \"\"\"Promote legacy parallel keys *within one config-precedence source*.\n\n Applying this only after file/CLI/env were flattened would let a lower-priority canonical env\n value shadow a higher-priority legacy file value (or a file canonical value shadow a CLI legacy\n override), so callers with sub-layers normalize each layer independently.\n\n ``max_parallel``/``eval_parallel`` are true aliases (pure eval width, no extra semantics) and are\n always promoted. ``parallel_build``/``llm_parallel`` are NOT: a positive ``llm_parallel`` also flips\n on the finite shared LLM broker (``orchestrator._startup_llm_total``), while legacy ``parallel_build``\n means \"legacy build width, unbounded overlap, no shared total\". So promoting a legacy-only\n ``parallel_build`` layer (e.g. just ``LOOPLAB_PARALLEL_BUILD=1`` in env) would silently serialize\n every provider call — contradicting llm_broker.py's docstring, orchestrator's \"legacy-only preserves\n unbounded overlap\" note, the broker-neutral live-control path, and the configuration.md ``llm_parallel``\n row (\"falls back ... without enabling a shared total\"). Config/startup loads therefore keep broker\n opt-in tied to an EXPLICITLY-spelled canonical ``llm_parallel``; the orchestrator's own width fallback\n (``llm_parallel if not None else parallel_build``) still carries the legacy build width. Only the\n Strategist live-control path (``canonicalize_strategy_parallelism``) opts into\n ``promote_build_to_llm_parallel``, where ``parallel_build`` is a deliberate full alias of the\n canonical knob (see strategy.py ``_apply_strategy``).\n \"\"\"\n if not isinstance(values, dict):\n return values\n out = dict(values)\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if canonical == \"llm_parallel\" and not promote_build_to_llm_parallel:\n continue\n # None is the durable legacy mode for llm_parallel, not a missing value. A normal snapshot\n # contains llm_parallel:null plus parallel_build:1; promoting null to 1 would unexpectedly enable\n # the global one-call broker. Promote aliases only when the canonical key is absent, and pin\n # snapshot round-trip parity. Canonicalize inside each precedence layer, never across the\n # flattened result, so the alias migration cannot invert CLI > file > env precedence.\n if canonical not in out and out.get(legacy) is not None:\n out[canonical] = out[legacy]\n return out\n\n\ndef flatten_parallelism_layers(layers) -> dict:\n \"\"\"Flatten same-tier precedence layers (LOWEST priority first) and resolve the legacy pair.\n\n PRECEDENCE REPAIR for the non-promoted `parallel_build`/`llm_parallel` pair. Declining to promote\n in `canonicalize_parallelism_source` is right (a legacy-only layer must not flip on the shared\n broker), but it let a LOWER-priority canonical value survive a HIGHER-priority legacy override:\n file `llm_parallel: 2` + CLI `-s parallel_build=8` flattened to BOTH keys, and the orchestrator\n prefers `llm_parallel`, so the operator's explicit CLI width was silently discarded. When a higher\n layer names the legacy width over a lower layer's canonical one, mask with the LEGACY SENTINEL\n (`llm_parallel=None` is the durable \"legacy mode — build width from parallel_build, no shared\n total\"), so the higher layer wins with the semantics it asked for.\n\n The mask lives HERE, not in the per-layer canonicalizer, because only a flatten knows the layer\n ORDER. Stamping it per layer meant any layer naming `parallel_build` emitted `llm_parallel=None`\n even when NO layer in the tier set the canonical key — and since pydantic-settings ranks init\n kwargs above env, that sentinel then silently overrode a `LOOPLAB_LLM_PARALLEL` the operator had\n exported as a deliberate provider rate/cost ceiling. Precedence is per FIELD: a tier that never\n spelled `llm_parallel` has not set it, and it must fall through to env/.env/defaults — which is\n exactly what `build_settings`' docstring promises (\"the merged dict wins only where it actually\n sets a value\").\n \"\"\"\n merged: dict = {}\n legacy_at = canonical_at = -1\n for index, layer in enumerate(layers):\n if not isinstance(layer, dict):\n continue\n merged.update(layer)\n if layer.get(\"parallel_build\") is not None:\n legacy_at = index\n if \"llm_parallel\" in layer:\n canonical_at = index\n # Only when this tier DID set the canonical key, at a strictly lower layer than the legacy one.\n if canonical_at >= 0 and legacy_at > canonical_at:\n merged[\"llm_parallel\"] = None\n return merged\n\n# Settings whose semantics are committed by ``run_started`` and restored from the folded event log\n# on every re-entry. Changing one only in ``config.snapshot.json`` would mix incompatible evidence\n# (a different holdout split) or make the live producer disagree with replay's selection policy.\n# The HTTP config editor and the engine both consume this one contract; keep deliberately mutable\n# controls such as ``trust_gate`` out (that field has its own durable change event).\nRUN_START_PINNED_FIELDS = frozenset({\n \"card_driven_selection\",\n \"speculation_depth\",\n \"holdout_fraction\",\n \"holdout_select\",\n \"select_verifier\",\n \"select_verifier_samples\",\n \"verifier_ci_tie\",\n})\n\n\ndef run_start_pinned_settings(state) -> dict:\n \"\"\"Return the effective Settings names committed by a folded run-start record.\n\n Pre-pin legacy logs have no recorded holdout fraction, so their snapshot remains authoritative\n for both holdout knobs. Verifier fields, however, have always been re-pinned from the legacy-safe\n fold defaults whenever a valid ``run_started`` identity exists.\n \"\"\"\n if not getattr(state, \"run_id\", \"\"):\n return {}\n values = {\n # Layer 3 changes which queue owns macro-action selection. Once a run starts, replay and\n # resume must keep the same owner even if the snapshot or ambient default changes later.\n \"card_driven_selection\": bool(getattr(state, \"card_driven_selection\", False)),\n # Layer 5 overlaps Card production with evaluation only when this pinned treatment is\n # positive. Old logs omit it and therefore retain the exact serial build/eval spine.\n \"speculation_depth\": int(getattr(state, \"speculation_depth\", 0)),\n \"select_verifier\": bool(getattr(state, \"select_verifier_tiebreak\", False)),\n \"select_verifier_samples\": int(getattr(state, \"select_verifier_samples\", 3)),\n \"verifier_ci_tie\": bool(getattr(state, \"verifier_ci_tie\", False)),\n }\n holdout_fraction = getattr(state, \"holdout_fraction\", None)\n if holdout_fraction is not None:\n values.update({\n \"holdout_fraction\": holdout_fraction,\n \"holdout_select\": bool(getattr(state, \"holdout_select\", False)),\n })\n if not values.keys() <= RUN_START_PINNED_FIELDS:\n raise RuntimeError(\"folded run-start pinned settings contract drifted\")\n return values\n\n# --- Run profiles (config-first presets) -------------------------------------------------------\n# A `profile` is a NAMED BUNDLE of setting defaults — nothing more. It only fills fields the user\n# did NOT set explicitly (via file / --set / LOOPLAB_* env / init-kwargs), so any explicit knob\n# always wins (see `_apply_profile`). Most engine intelligence lives behind individual flags. The\n# PART IV/V cross-run + concept-graph machinery (concept_pivot, graded_novelty, cross_run_concepts /\n# _advisory / _structured_claims / _curation / _read_tools, fingerprint_universal, foresight_verify)\n# now ships ON in the product `Settings` default. That is an explicit experimental product choice,\n# not a claim that a frozen A/B or workload-wide cost/quality gate has cleared: the bundle can steer\n# prompts and candidate generation, change graded-novelty admission / pre-execution choices, and add\n# paid LLM work when its client and memory prerequisites are present. The bare-library `EngineOptions`\n# default stays lean (see tests/test_options_divergence.py — product side is the aggressive one) so a\n# toy `Engine(...)` in a test doesn't fire cross-run LLM work unasked. The trust/confirm/ablate QUALITY\n# bundle still ships OFF and is turned on by `thorough`. Every value here is reachable by hand — the\n# profile is a convenience, never a hidden mode.\n# keep this rationale behavioral and evidence-based; do not relabel read-only storage\n# access or proposal-only governance as a no-op for agent behavior, cost, or proposal admission.\n#\n# `thorough` deliberately touches only QUALITY/TRUST machinery, not spend: it does NOT raise\n# max_nodes / eval_parallel (those are cost knobs the user owns). It enables multi-seed confirmation\n# (demote seed-lucky leaders), the reward-hack / leakage / critic monitors AND flips them from\n# advisory to *gating* (`trust_gate=\"gate\"` — a\n# flagged win can no longer be selected as best), ablation-driven refinement, and the prompt cues\n# (complexity / budget / failure-reflection / watchdog-reflection). Agentic novelty and the experimental\n# Part IV/V bundle are already product defaults, not profile changes. Effect is large; marginal cost is a\n# few extra evals.\nPROFILES: dict[str, dict] = {\n \"default\": {}, # explicit no-op alias for \"ship-as-is\" (== omitting profile)\n \"fast\": {}, # today's lean defaults, named for symmetry with `thorough`\n \"thorough\": {\n \"confirm_top_k\": 3,\n \"confirm_seeds\": 3,\n # novelty_gate intentionally NOT enabled here: duplicate-detection is the agentic Researcher's\n # job (it reads past experiments + find_analogous_across_runs and DECIDES), not an algorithmic\n # param-distance / embedding auto-reject that mis-fired (it nudged a good fresh idea onto a\n # twice-dead lineage — live node 61). Search stays as tools; the LLM judges novelty.\n \"operator_bandit\": True, # P4: adaptive operator mix over folded yields (GreedyTree)\n \"reward_hack_detect\": True,\n \"code_leakage_detect\": True,\n \"critic_check\": True,\n \"trust_gate\": \"gate\", # detectors stop gating a flagged node from WINNING (not just audit)\n \"ablate_every\": 3,\n \"complexity_cue\": True,\n \"budget_aware\": True,\n \"failure_reflection\": True,\n \"watchdog_reflection\": True, # feed recent live-watchdog (train-monitor/ASHA) flags to proposals\n \"reflection_priors\": True, # no-op unless memory_dir is set (cross-run priors)\n },\n}\n\n\n# The shipped agent-governance matrix (who may change which setting at runtime). Defined as a module\n# constant so the Engine's direct-construction default matches this shipped default exactly (the\n# EngineOptions \"Engine() == shipped defaults\" invariant) instead of diverging to an all-locked map.\nDEFAULT_AGENT_CONTROL: dict[str, list[str]] = {\n \"timeout\": [\"researcher\", \"strategist\"],\n # Layer-2 canonical names are the presented/default governance surface. `_agent_may` falls back\n # to an old snapshot's legacy grant only when its canonical counterpart is absent, so removing\n # duplicate defaults preserves resume authority while an explicit canonical revocation still wins.\n \"eval_parallel\": [\"strategist\"],\n \"llm_parallel\": [\"strategist\"],\n # Strategy-only pseudo-setting: the closed per-lane allocation inside the canonical LLM total.\n # It is not a launch Settings field; operators pin it through `set_strategy` and the Strategist\n # may reallocate it only while this grant is present.\n \"llm_lane_limits\": [\"strategist\"],\n \"max_nodes\": [\"strategist\", \"boss\"],\n \"max_eval_seconds\": [\"strategist\", \"boss\"],\n \"policy\": [\"strategist\"],\n # The policy NAME and its PARAMS are gated independently in `_apply_strategy`; grant both by default\n # so the Strategist that may switch to (say) MCTS may also apply the `c`/`eta` it decided — else its\n # tuned params are silently dropped and the recorded `active_strategy` diverges from the live engine.\n \"policy_params\": [\"strategist\"],\n \"n_seeds\": [\"strategist\"],\n \"ablate_every\": [\"strategist\"],\n \"merge_mode\": [\"strategist\"],\n \"complexity_cue\": [\"strategist\"],\n \"ablate_code_blocks\": [\"strategist\"],\n \"prefer_sweep\": [\"strategist\"],\n \"novelty_stance\": [\"strategist\"],\n \"developer\": [\"strategist\"],\n \"fidelity\": [\"strategist\", \"researcher\"],\n}\n\n\ndef default_agent_control() -> dict[str, list[str]]:\n \"\"\"A FRESH deep-ish copy of the shipped governance matrix (each role list copied so a caller can\n mutate its own map without touching the module constant). The SINGLE constructor of the default,\n called by both `Settings.agent_control`'s default_factory AND the Engine's direct-construction\n default — so \"Engine() == shipped defaults\" can't drift if the copy depth ever needs to change\n (e.g. nested values requiring copy.deepcopy), instead of two verbatim copy expressions.\"\"\"\n return {k: list(v) for k, v in DEFAULT_AGENT_CONTROL.items()}\n\n\nclass Settings(BaseSettings):\n \"\"\"The engine settings schema (every knob a run accepts).\n\n Timeout family — six distinct knobs, each owned by a different subsystem:\n - `timeout`: per-eval wall-clock budget for ONE experiment's evaluation (engine/eval).\n - `max_eval_timeout`: hard ceiling for a governed per-node Researcher timeout override.\n - `llm_timeout`: LLM request idle timeout — inter-token stall limit in stream mode\n (core.llm OpenAICompatibleClient).\n - `llm_header_timeout`: LLM first-byte (response-headers) window for stream attempts (core.llm).\n - `agent_time_budget_s`: wall-clock ceiling across a tool-using agent loop's turns (agents).\n - `dep_install_timeout`: per-package budget for auto-installing a missing dep (env self-prep).\n\n Config-source precedence is owned by `looplab.core.appconfig` (the loader) — see its module\n docstring for the one canonical order.\n \"\"\"\n # Config precedence: see appconfig.py — the loader owns the full order. The `.env` is read\n # so `looplab run`/`looplab ui` pick up LOOPLAB_LLM_BASE_URL etc. without exporting them by hand\n # (keys MUST carry the LOOPLAB_ prefix, same as the env vars). utf-8-sig tolerates a BOM from\n # Windows editors. The test suite disables this (tests/conftest.py) so a dev's real .env in the\n # repo root can't leak into assertions.\n model_config = SettingsConfigDict(\n env_prefix=\"LOOPLAB_\",\n env_file=\".env\",\n env_file_encoding=\"utf-8-sig\",\n extra=\"ignore\",\n allow_inf_nan=False,\n )\n\n @classmethod\n def settings_customise_sources(\n cls, settings_cls, init_settings, env_settings, dotenv_settings,\n file_secret_settings):\n \"\"\"Preserve source precedence across canonical/legacy parallelism names.\n\n Pydantic ranks init > env > dotenv correctly, but it ranks field names rather than semantic\n alias families. Promoting a legacy value inside each source before the merge makes an old\n explicit snapshot/file value beat a lower-priority canonical env value. ``appconfig`` also\n promotes its file/typed/--set sub-layers before combining them into the init source.\n \"\"\"\n def _with_parallel_aliases(source):\n return lambda: canonicalize_parallelism_source(source())\n\n return (\n _with_parallel_aliases(init_settings),\n _with_parallel_aliases(env_settings),\n _with_parallel_aliases(dotenv_settings),\n _with_parallel_aliases(file_secret_settings),\n )\n\n # Run profile (config-first preset, see PROFILES above). \"default\"/\"fast\" = today's lean\n # defaults; \"thorough\" turns the built quality/trust machinery ON in one word. A profile only\n # fills fields the user did NOT set — every knob it touches stays individually overridable.\n profile: str = \"default\"\n\n # Bounds (review C/⚪): a non-positive node/seed budget or timeout is never valid. Upper bounds are\n # equally load-bearing — the UI /start path writes these straight into the engine's env, and an\n # unbounded `eval_parallel` (or legacy `max_parallel`; e.g. 100000 from a crafted preflight) would\n # fan out that many sandboxes → resource exhaustion. Reject at config time, not mid-run. Ceilings\n # are generous; tests top out near 50.\n # Pydantic's lax mode coerces a JSON boolean into any numeric field (`true` -> 1), so a UI/API type\n # error used to silently collapse a run budget to ONE node instead of returning 422. Rejected for\n # EVERY numeric field by `_reject_bool_for_numeric_fields` below, not just the ones bounded here.\n n_seeds: int = Field(default=3, ge=1, le=1024)\n max_nodes: int = Field(default=8, ge=1, le=1_000_000)\n # legacy evaluation-width input. `eval_parallel` below is canonical and wins when set;\n # keep this field for old env/config/snapshots and direct Engine call sites. A width > 1 admits\n # concurrent evals, whose resource reservations may be CPU-only or span one/more GPUs — there is no\n # one-node/one-GPU promise. `max_parallel=0` is launch-time AUTO (detected GPU count, at least 1).\n max_parallel: int = Field(default=1, ge=0, le=1024)\n # legacy build-width input. `llm_parallel` below is canonical and also controls the\n # shared provider-call total when positive. A width > 1 uses fresh pooled researcher/developer pairs;\n # it says nothing about the later eval's CPU/GPU reservation. Startup `0` derives build fan-out from\n # resolved eval width; a live Strategist/operator zero settles to 1. Without role_factory it clamps to 1.\n parallel_build: int = Field(default=1, ge=0, le=64)\n # Hypothesis-card Kanban re-architecture (docs/23, Layer 2). Two INDEPENDENT concurrency axes,\n # decoupled from each other so cost (LLM work) can be tuned separately from throughput (GPU evals).\n # These are the CANONICAL names going forward; `max_parallel`/`parallel_build` above are kept as thin\n # back-compat aliases (env vars / snapshots / ~100 Engine(...) call sites depend on the exact names,\n # so they cannot be renamed — CLAUDE.md). Resolution (orchestrator.__init__): a NEW name, when set,\n # WINS over its legacy alias; None => fall back to the legacy field => byte-identical to today.\n # `eval_parallel`: concurrent EVALS (the GPU/experiment consumer). None -> max_parallel; 0 = AUTO\n # (one experiment per detected GPU). This is the in-Run eval-concurrency ceiling; the resource\n # scheduler bin-packs declared footprints separately. Sibling local GPU-owning Runs serialize\n # through the conservative host-pool lease; external execution needs its own admission boundary.\n # `llm_parallel`: total concurrent LLM provider calls + BUILD fan-out. None -> legacy\n # parallel_build semantics without a shared total; 0 = startup AUTO (= resolved eval_parallel\n # for build fan-out, historical research overlap unbounded). A positive canonical value enables\n # the named-lane broker the Strategist can reallocate at cadence.\n eval_parallel: int | None = Field(default=None, ge=0, le=1024)\n llm_parallel: int | None = Field(default=None, ge=0, le=64)\n # Training-log monitor (I-series watchdog family): a per-eval background observer that tails the live\n # training log while a (often multi-hour) declared stage runs in a worker thread. ON in the product\n # surface (Settings). Its alert event is fold-ignored and cannot directly change lifecycle/champion;\n # with watchdog_reflection on, that alert is nevertheless advice in a later Researcher\n # prompt and can change future proposals. It only fires on the command-eval path with an LLM client;\n # bare-library EngineOptions stays OFF. See test_options_divergence.\n # `_interval_s` is the BASE tick cadence (the effective one adapts to the per-experiment budget).\n train_monitor: bool = True\n train_monitor_interval_s: float = Field(default=600.0, gt=0)\n # Phase 3 — INTERVENTION (opt-in, separate from observation): let the monitor tree-kill a training the\n # LLM judges 'broken' (diverged / silent CPU fallback / not learning) EARLY, instead of burning the\n # whole budget. Off by default — the observer only WATCHES unless this is on. A kill only fires on a\n # 'broken' verdict (a plateau is 'watch', never killed) with confidence >= the threshold; the node then\n # fails normally (reason='monitor_broken'), so replay reconstructs it from that one terminal event.\n train_monitor_kill: bool = False\n train_monitor_kill_confidence: float = Field(default=0.8, ge=0.0, le=1.0)\n # ASHA live-curve watchdog (sibling of the training monitor): reads the latest INTERMEDIATE value of\n # the objective metric off the live log (reusing the eval's OWN metric reader). Finished-endpoint rank\n # remains advisory. An opt-in KILL additionally requires an operator-declared metric.resource_key and\n # enough sibling observations at exactly that resource; absent comparable evidence cannot stop a run.\n # Records a fold-ignored `asha_rank` diagnostic + trace span; the library default is OFF (off == today).\n asha_live: bool = True\n # Opt-in INTERVENTION (separate from the advisory signal): let the watchdog tree-kill a node whose\n # intermediate metric stays below the SAME-RESOURCE bar past a short grace window. Off by default —\n # it only surfaces endpoint rank unless this is on. A kill fails normally (asha_underperforming).\n asha_live_kill: bool = False\n # The bar sits at `asha_live_quantile` along a WORST->BEST ordering of the finished siblings' finals:\n # 0.5 = the median (a node worse than the median peer flags); a SMALLER value is more conservative —\n # it lowers the bar toward the WORST finished peer, so only a node worse than nearly all peers flags\n # (0.0 = worse than the worst). `min_siblings` is the floor of finished peers required before it ranks\n # at all, so it never acts on too little evidence.\n asha_live_quantile: float = Field(default=0.5, ge=0.0, le=1.0)\n asha_live_min_siblings: int = Field(default=3, ge=1)\n timeout: float = Field(default=30.0, gt=0)\n # Hard ceiling for the Researcher's governed per-node `Idea.eval_timeout`. One hour preserves the\n # existing heavy/neural-net authoring example while staying well below the sandbox's defensive\n # 24-hour subprocess ceiling. This is operator-owned run config: agents may request less or more,\n # but the accepted action is always clamped here after the `agent_control.timeout` permission gate.\n max_eval_timeout: float = Field(default=3600.0, gt=0, le=24 * 3600.0)\n # Intra-node sweep: a sweep node runs a whole grid in one process, so it gets this multiple of\n # `timeout` as its wall-clock budget (solution.py path; RepoTasks use their per-profile timeout).\n sweep_timeout_mult: float = Field(default=8.0, ge=1)\n # Eval STALL watchdog CAP (seconds): a stage that is completely SILENT on stdout/stderr for this\n # long — while still alive and BELOW its wall-clock deadline — is tree-killed early with a STALLED\n # marker (a hung dataloader/deadlock dies in minutes instead of burning a multi-hour timeout). The\n # per-stage window is `min(this, the stage's own timeout)`, so a short scorer's deadline fires first.\n # `0` DISABLES the watchdog entirely (only the hard deadline applies) — for a legitimately quiet\n # non-Python stage (block-buffered stdout, a script that logs only to its own file). Threaded into\n # the eval and surfaced to the Developer so its code can emit periodic progress to stay alive.\n eval_stall_timeout_s: float = Field(default=1800.0, ge=0)\n # Sandbox tier (ADR-13): \"trusted_local\" (subprocess, no Docker) for the CLI;\n # \"untrusted\" (Docker --network none, shared-kernel runtime) for hosted/multi-tenant UI;\n # \"hostile\" (untrusted + a true-isolation OCI runtime, gVisor `runsc` by default / Kata) for\n # running untrusted code. See runtime/sandbox.py::make_sandbox for the tier selection.\n trust_mode: str = \"trusted_local\"\n # Image for the untrusted command-eval tier (RepoTask, Phase 4): the framework's deps\n # should be baked in (the container runs --network none, so a pip setup can't fetch).\n docker_image: str = \"python:3.12-slim\"\n # Resource caps for the untrusted/hostile Docker tier (passed to `docker run --memory/--cpus`).\n # The default 4g bound protects other tenants from an OOM, but a model-training MLE-bench eval\n # routinely needs more — raise it (e.g. \"16g\") for such runs. \"\" disables the cap (unbounded).\n # Ignored by the trusted_local (subprocess) tier.\n sandbox_memory: str = \"4g\"\n sandbox_cpus: str = \"\"\n # Best-effort host-OOM guard for the TRUSTED-LOCAL (subprocess) tier. When set (e.g. \"8g\"), each\n # eval child gets an RLIMIT_AS address-space cap so a runaway trainer hits MemoryError instead of\n # OOM-killing the whole host (and the engine). POSIX only; a no-op on Windows. Default \"\" = OFF:\n # RLIMIT_AS bounds VIRTUAL memory, and CUDA/torch reserve tens of GB of virtual without using it,\n # so a default cap would spuriously kill GPU evals — leave it off for those and use the Docker\n # tier's real --memory cgroup bound (sandbox_memory) instead. Ideal for CPU/numpy/sklearn runs.\n sandbox_memory_local: str = \"\"\n # Best-effort disk-fill guard for the TRUSTED-LOCAL tier (P1-5): an RLIMIT_FSIZE cap on the size of\n # any single file an eval child writes (e.g. \"2g\"), so a runaway that writes an unbounded file gets\n # SIGXFSZ instead of filling the host disk. POSIX only. Default \"\" = OFF (large model checkpoints\n # need big files); set it for tasks that write only small artifacts.\n sandbox_fsize_local: str = \"\"\n # Search policy (ADR-2): \"greedy\" | \"evolutionary\" | \"mcts\" | \"asha\" | \"bohb\".\n # `bohb` is a registered policy (`search/policy.py::_REGISTRY` aliases it to the ASHA factory,\n # which BOHB reuses as its racing half) and is advertised by `appconfig.render_template` and the\n # `bohb` template kind; this comment used to omit it and undersell the valid set. The value is\n # not validated at config time, but `make_policy` raises on an unknown name — loud-late rather\n # than silent, unlike `developer_backend` above.\n policy: str = \"greedy\"\n # Ablation-driven refinement (I7): every N improves, ablate the best to find the\n # highest-impact parameter and refine it. 0 = off. (GreedyTree only.)\n ablate_every: int = 0\n # A0a (MLE-STAR): when ablating, treat each generated pipeline *code block* (not just a\n # numeric param) as an ablation unit — neutralize a block, measure the metric delta, refine\n # the highest-impact block. Off => the classic numeric-param ablation. Config-first knob.\n ablate_code_blocks: bool = False\n # A0b: real merge/ensembling. \"mean\" = legacy mean-param merge; \"ensemble\" = the Developer\n # writes a code-recombination ensemble over the two parents' solutions (verified: agent-proposed\n # ensembling 37.9%->43.9%). \"auto\" (default) = ensemble whenever the Developer actually\n # generates code (LLM/agent backends; declared via `is_code_generating`), mean for the\n # templated/toy developers where a code ensemble is meaningless — the evidence says code\n # recombination is the strongest single merge (removing it costs ~9 pp), so it is the default\n # wherever it can work. Falls back to mean when the Developer can't ensemble.\n merge_mode: str = \"auto\"\n # A0d (AIRA): inject a dynamic complexity hint into the draft/improve prompt keyed on the\n # node's child count (few children -> keep minimal; many -> escalate to ensembling/HPO).\n complexity_cue: bool = False\n # I1 (CAAFE-style) LLM feature-engineering: instruct the proposer/developer to add\n # semantically-meaningful engineered features (ratios, interactions, aggregations) as code. The\n # existing cross-validation eval + best-selection is the CV GATE — a feature set that doesn't\n # improve CV is never selected (feature engineering is non-universal, so this gate is mandatory).\n # Tabular tasks (those exposing column data). Off by default.\n feature_engineering: bool = False\n # A5: surface the REMAINING eval-compute budget into the proposal prompt so the Researcher can\n # reason over how much compute is left (explore broad while flush, exploit/cheapen when low).\n # No-op unless a max_eval_seconds budget is set.\n budget_aware: bool = False\n # A4 (LATS-style): feed a summary of the most recent FAILED branches (operator + error reason)\n # back into the proposal prompt so the proposer reflects on and avoids repeating them. ON by\n # default: it is SELECTIVE by construction (injects only when recent failures exist — the\n # evidenced form of reflection; reflecting at every step shows no gains, arXiv:2506.12928).\n failure_reflection: bool = True\n # Feed the LIVE-WATCHDOG observations (train-monitor health verdicts + ASHA intermediate-rank flags)\n # of recent experiments back into the proposal prompt, so the Researcher avoids re-proposing a config\n # whose TRAINING was already observed to be weak — even when the watchdog kills are off (default) and\n # the node ran to completion (those diagnostics are fold-ignored, so failure_reflection never sees\n # them). ON by default and SELECTIVE like failure_reflection (injects only when a recent flag exists).\n watchdog_reflection: bool = True\n # C3 deep test-driven repair: hand the Developer the failure taxonomy + a structured \"reproduce\n # then fix\" directive on debug, not just the raw stderr tail. ON by default (product surface); the\n # conservative library default in EngineOptions stays off, per that module's contract.\n deep_repair: bool = True\n # === Inline crash repair ==============================================================\n # Hybrid in-node crash repair: when an LLM-generated node CRASHES at runtime (mechanical errors\n # — bad import, removed kwarg, typo), the agent triages it and may repair the code IN PLACE within\n # the same eval (cheap; does NOT consume max_nodes and does NOT add a node to the search tree).\n # Semantic failures still flow to a new idea/debug node via the policy. ON by default; set False\n # to restore the prior behavior (every crash waits for a budgeted inter-node debug node).\n inline_repair: bool = True\n # Max in-place repair attempts per node before it fails normally. Default 0 = UNLIMITED: the same\n # agent keeps fixing + re-evaluating IN THE SAME node until it produces a clean metric — no new\n # node just to retry. Runaway is prevented not by a count but by the anti-stuck guard\n # (`inline_repair_stuck_repeat`): when the SAME error signature repeats with no progress, or the\n # agent's crash-triage says \"abandon\", the node fails. Set a positive N to cap the retries instead.\n inline_repair_attempts: int = Field(default=0, ge=0)\n # Anti-stuck: abandon in-node repair when the SAME error recurs this many times in a row (no\n # progress). Keeps \"unlimited\" repair from looping forever on an unfixable error.\n inline_repair_stuck_repeat: int = Field(default=4, ge=2)\n # Which failure reasons (_failure_reason: crash|timeout|oom|setup|no_metric|drift) are eligible for\n # inline repair. Default: mechanical crashes, timeouts AND OOM-kills — a timeout/OOM means the code\n # was too slow / too memory-hungry for the budget (or the pod's cgroup limit), not that the idea is\n # wrong, so the agent repairs it by reducing compute/memory (fewer estimators/epochs/folds/seeds, a\n # lighter model, a smaller batch, subsampling) instead of letting the node die unrecovered. Env\n # override expects a JSON array.\n inline_repair_reasons: tuple[str, ...] = (\"crash\", \"timeout\", \"oom\")\n # Cost ceiling for in-node repair of a MULTI-STAGE eval: the max number of FULL pipeline re-runs\n # (i.e. re-trains from the first stage) the inline-repair loop may do before abandoning to the\n # inter-node debug operator. A late-stage failure (a broken `score`/eval script that didn't touch\n # `train`) REUSES the completed train checkpoint and re-runs only from the failed stage — that is\n # CHEAP and NOT counted here; only a repair that changes an EARLIER stage's code (train.py/loss.py/…)\n # forces a full re-train, which each costs the whole training time. Without this bound, a repair that\n # keeps changing training code could burn many full trains (the anti-stuck guard is error-signature\n # based, not cost based). 0 = unlimited (legacy behavior). Single-command evals ignore it.\n inline_repair_retrain_cap: int = Field(default=2, ge=0)\n # Environment self-prep: when a solution crashes purely because a KNOWN library isn't installed\n # (ModuleNotFoundError), the engine pip-installs it into the eval interpreter and re-runs — so a\n # torch/XGBoost/CatBoost idea (e.g. a GRU model) actually runs on a fresh box instead of being\n # thrown away as `idea_rejected`. Trusted_local tier only (the untrusted/hostile Docker tiers run\n # --network none). A typo'd or local helper module is NOT installed (that's a code bug to repair).\n auto_install_deps: bool = True\n # Per-package wall-clock budget for an auto-install (seconds). Generous: large wheels like torch\n # can take minutes to download/build.\n dep_install_timeout: float = Field(default=900.0, gt=0)\n # --- Agent governance: who may change a setting at runtime (per-setting allow-list of roles) ---\n # Roles: \"strategist\" (A7 meta-controller; run-wide), \"boss\" (run-chat operator-proxy; run-wide),\n # \"researcher\" (per-experiment proposer; PER-NODE — e.g. set a longer eval timeout for a neural-net\n # idea). A setting ABSENT from this map is LOCKED — no agent may change it, only a human via the\n # snapshot/UI. The sole conditional exception is Strategy-only `card_scoring`: Card mode grants\n # it implicitly without changing the default flag-off snapshot, while an explicit [] revokes it.\n # The gate is ENFORCED at every AGENT seam via `_agent_may` — the strategist's whole\n # control surface (policy/operators/novelty_stance/prefer_sweep/developer/fidelity/timeout/\n # eval_parallel/llm_parallel) is checked in `_apply_strategy`, so removing a role from a knob locks it,\n # not just greys out the UI pill. The boss/max_* grants document boss-relevant knobs, but a\n # `budget_extend` is a HUMAN control intent (the boss action-builder can only emit `add_nodes`), so\n # it is applied as-is — the human always wins via the UI/snapshot. Defaults are conservative: resource/\n # search-shape knobs the agents already reason about; provider infra (llm_model/base_url/api_key,\n # docker_image) stays locked. The UI shows S/B/R pills per setting. `timeout` granted to the\n # researcher IS the \"auto\" per-node mode — the researcher sizes each experiment's wall-clock\n # (Idea.eval_timeout); the config `timeout` is the fallback for nodes it doesn't size. Env override\n # expects a JSON object. (`fidelity`/`novelty_stance`/`prefer_sweep`/`developer` are governance keys\n # for the strategist's per-node dials — not 1:1 Settings fields, but gated the same way.) The default\n # lives in the module-level DEFAULT_AGENT_CONTROL so the Engine's direct-construction default matches.\n agent_control: dict[str, list[str]] = Field(default_factory=default_agent_control)\n # C1 (Agentless localization): for repo tasks, rank the source files most relevant to the most\n # recent failure (traceback + identifiers) and surface them in the proposal/repair prompt so the\n # Developer edits the right place. Off by default; repo tasks only.\n localize_faults: bool = False\n # A2 surrogate-guided proposal (BO-lite): fit a k-NN surrogate over (params->metric) and propose\n # by acquisition instead of random/hill-climb. Numeric-bounds tasks only; bootstraps via the\n # base Researcher. Off by default. `surrogate_explore` = UCB-style exploration weight.\n surrogate_proposer: bool = False\n surrogate_explore: float = 0.1\n # E2 researcher panel: generate K candidate ideas per proposal and keep the one ranked best by a\n # cheap empirical surrogate over the (params->metric) history (NOT an LLM-judge). 1 = off.\n researcher_panel: int = 1\n # A1 ASHA / successive-halving: reduction factor (keep top 1/eta per rung) and rung budget.\n asha_eta: int = Field(default=3, ge=2)\n # Rung-0 width for ASHA/BOHB (the wide base of cheap drafts). 0 = use n_seeds (default,\n # preserving prior behavior); set >0 to seed a wider/narrower base independent of n_seeds.\n asha_rung_nodes: int = Field(default=0, ge=0)\n # A6 proxy/predictive scoring: cheaply rank a candidate's potential from early-stage signals\n # and skip a full eval for the doomed bottom fraction. 0.0 = off (no candidate skipped).\n proxy_scoring: bool = False\n proxy_kill_fraction: float = Field(default=0.0, ge=0.0, le=0.9)\n # === Novelty / dedup ==================================================================\n # E1 novelty/dedup gate: reject near-duplicate proposals (normalized param-space distance below\n # `novelty_epsilon`) by deterministically nudging them off the duplicate — stops the search\n # wasting evals re-trying the same idea. Audit event `novelty_rejected`. Off by default.\n # Novelty/dedup MODE selector: how a fresh proposal is checked against already-tried experiments.\n # \"off\" — no explicit gate; the agentic Researcher's own read-the-history judgment is trusted.\n # \"algo\" — the deterministic gate: numeric param-distance (`novelty_epsilon`) + optional embedding\n # similarity (`novelty_semantic`). Cheap, no LLM call, but can't explain itself.\n # \"llm\" — an LLM adjudicates (reads the real experiments via tools) whether the idea is a\n # near-duplicate and, if so, asks the Researcher once more for something different. One\n # extra LLM call per proposal — highest quality, follows the \"let the LLM decide\" line.\n novelty_mode: str = \"llm\"\n # Legacy sub-toggles, honored by the \"algo\" mode (and back-compat: novelty_gate=True forces \"algo\"):\n novelty_gate: bool = False\n novelty_epsilon: float = 0.05\n # T5 semantic novelty (active whenever the deterministic gate runs — novelty_mode=algo,\n # novelty_gate=true which is the legacy alias for algo, OR the Strategist novelty stance is\n # \"explore\"; a no-op only under novelty_mode=llm/off with a non-explore stance): reject a proposal\n # whose idea TEXT embeds within\n # `novelty_semantic_threshold` cosine of an existing node's. OFF BY DEFAULT: novelty is the agentic\n # Researcher's call, made by READING the actual prior experiments (read_experiment /\n # find_analogous_across_runs / list_experiments), NOT an embedding-similarity auto-reject that can't\n # explain itself and mis-fires on paraphrases. Semantic/param search stays available as TOOLS in the\n # Researcher's context; the LLM decides whether an idea is a true duplicate.\n novelty_semantic: bool = False\n novelty_semantic_threshold: float = Field(default=0.92, ge=0.5, le=1.0)\n # T10: debug-lineage depth bound for every policy — how many error-feedback repairs a failing\n # lineage gets before it is abandoned. The old default (1) abandoned lineages after ONE repair;\n # multi-turn/deeper debugging is a verified lever (AIRA2 ReAct debug +5.5 percentile points).\n debug_depth: int = Field(default=2, ge=1)\n # P4 operator bandit (GreedyTree): replace the fixed merge/ablate cadences with a\n # deterministic UCB over per-operator yield (Δmetric per eval-second, folded from the run).\n # Off by default (the cadences are well-tested and the bandit has no direct published\n # ablation); `thorough` turns it on.\n operator_bandit: bool = False\n # P1 hypothesis ledger: ask the Researcher to state the one-line hypothesis each experiment tests,\n # register deep-research directions as hypotheses, and track them to a verdict on the board. ON by\n # default. The board is shown to the Researcher and can be foresight-ordered, so it can\n # steer later proposals even though it never directly re-ranks already-evaluated nodes. Set False\n # to drop the prompt nudge + registration.\n track_hypotheses: bool = True\n # E4/M2/M3 reflection-memory -> priors (gradient-free cross-run meta-learning): at run end distill\n # the winner into `<memory_dir>/meta_notes.jsonl` AND structured lessons (incl. NEGATIVE results:\n # tested/abandoned hypotheses + failure themes) into `lessons.jsonl` with a task fingerprint; at run\n # start, inject exact-task notes + fingerprint-matched lessons from SIMILAR tasks into the proposal\n # prompt. ON by default — but a NO-OP until `memory_dir` is set (that's where cross-run memory lives).\n reflection_priors: bool = True\n # M6 comparative lessons (MARS \"comparative reflective memory\", doc 13 §7 item 2): distill\n # credit-assigned lessons from PAIRS of solutions — which SPECIFIC change made a child beat\n # (or regress from) its parent, and what fixed a failure — on top of the one-shot run-end\n # reflection. At most one extra LLM call per distillation (all pairs batched); offline a\n # deterministic param-diff credit stands in. Gated on reflection_priors + memory_dir like the\n # rest of cross-run memory — and since memory_dir has a default (~/.looplab/memory), this is\n # ACTIVE out of the box; clear memory_dir to disable cross-run memory wholesale.\n comparative_lessons: bool = True\n # M6 mid-run cadences (doc 13 §7 item 5 — the AgentRxiv live-share pattern): every N created\n # nodes, WRITE comparative lessons to the shared cross-run store during the run (lessons_every\n # — one batched LLM call per firing) and RE-READ the store so lessons distilled by CONCURRENT\n # runs reach this run's proposals (lessons_refresh_every; file re-read, no LLM call, skipped\n # when the store is unchanged). ON by default (every 4 nodes) and, like comparative_lessons\n # above, live out of the box because memory_dir defaults on; 0 = off — run-end write /\n # run-start read only, the pre-M6 behavior.\n lessons_every: int = Field(default=4, ge=0)\n lessons_refresh_every: int = Field(default=4, ge=0)\n # B3 output redaction: mask credentials (known key shapes + high-entropy tokens) in the\n # stdout/stderr tail before it is persisted to the event log / spans / UI — a leaked secret in a\n # print()/traceback must not land in the durable log. Off by default; recommend on for untrusted.\n redact_output: bool = False\n # B5 reward-hacking detector: a host-side monitor that flags suspicious wins (grader/answer-key\n # access, runtime writes to frozen files, suspiciously-perfect metrics) as a `reward_hack_suspected`\n # audit event in the Trust panel. Off by default. Whether a flag CHANGES selection is governed by\n # `trust_gate` below (default: audit-only, never changes selection).\n reward_hack_detect: bool = False\n # Trust enforcement (T2): what a reward-hack / data-leakage flag actually DOES to selection.\n # \"audit\" (default) = surface it in the Trust panel, never change selection (today's behavior);\n # \"gate\" = a flagged node can no longer be selected as BEST **and isn't bred/confirmed from**\n # (breed_excluded — see replay._apply_trust_gate / models.breedable_nodes), but stays\n # FEASIBLE so it still counts for diversity/audit — closes the \"a hacked/leaky node can\n # win OR seed its lineage\" hole;\n # \"block\" = additionally mark it fully INFEASIBLE (removed from feasible_nodes entirely).\n # Deliberately gates only high-precision CHEATING/LEAKAGE signals. Broad `critic:*` findings stay\n # advisory; `critic:hardcoded_metric` is the one narrow high-precision critic exception\n # and gates because it requires a literal metric with no computed assignment anywhere.\n trust_gate: str = \"audit\"\n # I3 data-centric: static code-leakage scan of each evaluated solution (fit-before-split,\n # fit-on-test) surfaced into the Trust panel alongside reward-hack flags. Off by default.\n code_leakage_detect: bool = False\n # 4.4 sandbox instrumentation (needs reward_hack_detect): after each eval, flag a RUNTIME write\n # to a protected/frozen file (an answer key or grader tampered mid-run) — behavioral evidence a\n # static code scan misses. ON by default; only fires when the reward-hack detector is on.\n workdir_audit: bool = True\n # D8 decoupled research Verifier: check every Deep-Research memo's CLAIMS against their cited\n # evidence (node ids / urls) before recording — synthesis is the documented weak link (Kosmos\n # 57.9% accurate). Deterministic layer always; an LLM rubric pass when the DeepResearcher has a\n # client. The verdict cannot change this run's node/champion, but finalize uses aligned supported\n # verdicts as the evidence gate for persisted D8 cross-run claims; unavailable/misaligned stays\n # unverified. ON by default (no-op with no memo/claims).\n research_verify: bool = True\n # C4 independent critic: an execution-free critic of each solution (stub / hardcoded-metric /\n # params-ignored; on host-graded tasks the metric checks become a submission-output check)\n # surfaced in the Trust panel. Broad findings are advisory; `critic:hardcoded_metric` can gate under\n # trust_gate=gate/block. Off by default.\n critic_check: bool = False\n # A7 Strategist (NEW, user-requested): optional LLM/rule meta-controller that picks the search\n # policy/allocator + operator mix + fidelity (+ Developer backend) per situation. Config-first:\n # every choice the Strategist makes is also a direct knob. Defaults to \"agent\" so the agent adapts\n # the search policy/operators/fidelity per situation; set \"off\" for static config-driven search.\n # \"agent\" = a tool-using Strategist that READS the run/data/sibling-runs/KB/memory (B1-guarded)\n # before deciding, instead of the single-shot \"llm\" call over aggregate stats.\n strategist_backend: str = \"agent\" # \"off\" | \"rule\" | \"llm\" | \"agent\" — default AGENTIC: the\n # Strategist READS the run/data/sibling-runs/KB/memory with tools before deciding, not a single-shot\n # call over aggregate stats. \"llm\" = the old non-agentic single-shot; \"rule\"/\"off\" = no LLM.\n strategist_every: int = Field(default=3, ge=1) # consult cadence (created nodes)\n # PART V (F1): concept CLASSIFIER re-tag + consolidation cadence, DECOUPLED from strategist_every. The\n # LLM concept map is heavier and slower-moving than a strategy consult, so it refreshes on its own\n # (sparser) interval. Researcher-authored idea.concepts still fold immediately at node_created — this\n # only paces the classifier-evidence + consolidation refresh (and the concept-coverage pivot snapshot).\n concept_retag_every: int = Field(default=30, ge=1)\n # === Confirmation & holdout ===========================================================\n # Multi-seed confirmation (I12, ADR-15): confirm the top-k under N seeds before\n # finishing. 0 disables (default). Only meaningful when eval has variance.\n confirm_top_k: int = 0\n confirm_seeds: int = 0\n # D1 seed-holdout discipline: the FIRST seed the confirm phase uses. Search evals run with the\n # implicit seed 0 (LOOPLAB_EVAL_SEED unset -> \"0\"), so a base of 1 keeps every confirm split\n # DISJOINT from anything the search optimized against — the confirm metric is then a\n # generalization signal, not a re-measurement (AIRA: selecting on a signal the search saw\n # overfits by 9-13 pp). Set 0 to restore the legacy overlapping seeds 0..N-1.\n confirm_seed_base: int = Field(default=1, ge=0)\n # D1 holdout-gated promotion (B6, Arbor-style): for host-graded tasks, reserve this fraction of\n # the held-out labels as a FINAL holdout partition the search never sees — every search/confirm\n # eval is scored on the remaining rows only, and at finish the val-top-k are re-scored on the\n # holdout partition (free: the predictions already exist). 0.0 = off (legacy full-label scoring).\n holdout_fraction: float = Field(default=0.25, ge=0.0, le=0.9)\n # Champion selection prefers the HOLDOUT metric among the nodes that have one (the val-top-k).\n # Recorded in run_started so replay applies the same rule; old logs fold to False (legacy pick).\n # The per-node val-holdout `generalization_gap` folds into the Trust panel either way.\n holdout_select: bool = True\n # How many val-leaders get a holdout evaluation at finish (host-graded tasks).\n holdout_top_k: int = Field(default=3, ge=1)\n # R1-c / Part IV — calibrated §12-verifier tie-break in best-selection. When a fresh node's robust\n # metric EXACTLY TIES an already-eligible node, the engine runs the §12 verifier (grounded on each\n # node's realized result, `selection_criteria`) and records a soundness score per node. The tie-break\n # then applies across the WHOLE selection path: the mean pick breaks a robust_metric tie by the score,\n # and — when `holdout_select` is on — the holdout-slot ranking prefers the sounder node so it isn't\n # denied a holdout eval, and the final holdout override breaks a holdout_metric tie by it too. Strictly\n # a TIE-BREAK — it can NEVER move a node ahead of a strictly-better metric (§21.7 advisory-never-\n # overrides). Best-effort: verification is LAZY (only fires on an actual tie) and bounded per cadence,\n # so a large/late tie stays on deterministic metric+id order unless one atomic treatment scores every\n # member. Recorded in run_started so\n # replay applies the same rule; old logs fold to False (legacy pick). Opt-in (default off); needs a\n # reachable LLM client. Enable only after validating calibration offline with `verifier.calibrate`.\n # See trust/verifier.py + core/fitness.py.\n select_verifier: bool = False\n # R1-d (§21.19): widen the verifier tie-break from EXACT-metric to a conservative STATISTICAL tie.\n # The band is capped by both the metric leader's SE and the pooled SE-of-difference, so challenger\n # variance cannot widen it and a significant difference is never a tie (§21.7). Off -> exact only.\n verifier_ci_tie: bool = False\n # Verifier sample count for `select_verifier` (the §12 repeated-sampling expectation). 3 tames the\n # single-shot variance; 1 = single-shot (cheaper, noisier).\n select_verifier_samples: int = Field(default=3, ge=1, le=32)\n # Budget (I13): hard wall-clock ceiling; the run aborts cleanly when exceeded.\n max_seconds: float | None = None\n # Eval-compute budget (#2): hard ceiling on cumulative time spent INSIDE evals (training\n # runs), separate from wall-clock. Survives resume (summed from the event log). The real\n # guard against a silent multi-hour sweep when an eval is a minutes-hours training run.\n max_eval_seconds: float | None = None\n # Cross-run memory (I19, ADR-10): if set, the best result of each run is stored as\n # a case here, and the cases become retrievable knowledge for future runs.\n memory_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"memory\"))\n # HITL (I21, ADR-11): pause for human approval of the final best before finishing.\n require_approval: bool = False\n # Diversity archive (I22): niche bucket width in parameter space.\n archive_resolution: float = 1.0\n # Coverage read-model (narrowing signal): at the strategist cadence, compute a breadth summary\n # (themes / param-niches / theme entropy / dominant-theme fraction) from the folded run, record\n # it as a `coverage_snapshot` audit event, and surface it into the Strategist's decision context.\n # Deterministic, cheap, and purely additive context — no search-behavior change on its own. On by\n # default (like the rest of the always-on situational context). See search/coverage.py.\n coverage_context: bool = True\n # PART IV Phase 2a live steering (§21.11/§21.13). When on, the `concept_retag_every` cadence (NOT\n # `strategist_every` — the producer gates on `_should_consult_concepts`, which uses the seed boundary\n # + `concept_retag_every`, default 30) records a\n # concept-graph coverage + uncovered-region snapshot (deterministic heuristic tagger over the\n # task-type skeleton) and, on an `explore` stance, the Researcher's novelty hint names the exact\n # uncovered regions (\"0 coverage in {negatives/external-mining, distillation} — go there\") instead\n # of the vague \"broaden\". ON by default in the product Settings (ce4a379), EngineOptions off: it\n # only enriches an already-mode-gated explore hint, never forces exploration, and no-ops for a task\n # with no curated concept skeleton. The snapshot is auditable, but its prompt cue can change future\n # candidate generation; it does not directly rewrite best-metric selection. See search/concept_graph.py.\n # DESIGN NOTE (2026-07-17 critique): now ON unconditionally, but a toy / non-ML task yields EMPTY concept\n # tags while still paying the per-node LLM concept cost (~10 calls/node with the full Part IV/V bundle).\n # Consider AUTO-QUIESCING: if the heuristic tagger returns empty for the first N nodes (no concept signal),\n # skip the LLM concept work for the run — keeps it on for ML tasks, free on toy/degenerate ones.\n concept_pivot: bool = True\n # PART IV Phase 2b — D3 graded novelty into the LIVE novelty gate (§21.4/§21.13). When on and the\n # task has a curated concept skeleton, the novelty gate first grades a proposal over the concept\n # graph: a level-4 \"same direction, DIFFERENT implementation\" or a level-5 \"re-opens a\n # wrongly-abandoned FAILED direction\" proposal is ALLOWED through unchanged — the flat LLM/semantic\n # dedup gate can't tell \"this DCL tweak\" from \"the whole DCL branch\" and would wrongly reject a\n # legitimate variant or never re-open a sound-but-killed direction (the node_63 archetype). Levels\n # 1/2/3 (identical / near-dup / prior-run) still fall through to the existing gate. Deterministic\n # (heuristic tagger, no LLM), audit event `novelty_graded`, replay-safe. ON by default in the product\n # Settings (ce4a379), EngineOptions off; no-ops for a task with no skeleton. See search/graded_novelty.py.\n graded_novelty: bool = True\n # PART IV Phase 2b — D7 capability-expansion forced-jump DIRECTIVE (§21.8/§21.13, issue #7). When on\n # and the concept-graph cadence detects action-space LOCK-IN (the search has stayed inside one D5\n # branch for a long consecutive streak) on an `explore` stance, the Researcher's novelty hint\n # ESCALATES from \"broaden\" to \"expand the ACTION SPACE — build the missing infra (external mining,\n # a new data pipeline, a different eval), do NOT swap another {locked} lever.\" The same\n # gate also stamps the proposal's authoritative operator as `expand`; that node competes normally\n # under SearchFitness and its separate operator yield is measured. It does not force a winner.\n # Reads the 2a concept-coverage snapshot, so it needs `concept_pivot` to record one. OPT-IN\n # (default off). See engine/proposal_cues.py + search/lock_in.py.\n capability_expansion: bool = False\n # PART IV cross-run Step 0 (§21.20.12/§21.20.13). Universal task-fingerprint tokenization: drop the\n # ASCII-only `[a-z0-9]` allowlist on goal keywords so a non-Latin goal (Russian, CJK, …) is NOT\n # silently dropped from its cross-run fingerprint (today such a goal keys only on kind/dir/metric and\n # never reaches SIMILAR-task priors/lessons/cases). Uses `[^\\W_]+`/`.casefold()` — same splitting as\n # before, any script. ON by default in the product Settings (ce4a379); the bare-library EngineOptions\n # default stays off, so a run pinned to it won't silently re-key a portfolio mid-flight. See engine/memory.py.\n fingerprint_universal: bool = True\n # PART IV cross-run Step 2 (§21.20). Fill graded-novelty's `prior_concepts` from the cross-run\n # ConceptCapsuleStore: at run end write a per-run concept capsule (the shipped `node_concepts` tags +\n # best outcome, keyed by task_fingerprint) to `memory_dir`; when a later SIMILAR run proposes an idea\n # whose concept was tried before, SURFACE the prior outcome as a `cross_run_prior` audit event (never\n # reject — D3 level 3). Audit-only, off the selection path; ON by default in the product Settings\n # (ce4a379), EngineOptions off. Needs a `memory_dir` to share capsules.\n # See engine/memory.py (ConceptCapsuleStore) + engine/novelty.py.\n # PREREQUISITES (this flag is NOT standalone): the WRITE needs per-run concept tags\n # (`node_concepts`, produced by `concept_pivot`/F1 tagging) — no tags => no capsule; the SURFACE path\n # runs inside the graded-novelty precheck, so it also needs `graded_novelty` on. With only this flag +\n # `memory_dir` it persists/surfaces nothing. Enable `concept_pivot` + `graded_novelty` alongside it.\n # A non-Latin (Russian/CJK) portfolio should ALSO set `fingerprint_universal`, else the capsule's goal\n # tokens are dropped and its fingerprint over-matches on kind/dir/metric alone (the novelty direction\n # gate narrows this, but goal-word similarity is still lost).\n cross_run_concepts: bool = True\n # PART V (B): run-base + node-DELTA concept authoring. When on, once the first evaluated node has\n # authored concepts the engine seeds RunState.run_base_concepts from them (a one-shot EV_RUN_CONCEPTS,\n # idempotent because it only fires while the base is empty), and the Researcher's proposal hint tells it\n # to author only `concepts_added`/`concepts_removed` vs the run base + its parent — minimal per-node\n # annotations that inherit down the DAG (a swap propagates). Off -> every node authors its full concept\n # set as before. Product-on; the bare EngineOptions mirror defaults off (test_options_divergence).\n concept_run_base: bool = True\n # PART IV cross-run Step 5 advisory (§21.20.5). Fold the bounded cross-run CONTEXT PACK — evidence-\n # grounded claims with BOTH support and counter-evidence (Step 4) + a portfolio-coverage line (Step 3) —\n # into the Researcher's proposal prompt, exactly like the E4 cross-run prior note. Advisory ONLY: it is\n # prompt-grounding: it can change the Researcher's later candidates, but does not directly rewrite\n # best-metric selection (§21.7). Reads `memory_dir` (lessons.jsonl + concept_capsules.jsonl); \"\" when\n # empty. ON by explicit experimental product choice; no frozen A/B validation is claimed. The\n # bare-library EngineOptions default stays off (engine/options.py, frozen in\n # test_options_divergence). See engine/proposal_cues.py.\n cross_run_advisory: bool = True\n # PART IV cross-run §21.20.13 (full CR of the lean fuzzy claim merge). Switch the claim read-model to the\n # SCOPE+POLARITY-safe STRUCTURED claim key (engine/claim_key.py): claims from different tasks never\n # merge, opposite polarity (\"X helps\" vs \"X never helps\") is surfaced as a CONTRADICTION instead of being\n # collapsed, paraphrase/inflection variants group by exact structured key (no transitive over-merge), and\n # operator governance is scope-precise (a decision in task A cannot reach a same-worded claim in task B).\n # Affects the `cross_run_advisory` context pack only; ON by default in the product Settings (ce4a379);\n # the bare-library EngineOptions default stays off (engine/options.py). See engine/claims.py.\n cross_run_structured_claims: bool = True\n # PART IV cross-run §22.4 (AGENTIC taxonomy steward). At finalize, when an LLM client is available, let\n # the concept steward (engine/concept_steward.py) review the freshly-updated portfolio concept graph and\n # PROPOSE a curation — merge duplicate slugs / split conflated ones / purge noise. Proposals are LOGGED to\n # `concept_curation_log.jsonl` for operator ratification (via the serve/CLI governance surface); the LLM\n # never mutates fold state. The calls run synchronously during finalize, so they add latency and may add\n # paid inference; a caught steward failure does not prevent terminal completion or trigger a retry.\n # ON by explicit experimental product choice when `memory_dir` + an LLM backend are present; the\n # bare-library EngineOptions default stays off (engine/options.py). See engine/lessons.py.\n cross_run_curation: bool = True\n # Deprecated compatibility flag. Steward output is untrusted and finalize is proposal-only regardless\n # of this value; retained so old snapshots still validate and logs can disclose that auto was requested.\n cross_run_curation_auto: bool = False\n # Role backend (ADR-7/14): \"toy\" (offline optimizer) | \"llm\" (live model). Validated in\n # `_check_trust_gate` alongside the other enum-ish strings — every consumer tests `== \"llm\"`\n # exactly (cli/__init__.py, adapters/tasks.py), so an untyped `--set`/file/env typo (or a mis-cased\n # \"LLM\") used to fall through to the OFFLINE toy roles and quietly produce a run that never called\n # the model. Left as `str`, not `Literal`, so old snapshots still validate through the same path.\n backend: str = \"toy\"\n # Developer backend (ADR-7): \"default\" (templated/LLM from the task) or an external\n # CLI coding agent: \"opencode\" | \"aider\" | \"goose\" | \"continue\". A CLOSED enum (\"default\" +\n # cli_agent.PRESETS keys), validated loudly in `_check_trust_gate` — an unknown value used to reach\n # adapters/tasks.py (`developer_backend not in PRESETS`) and silently wire the DEFAULT in-house\n # developer, the same silent downgrade the `backend` guard closes.\n developer_backend: str = \"default\"\n # C2 best-of-N: generate N candidate implementations per node and keep the best by an\n # execution-free reward (static validity + metric-print). 1 = off. In-house LLM developer only\n # (not external agents — ADR-7 cost rule). The top SWE-bench reliability lever for weak models.\n best_of_n: int = 1\n # D10 list-wise best-of-N selection: when the top static-scorers TIE, break the tie with a\n # comparative LLM selection over the candidates presented together (+~3 pts vs pointwise on\n # GAIA, arXiv:2506.12928). The static score stays the primary filter — the LLM is a weak\n # comparative prior, never the sole oracle. ON by default (no-op when best_of_n==1).\n best_of_n_listwise: bool = True\n # === Foresight / predict-before-execute ===============================================\n # FOREAGENT predict-before-execute (arXiv:2601.05930): use the LLM as an IMPLICIT WORLD MODEL to\n # predict — WITHOUT executing — which candidate / hypothesis will score best, primed with a\n # Verified Data Analysis Report (data profile / brief) + the accumulated experiment memory. Master\n # switch, ON by default; a no-op wherever there is nothing to rank (best_of_n==1 and\n # foresight_panel==1) or no LLM client. See `looplab/search/foresight.py`.\n foresight: bool = True\n # Hypothesis foresight breadth: generate K candidate ideas per proposal and keep the one PREDICTED\n # best pre-execution by the world model — it ranks the STRUCTURAL / text ideas the numeric k-NN\n # surrogate (researcher_panel) is blind to, primed with the data profile + memory (the synergy).\n # >1 enables it (LLM backend only; needs `foresight` on); 2 = on by default at modest cost, 1 = off.\n foresight_panel: int = 2\n # AGENTIC foresight: run the ranking (hypothesis-board prioritization + K-idea pick) as a TOOL-USING\n # loop that can pull actual experiment results / data facts before deciding, instead of a one-shot\n # prediction from a pre-baked report. ON by default (needs foresight + a client; falls back to the\n # one-shot predictor on any hiccup). A few extra LLM calls per proposal; set False for the cheaper\n # single-call ranker.\n foresight_agentic: bool = True\n # §1 foresight confidence gate: the minimum predicted confidence at which a predict-before-execute\n # pick is ACTED ON. Below it the ranker abstains — the K-idea panel falls back to the first\n # proposal, best-of-N falls through to the D10 tie-break — instead of committing a low-confidence\n # choice. 0.0 (default) = OFF (act on every pick, the historical behavior); raise toward ~0.5 to\n # make the world model defer when it isn't sure. Pairs with the foresight track record (§1) the\n # predictor is now primed with. Range 0.0-1.0.\n # BOUNDED like its confidence/fraction siblings (train_monitor_kill_confidence,\n # asha_live_quantile). Unbounded, a typo'd 5.0 validated silently into a gate no score can ever\n # clear, so every foresight pick abstained forever with nothing in the snapshot to say why.\n foresight_min_confidence: float = Field(default=0.0, ge=0.0, le=1.0)\n # PART IV Phase 2c — replace the world model's SELF-REPORTED confidence (measured Pearson≈0 with the\n # realized outcome, §21.12) with a CALIBRATED §12-verifier score. When on and a client is available,\n # after the K-idea ranker picks the predicted-best candidate the §12 verifier scores it (grounded +\n # repeated + criteria-decomposed: `foresight_criteria` — likely to improve the objective, and\n # sound/feasible) and THAT calibrated score becomes the confidence the `foresight_min_confidence` gate\n # and the telemetry use. Degrades to the self-reported confidence without a client or on any verifier\n # error (the telemetry records which source was used). ON by default in the product Settings (ce4a379),\n # EngineOptions off; a few extra LLM calls per acted-on proposal. See looplab/trust/verifier.py + search/foresight.py.\n # REVISIT (cost/quality, not yet resolved): a live classification run measured ~10 LLM calls & ~3.8 min/node\n # on deepseek-v4-flash with the full Part IV/V default bundle; the calibrated verifier is part of that per-node\n # tax. Kept ON deliberately — it raises decision quality (self-reported foresight confidence has ≈0 Pearson\n # with realized outcome), and lowering quality to save calls is the wrong trade. TODO: run a frozen quality/cost\n # A/B (verifier-on vs -off) to quantify the recall@objective gain per extra call before considering a default flip.\n foresight_verify: bool = True\n # Verifier sample count for `foresight_verify` (the §12 repeated-sampling expectation on a no-logprob\n # backend). 3 tames the single-shot variance §21.12 measured; 1 = single-shot (cheaper, noisier).\n # RESUME-critical: this value is persisted in config.snapshot.json, so a hard `le` schema bound would\n # make an old run started with a larger LOOPLAB_FORESIGHT_VERIFY_SAMPLES un-resumable (Settings(**snapshot)\n # would raise on `looplab resume` / finalize-recovery / serve replay). CLAMP into [1, MAX] via a validator\n # instead of rejecting — the runtime cap is preserved without breaking the snapshots/env-compat rule.\n foresight_verify_samples: int = 3\n\n @field_validator(\"foresight_verify_samples\")\n @classmethod\n def _clamp_foresight_verify_samples(cls, v: int) -> int:\n # Coerce a persisted/env value into [1, MAX] rather than reject (see field note above).\n try:\n v = int(v)\n except (TypeError, ValueError):\n return 3\n return max(1, min(v, MAX_FORESIGHT_VERIFY_SAMPLES))\n\n # T7 LLM response cache: serve an identical DETERMINISTIC (temperature 0) request from an\n # in-process content-addressed cache instead of re-hitting the model — cuts cost on\n # retry/panel/verify flows. NEVER caches sampling calls (temp>0: best-of-N / panel / novelty\n # re-propose must vary). Off by default (most role calls run at temperature>0 anyway).\n llm_cache: bool = False\n agent_cmd: str | None = None # override the agent's launcher (path / wrapper)\n # External-agent validation (ADR-7): wrap a CLI-agent Developer with a validator that\n # audits each output (no-op/syntax/crash/timeout), retries with feedback, and falls\n # back to the in-house LLM Developer. Off -> use the raw agent output unchecked.\n validate_agent: bool = True\n agent_max_retries: int = 1 # re-prompts of the agent on an invalid result\n # Patch-gated multi-file agent (ADR-7 Rule 3): run the CLI agent in a git worktree and\n # accept only edits whose paths match `agent_surface` (reject-not-strip out-of-surface\n # touches). Lets the agent create helper modules, not just solution.py. Degrades to\n # whole-file readback if git is unavailable.\n agent_patch_gate: bool = True\n agent_surface: list[str] = [\"*.py\"] # edit-surface allow-list (globs)\n # Trust policy for an agent-authored eval/metric adapter (RepoTask onboarding, Phase 3):\n # \"ratify_freeze\" waits for human approval, \"autonomous\" accepts the proposed spec without that\n # gate, and \"ratify_freeze_drift\" also enforces the configured independent drift cross-check.\n eval_trust_mode: str = \"ratify_freeze\"\n # RepoTask node seeding policy (run-wide fallback; a task/editable `seed_mode` overrides):\n # \"auto\" (default) copies git-tracked files when the editable is a git repo (so a tree bloated\n # with untracked artifacts isn't deep-copied per node), else a full copy; \"tracked\" forces\n # code-only; \"all\" forces a full recursive copy (legacy). Genesis authors per-task from the\n # user's words; this is the global default when unspecified.\n seed_mode: str = \"auto\"\n llm_model: str = \"qwen3:8b\"\n # === LLM / transport ==================================================================\n llm_base_url: str = \"http://localhost:11434/v1\" # Ollama OpenAI-compatible endpoint\n llm_temperature: float = 0.6\n # H3 per-role model presets (5090 recipe): optionally run the Researcher and Developer on\n # DIFFERENT models/endpoints (e.g. Developer=Qwen3-Coder-30B for code, a fast model for breadth).\n # Blank = use the shared llm_model/llm_base_url. Generalizes the role-backend swap to per-role.\n researcher_model: str | None = None\n developer_model: str | None = None\n researcher_base_url: str | None = None\n developer_base_url: str | None = None\n # Per-role sampling temperature (§4.1): the Researcher wants breadth (higher temp = more diverse\n # ideas), the Developer wants determinism (lower temp = fewer syntax slips), the Strategist a\n # steady hand. Each overrides the shared `llm_temperature` for that role only; None = use the\n # shared value (byte-identical to before). Flat + nullable, mirroring the per-role model fields.\n researcher_temperature: float | None = None\n developer_temperature: float | None = None\n strategist_temperature: float | None = None\n # The Strategist had per-role TEMPERATURE but no model/endpoint of its own, so in split mode it\n # always ran on the shared llm_model however the other roles were pointed. These close that gap\n # with the same names/shape as the pair above; blank = its profile, else the shared value.\n strategist_model: str | None = None\n strategist_base_url: str | None = None\n # === Connection profiles (multi-provider) =============================================\n # A \"profile\" is a NAMED connection: {model, base_url, temperature, api_key_env, provider}. Roles\n # point at one by name through `role_profiles`; `llm_profile` is the default for every role that\n # doesn't. All three are EMPTY by default and the resolver short-circuits to the shared\n # llm_model/llm_base_url/llm_api_key path when they are — an operator running one model never\n # needs to know the word \"profile\".\n #\n # A profile stores `api_key_env`, the NAME of an environment variable, never the key itself. That\n # is ADR-11's \"a secret is a reference, not a value\" turned from a convention into a data type:\n # the whole profile map is then safe to write into config.snapshot.json, hand back over HTTP and\n # render into LOOPLAB_* — there is nothing in it to mask. It is also what makes per-role\n # CREDENTIALS expressible at all: two roles on one provider can carry different keys, which a\n # key-per-endpoint map could not say.\n llm_profiles: dict[str, dict] = Field(default_factory=dict)\n llm_profile: str | None = None\n role_profiles: dict[str, str] = Field(default_factory=dict)\n # Unified self-driving agent: one LLM identity that plays Researcher + Developer (+ Strategist)\n # across pipeline stages, choosing its own model/toolset per stage. ON by default — the agent\n # drives the loop (incl. crash triage: repair/abandon/reject_idea) and, via\n # `agent_drives_actions`, picks the next macro action within a pure legal-action gate. Set both\n # to False for the legacy byte-identical split-role behavior. (No-op unless backend=\"llm\".)\n unified_agent: bool = True\n agent_drives_actions: bool = True\n # Layer 3 Card queue ownership. False preserves the existing policy/unified-pilot action path.\n # True is pinned in run_started because changing the selector on resume would mix two search\n # treatments in one run. When both this and agent_drives_actions are true, Card selection wins.\n card_driven_selection: bool = False\n # Layer 5 bounded speculative Card buffer. Zero is a hard OFF switch and preserves the\n # historical alternating build/eval spine; positive values are pinned by run_started so a\n # resume cannot silently mix search treatments after a snapshot/default edit.\n speculation_depth: int = Field(default=0, ge=0, le=64)\n # Local evidence receipt produced by ``looplab speculation-gate``. This is deliberately absent\n # from the curated Settings UI: positive-depth execution is a calibrated rollout decision, not a\n # casual tuning knob. The Engine validates the receipt against the current scorer,\n # implementation, GPU inventory and raw paired-run evidence before it enables speculation.\n speculation_gate_receipt: str | None = Field(default=None, max_length=4096)\n # Per-stage model/endpoint overrides for the unified agent. Recognized keys (see\n # tasks.build_unified_agent): `propose` (researcher), `implement`/`repair` (developer),\n # `strategy`, `pilot`. Unlisted keys are ignored. Empty = the per-role researcher_*/developer_*\n # models, then the shared llm_model. Explicit map wins. No-op unless `unified_agent` is true.\n agent_stage_models: dict = {}\n agent_stage_base_urls: dict = {}\n llm_parser: str = \"tool_call\"\n # Reasoning/thinking toggle sent IN the request (providers differ — see `llm.reasoning_body`):\n # Qwen3 on vLLM/SGLang -> chat_template_kwargs.enable_thinking (bool)\n # OpenAI/Ollama-v1/DeepSeek -> reasoning_effort (low|medium|high|none)\n # `llm_reasoning`: \"\" = send nothing (server default — current behavior); off = actively disable;\n # on = enable at default depth; low|medium|high = enable at that effort.\n # Defaults to \"high\" so the agent reasons before proposing/repairing/triaging (better code, fewer\n # crashes); set \"\" to send nothing (server default) or \"off\" to disable. The model's chain-of-thought\n # is CAPTURED either way (reasoning_content field or inline <think>). NOTE: to get a SEPARATE\n # reasoning_content field from SGLang/vLLM, the server also needs `--reasoning-parser qwen3`.\n llm_reasoning: str = \"high\"\n llm_reasoning_style: str = \"auto\" # auto | qwen | effort | none — how to shape the request param\n llm_reasoning_extra: dict = {} # raw fields merged into the body (escape hatch, e.g. Anthropic thinking)\n # H1: drive structured calls with the endpoint's constrained decoding (vLLM/SGLang guided_json +\n # response_format json_schema) so weak models can't emit invalid JSON. Off for Ollama (default).\n llm_guided_json: bool = False\n # Stream every LLM request (SSE) and reassemble it, so the request `timeout` is an INTER-TOKEN\n # idle timeout rather than a whole-request deadline: a long-but-alive generation is never cut off\n # (tokens keep resetting the timer), while a genuinely STALLED endpoint (no token for `timeout` s)\n # is detected and retried on a fresh connection — the fix for silent multi-minute hangs. Set False\n # to use one blocking read (old per-op timeout semantics) if an endpoint streams badly.\n llm_stream: bool = True\n # LLM IDLE timeout (seconds). Stream mode: the INTER-TOKEN stall limit — a steady generation is\n # never cut off, only a silent endpoint. Non-stream: bounds the whole read. Raise for endpoints\n # with a long prefill on huge prompts; lower to fail fast on a flaky shared endpoint.\n llm_timeout: float = Field(default=180.0, gt=0)\n # First-byte (response-headers) window for STREAM attempts, seconds: an SSE response sends its\n # headers on admission, so no-headers ≈ a black-holed request — fail over to a fresh connection\n # fast instead of waiting the full idle timeout. Clamped to llm_timeout. Non-stream attempts are\n # NOT bounded by this (their headers arrive only after the whole generation). The default mirrors\n # `DEFAULT_HEADER_TIMEOUT_S` in looplab/core/llm.py is the single source (imported above).\n llm_header_timeout: float = Field(default=DEFAULT_HEADER_TIMEOUT_S, gt=0)\n # Honour HTTP(S)_PROXY / NO_PROXY / SSL_CERT_FILE env vars for LLM calls. Default False: the\n # transport connects DIRECTLY (many internal/OLAP endpoints are reachable only by bypassing an\n # ambient proxy — a picked-up proxy yields \"connection refused\"). Set true when the endpoint is\n # reachable ONLY via a corporate proxy or needs a custom CA bundle from the environment.\n llm_trust_env: bool = False\n # H4/C2: compact the growing agentic tool-call history once it exceeds this many chars (auto_summary\n # LLM-summarizes the stale middle; else middle-truncate). Sized to the model's real context window so\n # files read earlier STAY in context instead of being compacted away and re-read: ~1,000,000 chars ≈\n # 250k tokens (≈4 chars/token), matching the configured deepseek-v4-flash (≥200k tokens confirmed).\n # The old 120k-char (~30k-token) default was ~8× too aggressive and drove a read-thrash loop. 0 = off.\n context_budget_chars: int = 1_000_000\n # M5: the Researcher's always-on experiments digest budget (chars). 0 = AUTO — scales with the\n # run size (60 chars/node, bounded [1200, 6000]) instead of one flat cap for an 8-node toy run\n # and a 200-node benchmark run alike. Set a positive value to pin it.\n digest_char_cap: int = Field(default=0, ge=0)\n # D11 compression model slot (open_deep_research's four-slot pattern): a dedicated CHEAP model\n # for agent-history summarization (C2 auto-summary), so compression doesn't pay the main\n # model's price. Blank = use each loop's own model (legacy). `compressor_base_url` blank =\n # the shared llm_base_url.\n compressor_model: str | None = None\n compressor_base_url: str | None = None\n # === Agentic tool-loop ================================================================\n # Agentic tool-loop limits — apply to EVERY tool-using agent (the LLM Researcher, the unified\n # agent's pilot + crash-triage, Deep-Research, the run-chat Boss, the genesis repo-scout, and the\n # cross-run report synthesizer). The loop lets the model call read-only tools across turns before\n # emitting its structured result. Both default to UNLIMITED so the agent takes as many turns / as\n # long as it needs — it is never cut off mid-reasoning; set a positive value here (or per-run, or\n # in the UI settings) to bound latency/cost. These used to be hardcoded per call site (e.g. the\n # boss at max_turns=3 / 45s), which silently dropped a slow reasoning model to a no-op reply.\n agent_max_turns: int = 0 # max tool turns before the emit is forced (0 = unlimited)\n agent_time_budget_s: float = 0.0 # wall-clock ceiling across the loop's turns (0 = no cap)\n # G · Soft convergence SAFETY NET (not a research cap). Two thresholds on the agent's tool-turn count:\n # at `agent_emit_after` the loop NUDGES the agent once (\"you've investigated enough — emit now\"); at\n # `agent_emit_force` it FORCES the emit from what was gathered. Set GENEROUSLY — a focused researcher\n # may legitimately read many files/experiments before it can propose a grounded idea; these only catch\n # a genuine runaway that keeps issuing DIFFERENT calls forever (which stuck-detection, keyed on\n # repeats, misses). NB: the old 499-read runaway (node 63) was 79% REDUNDANT re-reads caused by a\n # broken read_file — fixing read_file (pagination) is the real cure; this is just insurance. 0 = off.\n agent_emit_after: int = 300\n agent_emit_force: int = 500\n # B1 · No-progress / stuck detection — the safety net that makes \"unlimited turns\" safe. The\n # turn/time ceilings above are only BACKSTOPS; this is what actually stops a runaway loop, on the\n # cheapest signal: when the model repeats the SAME tool call (or ping-pongs between two, or keeps\n # hitting the SAME error) with no progress, the loop forces the final emit and finishes. ON by\n # default; reading DIFFERENT files or running ONE long command never trips it. (OpenHands-style.)\n agent_stuck_detection: bool = True\n agent_stuck_repeat: int = 4 # identical calls in a row that count as \"stuck\" (>=2)\n agent_stuck_alternate: int = 4 # ping-pong cycles between two calls that count as \"stuck\" (>=2)\n # C1 · Self-plan (TodoWrite-style): expose an `update_plan` tool so a long-running agent keeps its\n # OWN working TODO and re-surfaces it every `agent_plan_reinject_every` turns — keeps the goal in\n # view across a long loop so the agent \"writes its own context\" (Claude Code style). ON by default.\n agent_self_plan: bool = True\n agent_plan_reinject_every: int = 5\n # C2 · Auto-summary (ON by default): when the tool-loop history grows long, LLM-summarize the\n # stale middle instead of only middle-truncating it. The trigger is `context_budget_chars` if set,\n # else a built-in high-water mark (context_budget.DEFAULT_SUMMARY_CHARS ≈ 120k chars) so short\n # loops are untouched. Falls back to truncation on any summarizer error; one extra model call per\n # over-budget turn.\n agent_auto_summary: bool = True\n # C4 · Repo-developer plan decomposition. A big feature attempted in ONE developer session made a\n # non-converging reasoning model loop indefinitely — it kept writing + exploring without ever\n # emitting `done` (10k+ LLM calls / hours; agentic stuck-detection can't catch VARIED exploration,\n # only literal repeats). Fix: the developer first proposes an ordered plan of ATOMIC steps; a\n # multi-step plan is executed step-by-step, each in a FRESH bounded session with only that step's\n # scope + the files accumulated so far, syntax-validated per write. Every session stays\n # small-context and convergent. ON by default for repo tasks (a 1-step plan == the old single pass).\n developer_plan_decompose: bool = True\n developer_plan_min_steps: int = 2 # a proposed plan with >= this many steps runs step-by-step\n developer_plan_max_steps: int = 8 # cap on plan length (a runaway planner can't spawn 100 steps)\n # Hard per-session backstop for the repo developer — the ONE write-heavy agent that can run away\n # even with stuck-detection on (varied writes/reads never trip the repeat/ping-pong signal). Bounds\n # the plan phase, EACH step, and the single-session fallback. Unlike the global agent_* limits\n # (0 = unlimited, meant for read-only agents), the developer always gets a FINITE ceiling so a\n # model that never emits `done` fails cleanly with the code it wrote so far, instead of looping.\n developer_session_max_turns: int = 500\n developer_session_time_budget_s: float = 1200.0 # 20 min wall-clock per developer session\n # Phase-handoff summaries. Each LLM phase in a node build (Researcher·propose → Developer·stages →\n # plan → implement) ends with ONE extra LLM call that distills its transcript — the repo structure\n # it mapped, files/data confirmed, decisions made — into a brief injected into the NEXT phase (even\n # across the role boundary). The next phase TRUSTS that instead of re-reading the same files, which\n # cuts the biggest source of tool-call thrash (stages/plan/implement each re-exploring the repo).\n # One summary call per phase in exchange for many fewer read calls downstream. ON by default.\n phase_handoff_summary: bool = True\n # Observability (ADR-17): capture each LLM call's full prompt + completion into the active\n # span (spans.jsonl) so the UI can show exactly what the model read and wrote per node.\n # Diagnostics only — `replay.fold` never reads spans. Default on for local single-user; set\n # LOOPLAB_TRACE_LLM_IO=0 to suppress (e.g. to keep spans.jsonl small or avoid storing prompts).\n trace_llm_io: bool = True\n # Run-introspection tools (ON by default): make the LLM Researcher (and DeepResearcher) a\n # tool-using agent that can read its OWN experiments (list/read/find-analogous/themes/code) and\n # the task data (schema/profile/asset) mid-loop, instead of seeing only best+parent. Advisory —\n # never changes best-selection. Off = the legacy single-shot Researcher (richer digest still added).\n researcher_tools: bool = True\n # Cross-run introspection: give the agentic Researcher / pilot read-only tools to look at\n # SIBLING runs (same task_id, same run-root) — list them, read an experiment / its code, and\n # find analogous configs across runs — so a run can build on what neighbouring runs already\n # learned instead of rediscovering it. Advisory; never changes best-selection. Needs the run's\n # own dir wired through (no-op for unit-built roles). Off = the legacy single-run view only.\n cross_run_tools: bool = True\n # Read-only access to EVERY run on this machine ACROSS ALL TASKS (not just same-task siblings):\n # the Developer/Researcher get list_all_runs + read_run_code + read_run_experiment so they can read\n # the code + result of ANY past experiment anywhere and reuse an approach. Broader than\n # `cross_run_tools` (same-task only); the agent decides when a foreign run is relevant. Advisory;\n # never changes best-selection. Needs the run's own dir wired through (no-op for unit-built roles).\n all_runs_tools: bool = True\n # PART V §22 — read-only CROSS-RUN KNOWLEDGE tool for the reasoning roles (Researcher, Strategist,\n # deep-research, and a Developer-scoped variant). Adds `cross_run_prior_attempts` / `cross_run_claims`\n # / `cross_run_atlas` over the §21.20 read-models (concept overview + claim assessments + atlas), read\n # from `memory_dir` (lessons.jsonl + concept_capsules.jsonl). Read-only is a storage-authority\n # boundary, not a behavioral no-op: retrieved content can steer later proposals, and a tool-using\n # reasoning loop can consume additional model calls. The tools never mutate cross-run truth (facts\n # are engine-written, verdicts operator-ratified, §22.4) or directly select a winner. ON by explicit\n # experimental product choice, EngineOptions off; stores only exist once Part-IV features have run.\n cross_run_read_tools: bool = True\n # Agentic retrieval (ADR-16): if set, the LLM Researcher gets grep/kb_search/read\n # tools over this directory of markdown notes and chooses when to use them.\n knowledge_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"knowledge\"))\n # T4 real embeddings: model id for an OpenAI-compatible `/embeddings` endpoint used for\n # kb_search / case retrieval. Blank (default) = the dependency-free lexical `hash_embed` (today's\n # behavior). Set e.g. \"nomic-embed-text\" (Ollama) or \"text-embedding-3-small\" for SEMANTIC\n # retrieval; a misconfigured/offline endpoint degrades back to hash_embed at call time (never\n # crashes a run). `embed_base_url` blank = reuse `llm_base_url`; the shared `llm_api_key` is used.\n embed_model: str | None = None\n embed_base_url: str | None = None\n # Harmonic memory (Memora, ICML'26 — idea import). When ON, cross-run cases and knowledge notes are\n # indexed by a short ABSTRACTION + cue ANCHORS instead of their raw text, near-duplicate memories are\n # CONSOLIDATED into one entry under a matching abstraction, and `kb_search`/case retrieval EXPAND\n # through the top hits' anchors to surface related-but-not-similar memories. ON by default. Set\n # `memora=false` to restore the pre-Memora raw-text index. Never a source of truth — abstractions\n # live only in the derived, rebuildable retrieval index (never the event log or canonical cases.jsonl).\n memora: bool = True\n # Write abstractions with the wired chat model (richer than the deterministic lexical fallback). ON\n # by default; results are CACHED by content hash (see `memora_cache`) so a re-built index doesn't\n # re-call the model on unchanged notes/cases, and any endpoint failure degrades to lexical at call\n # time — so an offline box just gets lexical abstractions, never a crash. Set false to force lexical.\n memora_llm: bool = True\n # === Cross-run memory / Memora ========================================================\n memora_anchors: int = 6 # max cue anchors kept per memory\n # Cosine similarity at/above which a new case is CONSOLIDATED into an existing one (same evolving\n # topic) rather than stored as a separate entry. Only used when `memora` is on.\n # BOUNDED like its 0..1 sibling `novelty_semantic_threshold`: consumers compare\n # `cosine(...) >= consolidate_threshold` (engine/memory.py, tools/knowledge_tools.py) and cosine\n # cannot exceed 1.0, so an unbounded typo above 1.0 validated cleanly and silently disabled\n # consolidation forever — while a negative one consolidated everything. Fail loud instead.\n memora_consolidate_threshold: float = Field(default=0.86, ge=0.0, le=1.0)\n # Where the LLM-abstraction cache lives (JSON). Blank (default) derives it from `memory_dir`\n # (`<memory_dir>/memora_cache.json`) or else `knowledge_dir` (`<knowledge_dir>/.memora_cache.json`);\n # with neither set the cache is in-memory only (per process). No-op unless `memora_llm` is on.\n memora_cache: str | None = None\n # E3 literature-grounded ideation (network-OPTIONAL): give the agentic Researcher an arXiv search\n # tool to ground ideas in real techniques. Off by default (egress is unreliable on some boxes);\n # fails gracefully if the network is blocked.\n literature_search: bool = False\n # Deep-Research stage (network-OPTIONAL): give the DeepResearcher a general web search/fetch\n # tool (DuckDuckGo) on top of arXiv to read across results + the web before steering the next\n # batch. Off by default (egress is unreliable on some boxes); fails gracefully when blocked.\n web_search: bool = False\n # Cadence for the Deep-Research stage: run it automatically every N created nodes. Zero disables\n # only the cadence; manual `deep_research` and Strategist `request_research` triggers still fire.\n # Default 3: deep research analyzes the accumulating results and steers the next batch (its\n # directions become hints + open hypotheses); paired with `concurrent_research` it overlaps the\n # think with the GPU-bound eval, so the cadence costs LLM tokens but no wall-clock on the search.\n deep_research_every: int = 3\n # Overlap a DUE deep-research \"think\" with the GPU-bound eval instead of running it in its own\n # serial step (the agent is otherwise idle while a node trains). research() computes from a state\n # snapshot; the joined background task records only allowlisted, order-tolerant\n # research/hint/hypothesis events as soon as the memo finishes. ON by default: the LLM is typically\n # remote (no GPU contention with eval), so overlapping hides latency behind long training.\n concurrent_research: bool = True\n # Concurrent-research REPEAT — don't idle a multi-DAY eval. The overlapped \"think\" fires ONCE per\n # window by default; ON here, it RE-RUNS on an adaptive time cadence for the WHOLE eval window\n # (a two-day training must not leave the reasoning agents idle after one memo). Self-paced: it\n # only records a memo whose CONTENT is new (identical re-runs are skipped, so the log/board don't\n # bloat) and backs off geometrically as the analysis converges, capped so it always re-checks.\n # The appended records never rewrite the current metric champion, but their hints/open hypotheses\n # deliberately steer later proposals and replay reconstructs that advice. The library default is\n # one-shot (== today, byte-identical); this product default turns the repetition on.\n concurrent_research_repeat: bool = True\n # Base cadence (seconds) between repeated concurrent-research passes. Research is EXPENSIVE\n # (multi-turn LLM + web/arXiv), so this is a FLOOR, not a ceiling: the effective cadence is\n # max(this, ~5% of the per-experiment time budget) — a two-day eval is re-researched about hourly,\n # a short eval not at all (the first tick outlasts it). Only matters when the repeat is on.\n concurrent_research_interval_s: float = Field(default=1800.0, gt=0)\n # Per-eval-window backstop on repeated-research LLM calls (0 = cadence-only, no cap). The adaptive\n # cadence + convergence backoff are the primary budget control; this bounds a pathological\n # never-converging window. Past it the loop stops calling the LLM (the health monitor still runs).\n concurrent_research_max_calls: int = Field(default=40, ge=0)\n # Concurrent hypothesis-board CONSOLIDATION during the eval window. Repeated research keeps ADDING\n # near-duplicate directions to the open board (each memo registers up to 5 as open hypotheses), so a\n # long eval's board bloats before the between-nodes merge runs. This dedups/merges it on the SAME\n # background loop so the board stays clean for the next proposal. In Card-driven mode the same merge\n # changes ownership/readiness, so it is deferred to the joined main-task cadence instead. Needs the\n # reflect client + `track_hypotheses`.\n # Only active while `concurrent_research` runs the overlap loop. Library default off (== today).\n concurrent_consolidate: bool = True\n # Cadence for the agent-authored run report: regenerate the conclusion-first narrative every N\n # created nodes (0 = off; it still regenerates on a manual `report_refresh` from the UI). The\n # deterministic report always renders from the node set regardless of this knob.\n report_every: int = 3\n # Agent Skills (I18, ADR-9): dir of SKILL.md the Researcher can list/load as tools.\n skills_dir: str | None = None\n # Prompt store (I18, ADR-8): dir of editable, hot-reloaded role prompt .md files.\n prompt_dir: str | None = None\n # API key as a reference, never serialized as a value (ADR-11). Local servers\n # ignore it; default to a placeholder.\n llm_api_key: SecretStr | None = None\n\n @model_validator(mode=\"before\")\n @classmethod\n def _reject_bool_for_numeric_fields(cls, data):\n \"\"\"A JSON boolean is a TYPE ERROR for a numeric setting, not the number 1.\n\n Pydantic runs in lax mode here (`model_config` sets no `strict`), and `isinstance(True, int)`\n is True — so `{\"max_nodes\": true}` from a UI/API type slip validated cleanly as a budget of\n ONE node, and `{\"eval_parallel\": true}` collapsed the width to one. The run then looked\n configured and simply did almost nothing, with no 422 and nothing in the snapshot to show why.\n Rejecting at the boundary covers every numeric field at once, including the many that carry no\n `Field(ge=...)` bound of their own to trip over.\n \"\"\"\n if not isinstance(data, dict):\n return data\n for key, val in data.items():\n if type(val) is bool and _is_numeric_annotation(\n getattr(cls.model_fields.get(key), \"annotation\", None)):\n raise ValueError(f\"{key} must be a number, not a boolean (got {val!r})\")\n return data\n\n @model_validator(mode=\"before\")\n @classmethod\n def _apply_profile(cls, data):\n \"\"\"Expand `profile` into a bundle of setting defaults BEFORE validation.\n\n pydantic-settings hands this validator the fully-merged input from ALL explicit sources\n (init kwargs + LOOPLAB_* env + .env), with fields the user left unset simply ABSENT (they\n fall to field defaults afterward). So a profile value is applied only when its key is not\n already present — i.e. any explicit file/CLI/env setting wins over the profile, and the\n profile wins over the bare field default. This makes `thorough` a true convenience preset,\n never an override of what the user asked for.\"\"\"\n if not isinstance(data, dict):\n return data\n prof = data.get(\"profile\", \"default\")\n # env may deliver the key as \"profile\" (env_prefix stripped) — normalize to a str\n prof = str(prof).strip().lower() if prof is not None else \"default\"\n overrides = PROFILES.get(prof)\n if overrides is None:\n raise ValueError(f\"unknown profile {prof!r}; known: {sorted(PROFILES)}\")\n for key, val in overrides.items():\n if key not in data: # explicit source (file/CLI/env) always wins\n data[key] = val\n return data\n\n @model_validator(mode=\"after\")\n def _check_trust_gate(self):\n if self.trust_gate not in (\"audit\", \"gate\", \"block\"):\n raise ValueError(\n f\"trust_gate must be audit|gate|block, got {self.trust_gate!r}\")\n if self.merge_mode not in (\"auto\", \"mean\", \"ensemble\"):\n raise ValueError(\n f\"merge_mode must be auto|mean|ensemble, got {self.merge_mode!r}\")\n # novelty_mode drives the dedup/novelty gate; an out-of-set value (e.g. a mis-cased\n # LOOPLAB_NOVELTY_MODE=LLM, or \"on\") used to fall through _apply_novelty_gate silently as a\n # NO-OP — turning the gate off with no diagnostic. Fail loudly at config time like trust_gate/\n # merge_mode do. (seed_mode / eval_trust_mode / strategist_backend share the pattern and are\n # validated in this same block, just below.)\n if self.novelty_mode not in (\"off\", \"algo\", \"llm\"):\n raise ValueError(\n f\"novelty_mode must be off|algo|llm, got {self.novelty_mode!r}\")\n # The remaining enum-ish string fields (arch-review §5 P3): a typo used to be accepted at\n # construction and only fail-safe/later-loud downstream (e.g. a mis-cased strategist_backend\n # silently ran the default). Fail loudly here like the fields above.\n if self.strategist_backend not in (\"off\", \"rule\", \"llm\", \"agent\"):\n raise ValueError(\n f\"strategist_backend must be off|rule|llm|agent, got {self.strategist_backend!r}\")\n if self.eval_trust_mode not in (\"ratify_freeze\", \"autonomous\", \"ratify_freeze_drift\"):\n raise ValueError(\n \"eval_trust_mode must be ratify_freeze|autonomous|ratify_freeze_drift, \"\n f\"got {self.eval_trust_mode!r}\")\n if self.seed_mode not in (\"auto\", \"tracked\", \"all\"):\n raise ValueError(f\"seed_mode must be auto|tracked|all, got {self.seed_mode!r}\")\n if self.backend not in (\"toy\", \"llm\"):\n raise ValueError(f\"backend must be toy|llm, got {self.backend!r}\")\n # developer_backend is a CLOSED enum: \"default\" (in-house developer) plus the external\n # coding-agent keys in agents/cli_agent.py::PRESETS. The CLI --developer-backend flag guards it\n # (_DEV_BACKENDS), but a file/env/`--set` value reached here unchecked and adapters/tasks.py then\n # silently wired the DEFAULT developer for anything not in PRESETS. Validate against the SAME\n # authoritative registry the runtime consumer uses (imported lazily to keep config import-light),\n # so a typo fails loud instead of downgrading — mirroring the `backend` guard above.\n from looplab.agents.cli_agent import PRESETS as _DEV_PRESETS\n _dev_backends = (\"default\", *_DEV_PRESETS)\n if self.developer_backend not in _dev_backends:\n raise ValueError(\n f\"developer_backend must be {'|'.join(_dev_backends)}, \"\n f\"got {self.developer_backend!r}\")\n # `parse_structured` resolves an unknown name to the DEFAULT fallback order, so a typo'd\n # llm_parser is indistinguishable from asking for the default. Validate against the parser\n # registry itself (imported here, not at module scope, to keep config import-light).\n from looplab.core.parse import _ORDER as _PARSER_ORDER\n if self.llm_parser not in _PARSER_ORDER:\n raise ValueError(f\"llm_parser must be one of {'|'.join(sorted(_PARSER_ORDER))}, \"\n f\"got {self.llm_parser!r}\")\n self._check_llm_profiles()\n return self\n\n def _check_llm_profiles(self) -> None:\n \"\"\"Validate the connection-profile maps LOUDLY, at construction.\n\n Assignment is not validated anywhere in this project, and these two maps are the kind of\n thing a typo makes into a silent no-op: an unknown role name would simply never be read, and\n a reference to a missing profile would fall back to the shared model while the operator\n believed a second provider was in play. Both are errors here instead.\n\n The `api_key_env` rules exist for a specific reason. It must LOOK like a secret to\n `runtime/sandbox.py::SECRET_ENV`, because that is the filter that strips the operator's keys\n out of the environment handed to generated candidate code — a key parked in a variable named\n `MY_HANDLE` would be invisible to it and ride straight into the sandbox. This pattern is\n deliberately the STRICTER of the two (the sandbox has to recognize whatever already exists;\n this names a variable the operator is about to create), and it is duplicated below rather than\n imported because layering forbids `core` from importing `runtime`.\n `tests/test_secret_env_pattern.py` pins the two together — including the invariant that every\n name accepted here is one the sandbox strips. And a literal key inside a profile is refused\n outright: the profile map is written verbatim into config.snapshot.json and served over HTTP\n precisely because it is supposed to contain no secrets.\"\"\"\n from looplab.core.llm import LLM_ROLE_KEYS\n if not isinstance(self.llm_profiles, dict):\n raise ValueError(\"llm_profiles must be a map of profile name -> profile\")\n for name, entry in self.llm_profiles.items():\n if not isinstance(entry, dict):\n raise ValueError(f\"llm_profiles[{name!r}] must be an object\")\n # BEFORE the unknown-field check: none of these is a known field, so checking them\n # afterwards could never fire and someone who pasted a live key was told it was a typo.\n for banned in (\"api_key\", \"key\", \"token\", \"secret\"):\n if banned in entry:\n raise ValueError(\n f\"llm_profiles[{name!r}] must not contain {banned!r}: a profile stores \"\n \"api_key_env (the NAME of an environment variable), never a key value — \"\n \"the whole map is written to config.snapshot.json and served over HTTP\")\n unknown = set(entry) - _PROFILE_FIELDS\n if unknown:\n raise ValueError(\n f\"llm_profiles[{name!r}] has unknown field(s) {sorted(unknown)}; \"\n f\"known: {sorted(_PROFILE_FIELDS)}\")\n env = entry.get(\"api_key_env\")\n if env is not None and not _SECRET_ENV_NAME.match(str(env)):\n raise ValueError(\n f\"llm_profiles[{name!r}].api_key_env={env!r} is not a usable variable name: it \"\n \"must be UPPER_SNAKE and contain KEY/SECRET/TOKEN/PASSWORD/PASSWD/CREDENTIAL, so \"\n \"the sandbox's secret-name filter strips it from generated code's environment\")\n temp = entry.get(\"temperature\")\n if temp is not None and (isinstance(temp, bool) or not isinstance(temp, (int, float))):\n raise ValueError(f\"llm_profiles[{name!r}].temperature must be a number\")\n if not isinstance(self.role_profiles, dict):\n raise ValueError(\"role_profiles must be a map of role name -> profile name\")\n for role, profile in self.role_profiles.items():\n if role not in LLM_ROLE_KEYS:\n raise ValueError(\n f\"role_profiles has unknown role {role!r}; known: {sorted(LLM_ROLE_KEYS)}\")\n if profile not in self.llm_profiles:\n raise ValueError(\n f\"role_profiles[{role!r}] names profile {profile!r}, which is not in llm_profiles\")\n if self.llm_profile is not None and self.llm_profile not in self.llm_profiles:\n raise ValueError(\n f\"llm_profile={self.llm_profile!r} is not in llm_profiles\")\n\n def masked_snapshot(self) -> dict:\n # Snapshots are a JSON contract (they are written verbatim to config.snapshot.json), not a\n # Python-object dump. In particular tuple-valued schema fields such as\n # ``inline_repair_reasons`` must become JSON arrays before callers hash the runtime envelope;\n # otherwise the strict speculation-scope canonicalizer sees a different/non-JSON preimage\n # from the one that ``json.dumps`` eventually persists.\n d = self.model_dump(mode=\"json\")\n if self.llm_api_key is not None:\n d[\"llm_api_key\"] = \"***\"\n else:\n d[\"llm_api_key\"] = None\n # The snapshot's own FORMAT version — not a Settings field (it is never settable by env or\n # file, and it describes the document, not the run). `settings_from_snapshot` fails closed on\n # a version it does not understand; see CONFIG_SNAPSHOT_SCHEMA.\n d[CONFIG_SNAPSHOT_SCHEMA_KEY] = CONFIG_SNAPSHOT_SCHEMA\n return d\n\n\n# The config snapshot's document-format version, written by `masked_snapshot` and enforced by\n# `settings_from_snapshot`. Bump it when a snapshot written by this binary would be MISREAD by an\n# older one — i.e. when a new field changes paid, concurrency or selection behaviour and silently\n# defaulting it would resume the same event history under different semantics. `Settings` is\n# `extra=\"ignore\"`, so without this an older binary drops what it does not recognize and resumes\n# anyway, with no diagnostic; a version it does not know is now a loud refusal instead.\n# Version 1 is the first VERSIONED format. An absent key means a pre-versioning snapshot, which stays\n# readable through `LEGACY_CONFIG_SNAPSHOT_DEFAULTS` — that historical contract is unchanged.\nCONFIG_SNAPSHOT_SCHEMA = 1\nCONFIG_SNAPSHOT_SCHEMA_KEY = \"config_snapshot_schema\"\n\n\nclass ConfigSnapshotVersionError(ValueError):\n \"\"\"A config snapshot was written by a NEWER binary than the one reading it.\"\"\"\n\n\n# Pre-versioned snapshots are full Settings dumps, so absence is a reliable per-field version marker.\n# Keep their historical effective behavior when newer product defaults become active: re-entry must not\n# silently add paid calls, interventions, concurrency or a different selection policy to an old run.\nLEGACY_CONFIG_SNAPSHOT_DEFAULTS: dict[str, object] = {\n \"parallel_build\": 1,\n \"eval_parallel\": None,\n \"llm_parallel\": None,\n \"train_monitor\": False,\n \"train_monitor_interval_s\": 600.0,\n \"train_monitor_kill\": False,\n \"train_monitor_kill_confidence\": 0.8,\n \"asha_live\": False,\n \"asha_live_kill\": False,\n \"asha_live_quantile\": 0.5,\n \"asha_live_min_siblings\": 3,\n # Before this operator ceiling existed, the sandbox's 24-hour process limit was authoritative.\n \"max_eval_timeout\": 24 * 3600.0,\n \"watchdog_reflection\": False,\n \"card_driven_selection\": False,\n \"speculation_depth\": 0,\n \"speculation_gate_receipt\": None,\n \"concurrent_research_repeat\": False,\n \"concurrent_research_interval_s\": 1800.0,\n # Zero means unlimited; 40 was the first bounded default, and is latent while repeat is disabled.\n \"concurrent_research_max_calls\": 40,\n \"concurrent_consolidate\": False,\n # The map originally covered only the flags added in the days just before it was written\n # (the entries above are all 2026-07-19..07-21; the map itself landed 07-22). Everything below was\n # added 2026-06-24..07-17 — AFTER `config.snapshot.json` existed (it ships in the initial commit,\n # 2026-06-23) and BEFORE this map did — so a snapshot from that window carries none of these keys\n # and, without an entry here, resumed with today's paid product default ACTIVE. That is precisely\n # the \"silently add paid calls ... to an old run\" the paragraph above forbids: the resumed half of\n # the run would make predict-before-execute and cross-run LLM calls the original never made, and\n # admit candidates under a different novelty policy.\n # `setdefault` makes every one of these a no-op for a snapshot that DOES carry the key, so only\n # genuinely older runs are affected. Each value is the behaviour before the field existed: absent\n # feature = off, absent cadence = 0 (the same \"conservative library\" value the frozen\n # Settings-vs-EngineOptions divergence table records for these fields). Each was dated against the\n # tree before being listed — e.g. `report_every` (2026-06-25) is 0 because the run-report writer\n # itself did not exist before that commit, so an older run wrote no report to preserve.\n \"foresight\": False,\n \"foresight_agentic\": False,\n \"foresight_verify\": False,\n \"concept_pivot\": False,\n \"graded_novelty\": False,\n \"cross_run_concepts\": False,\n \"cross_run_advisory\": False,\n \"cross_run_structured_claims\": False,\n \"cross_run_curation\": False,\n \"cross_run_read_tools\": False,\n \"concept_run_base\": False,\n \"fingerprint_universal\": False,\n \"memora_llm\": False,\n \"comparative_lessons\": False,\n \"lessons_every\": 0,\n \"lessons_refresh_every\": 0,\n \"unified_agent\": False,\n \"deep_research_every\": 0,\n \"report_every\": 0,\n \"failure_reflection\": False,\n \"reflection_priors\": False,\n \"agent_drives_actions\": False,\n \"concurrent_research\": False,\n \"deep_repair\": False,\n # CLAUDE REVIEW: [QUALITY] possible gap — `merge_mode` is absent. options.py's docstring names it as a\n # product-vs-library divergence in the SAME breath as report_every (\"merge_mode='auto', report_every=3\"),\n # and report_every IS in this map. Settings defaults \"auto\", which resolves to \"ensemble\" for a\n # code-generating developer (orchestrator.__init__ L715) — a PAID code-recombination merge producing a\n # DIFFERENT merged solution than the frozen legacy default \"mean\" (EngineOptions/orchestrator._DEFAULTS).\n # So a pre-field snapshot resumed on an LLM backend silently gains ensemble merges it never did — the\n # exact \"add paid calls / a different selection policy to an old run\" this map guards against, with a\n # pointable before-value (\"mean\"). Add `\"merge_mode\": \"mean\"` here IF merge_mode postdates 2026-06-23\n # (unverifiable in this squashed history — confirm against the real commit before acting).\n # WHAT THIS MAP IS NOT. It is a hand-maintained list of FEATURE switches whose before-the-field\n # value is unambiguous, not a complete partition of `Settings`. Two classes stay out on purpose,\n # because for them a wrong entry is worse than a missing one — it would silently REMOVE behaviour\n # the original run really had:\n # * knobs that are latent while their parent feature is off — `foresight_panel`,\n # `foresight_verify_samples`, `memora_anchors`, `holdout_top_k`, `asha_eta`: the parent above\n # already pins them out of play, and their own before-the-field value is not recoverable;\n # * magnitudes and depths for behaviour that ALREADY existed — `debug_depth`,\n # `context_budget_chars`, `developer_plan_max_steps`, `agent_emit_after`: guessing 0/off there\n # disables working machinery rather than preserving history.\n # A new field belongs here only when it (a) postdates 2026-06-23, (b) defaults to adding paid\n # calls / interventions / concurrency / a different selection policy, and (c) has a historical\n # value you can point at a commit for. When (c) fails, leave it out and say so here.\n}\n\n\ndef migrate_config_snapshot(data: dict) -> dict:\n \"\"\"Return an effective copy of one legacy/current snapshot without rewriting evidence bytes.\"\"\"\n if not isinstance(data, dict):\n raise TypeError(\"config snapshot must be a JSON object\")\n migrated = dict(data)\n for key, value in LEGACY_CONFIG_SNAPSHOT_DEFAULTS.items():\n migrated.setdefault(key, value)\n return migrated\n\n\ndef config_snapshot_schema(data: dict) -> int:\n \"\"\"The snapshot's declared format version. 0 = pre-versioning (the key did not exist yet).\n\n A malformed value is treated as UNREADABLE rather than as 0: a snapshot whose own version marker\n is corrupt cannot be assumed to be the oldest format.\"\"\"\n raw = data.get(CONFIG_SNAPSHOT_SCHEMA_KEY, 0) if isinstance(data, dict) else 0\n if isinstance(raw, bool) or not isinstance(raw, int) or raw < 0:\n raise ConfigSnapshotVersionError(\n f\"config snapshot has a malformed {CONFIG_SNAPSHOT_SCHEMA_KEY}: {raw!r}\")\n return raw\n\n\ndef settings_from_snapshot(data: dict) -> Settings:\n \"\"\"Build re-entry Settings from a masked snapshot under its historical missing-field contract.\n\n Fails closed on a snapshot written by a NEWER binary. `Settings` is `extra=\"ignore\"`, so such a\n snapshot would otherwise load with every unrecognized control silently dropped — the run would\n resume on the same event history under different paid/concurrency/selection semantics, with\n nothing to show why. Refusing is the only honest answer: this binary cannot know what it is\n dropping. Older and pre-versioning snapshots keep loading exactly as before.\"\"\"\n found = config_snapshot_schema(data)\n if found > CONFIG_SNAPSHOT_SCHEMA:\n raise ConfigSnapshotVersionError(\n f\"this run's config.snapshot.json declares format v{found}, but this build understands \"\n f\"at most v{CONFIG_SNAPSHOT_SCHEMA}. It was written by a newer LoopLab; resuming here \"\n \"would silently drop settings this build does not know. Upgrade LoopLab to resume it.\")\n migrated = migrate_config_snapshot(data)\n migrated.pop(\"llm_api_key\", None)\n migrated.pop(CONFIG_SNAPSHOT_SCHEMA_KEY, None) # a document marker, never a Settings field\n return Settings(**migrated)\n"}, "61f2c4267ad9e2e1921103e3240e6a87eed4aac1": {"t": 1785768729.0197093, "n": 119769, "h": "288b7f87f7618cdc", "c": "\"\"\"Engine config: the schema (what settings exist). (I0, ADR-11)\n\nOne pydantic-settings class declaring every engine knob, its default, and its docs. The loader —\nhow a run file / CLI / env becomes a `Settings` — is `looplab.core.appconfig`, which also owns\nthe config-precedence order. A resolved, secret-masked snapshot is written next to each run for\nreproducibility. (No real secrets in P0, but the masking discipline is in place.)\n\"\"\"\nfrom __future__ import annotations\n\nimport re\nimport types\nimport typing\nfrom pathlib import Path\n\nfrom pydantic import Field, SecretStr, field_validator, model_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n# Single source of the LLM first-byte (response-headers) default — see core/llm.py.\nfrom looplab.core.llm import DEFAULT_HEADER_TIMEOUT_S\n\n# Default home for cross-run memory + the knowledge base (shared across every run). Both are ON by\n# default now (a user asked for it) so a fresh install accumulates + consults knowledge with no setup;\n# set the env var to \"\" to disable, or to a path to relocate. `~/.looplab/` keeps them out of any one\n# project + shared across projects.\n_LL_HOME = Path.home() / \".looplab\"\n\n# What one `llm_profiles` entry may contain. Deliberately closed: an unrecognized field is a typo the\n# operator would otherwise never see fail, and the map is a public artifact (config.snapshot.json,\n# HTTP, LOOPLAB_*), so it must never accrete free-form keys.\n_PROFILE_FIELDS = frozenset({\"model\", \"base_url\", \"temperature\", \"api_key_env\"})\n# A variable name that `runtime/sandbox.py::SECRET_ENV` will recognize as holding a secret and strip\n# from generated code's environment. Duplicated here, not imported: layering forbids `core` from\n# importing `runtime`. `tests/test_secret_env_pattern.py` holds the two in agreement.\n_SECRET_ENV_NAME = re.compile(\n r\"\\A(?=[A-Z][A-Z0-9_]{0,63}\\Z)(?=.*(KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL)).*\\Z\")\n\n\ndef _is_numeric_annotation(ann) -> bool:\n \"\"\"True when a field's annotation admits int/float but NOT bool.\n\n Used by `Settings._reject_bool_for_numeric_fields`: pydantic resolves annotations at class build,\n so `model_fields[...].annotation` is a real type object here even under\n `from __future__ import annotations`. Optionals are numeric when every non-None member is\n (`int | None` yes, `int | str` no), so a boolean can never slip in through a union arm.\"\"\"\n if ann is bool:\n return False # a bool field legitimately takes a bool\n if ann is int or ann is float:\n return True\n args = [a for a in typing.get_args(ann) if a is not type(None)]\n origin = typing.get_origin(ann)\n if args and origin in (typing.Union, types.UnionType):\n return all(_is_numeric_annotation(a) for a in args)\n return False\n\n# Autonomous roles that may be granted per-setting write access (see Settings.agent_control).\n# strategist = A7 meta-controller (run-wide); boss = run-chat operator-proxy (run-wide);\n# researcher = per-experiment proposer (per-node sizing, e.g. eval timeout for a heavy model).\nAGENT_ROLES = (\"strategist\", \"boss\", \"researcher\")\n\n# A foresight score is computed for every acted-on proposal. Keep this lower than the generic\n# verifier's emergency ceiling so a typo cannot multiply paid synchronous calls across a run.\nMAX_FORESIGHT_VERIFY_SAMPLES = 8\n\n\n# Layer-2 parallelism renamed two public settings without removing the durable legacy fields. Keep\n# the equivalence in one place: config-source precedence, governance migration, and live strategy\n# application must all agree which names describe the same authority/value.\nPARALLELISM_ALIASES: dict[str, str] = {\n \"max_parallel\": \"eval_parallel\",\n \"parallel_build\": \"llm_parallel\",\n}\n\n\ndef parallelism_aliases(setting: str) -> tuple[str, ...]:\n \"\"\"Return every spelling in ``setting``'s alias family, canonical name first.\"\"\"\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if setting in (legacy, canonical):\n return canonical, legacy\n return (setting,)\n\n\ndef canonicalize_parallelism_source(\n values: dict, *, promote_build_to_llm_parallel: bool = False,\n) -> dict:\n \"\"\"Promote legacy parallel keys *within one config-precedence source*.\n\n Applying this only after file/CLI/env were flattened would let a lower-priority canonical env\n value shadow a higher-priority legacy file value (or a file canonical value shadow a CLI legacy\n override), so callers with sub-layers normalize each layer independently.\n\n ``max_parallel``/``eval_parallel`` are true aliases (pure eval width, no extra semantics) and are\n always promoted. ``parallel_build``/``llm_parallel`` are NOT: a positive ``llm_parallel`` also flips\n on the finite shared LLM broker (``orchestrator._startup_llm_total``), while legacy ``parallel_build``\n means \"legacy build width, unbounded overlap, no shared total\". So promoting a legacy-only\n ``parallel_build`` layer (e.g. just ``LOOPLAB_PARALLEL_BUILD=1`` in env) would silently serialize\n every provider call — contradicting llm_broker.py's docstring, orchestrator's \"legacy-only preserves\n unbounded overlap\" note, the broker-neutral live-control path, and the configuration.md ``llm_parallel``\n row (\"falls back ... without enabling a shared total\"). Config/startup loads therefore keep broker\n opt-in tied to an EXPLICITLY-spelled canonical ``llm_parallel``; the orchestrator's own width fallback\n (``llm_parallel if not None else parallel_build``) still carries the legacy build width. Only the\n Strategist live-control path (``canonicalize_strategy_parallelism``) opts into\n ``promote_build_to_llm_parallel``, where ``parallel_build`` is a deliberate full alias of the\n canonical knob (see strategy.py ``_apply_strategy``).\n \"\"\"\n if not isinstance(values, dict):\n return values\n out = dict(values)\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if canonical == \"llm_parallel\" and not promote_build_to_llm_parallel:\n continue\n # None is the durable legacy mode for llm_parallel, not a missing value. A normal snapshot\n # contains llm_parallel:null plus parallel_build:1; promoting null to 1 would unexpectedly enable\n # the global one-call broker. Promote aliases only when the canonical key is absent, and pin\n # snapshot round-trip parity. Canonicalize inside each precedence layer, never across the\n # flattened result, so the alias migration cannot invert CLI > file > env precedence.\n if canonical not in out and out.get(legacy) is not None:\n out[canonical] = out[legacy]\n return out\n\n\ndef flatten_parallelism_layers(layers) -> dict:\n \"\"\"Flatten same-tier precedence layers (LOWEST priority first) and resolve the legacy pair.\n\n PRECEDENCE REPAIR for the non-promoted `parallel_build`/`llm_parallel` pair. Declining to promote\n in `canonicalize_parallelism_source` is right (a legacy-only layer must not flip on the shared\n broker), but it let a LOWER-priority canonical value survive a HIGHER-priority legacy override:\n file `llm_parallel: 2` + CLI `-s parallel_build=8` flattened to BOTH keys, and the orchestrator\n prefers `llm_parallel`, so the operator's explicit CLI width was silently discarded. When a higher\n layer names the legacy width over a lower layer's canonical one, mask with the LEGACY SENTINEL\n (`llm_parallel=None` is the durable \"legacy mode — build width from parallel_build, no shared\n total\"), so the higher layer wins with the semantics it asked for.\n\n The mask lives HERE, not in the per-layer canonicalizer, because only a flatten knows the layer\n ORDER. Stamping it per layer meant any layer naming `parallel_build` emitted `llm_parallel=None`\n even when NO layer in the tier set the canonical key — and since pydantic-settings ranks init\n kwargs above env, that sentinel then silently overrode a `LOOPLAB_LLM_PARALLEL` the operator had\n exported as a deliberate provider rate/cost ceiling. Precedence is per FIELD: a tier that never\n spelled `llm_parallel` has not set it, and it must fall through to env/.env/defaults — which is\n exactly what `build_settings`' docstring promises (\"the merged dict wins only where it actually\n sets a value\").\n \"\"\"\n merged: dict = {}\n legacy_at = canonical_at = -1\n for index, layer in enumerate(layers):\n if not isinstance(layer, dict):\n continue\n merged.update(layer)\n if layer.get(\"parallel_build\") is not None:\n legacy_at = index\n if \"llm_parallel\" in layer:\n canonical_at = index\n # Only when this tier DID set the canonical key, at a strictly lower layer than the legacy one.\n if canonical_at >= 0 and legacy_at > canonical_at:\n merged[\"llm_parallel\"] = None\n return merged\n\n# Settings whose semantics are committed by ``run_started`` and restored from the folded event log\n# on every re-entry. Changing one only in ``config.snapshot.json`` would mix incompatible evidence\n# (a different holdout split) or make the live producer disagree with replay's selection policy.\n# The HTTP config editor and the engine both consume this one contract; keep deliberately mutable\n# controls such as ``trust_gate`` out (that field has its own durable change event).\nRUN_START_PINNED_FIELDS = frozenset({\n \"card_driven_selection\",\n \"speculation_depth\",\n \"holdout_fraction\",\n \"holdout_select\",\n \"select_verifier\",\n \"select_verifier_samples\",\n \"verifier_ci_tie\",\n})\n\n\ndef run_start_pinned_settings(state) -> dict:\n \"\"\"Return the effective Settings names committed by a folded run-start record.\n\n Pre-pin legacy logs have no recorded holdout fraction, so their snapshot remains authoritative\n for both holdout knobs. Verifier fields, however, have always been re-pinned from the legacy-safe\n fold defaults whenever a valid ``run_started`` identity exists.\n \"\"\"\n if not getattr(state, \"run_id\", \"\"):\n return {}\n values = {\n # Layer 3 changes which queue owns macro-action selection. Once a run starts, replay and\n # resume must keep the same owner even if the snapshot or ambient default changes later.\n \"card_driven_selection\": bool(getattr(state, \"card_driven_selection\", False)),\n # Layer 5 overlaps Card production with evaluation only when this pinned treatment is\n # positive. Old logs omit it and therefore retain the exact serial build/eval spine.\n \"speculation_depth\": int(getattr(state, \"speculation_depth\", 0)),\n \"select_verifier\": bool(getattr(state, \"select_verifier_tiebreak\", False)),\n \"select_verifier_samples\": int(getattr(state, \"select_verifier_samples\", 3)),\n \"verifier_ci_tie\": bool(getattr(state, \"verifier_ci_tie\", False)),\n }\n holdout_fraction = getattr(state, \"holdout_fraction\", None)\n if holdout_fraction is not None:\n values.update({\n \"holdout_fraction\": holdout_fraction,\n \"holdout_select\": bool(getattr(state, \"holdout_select\", False)),\n })\n if not values.keys() <= RUN_START_PINNED_FIELDS:\n raise RuntimeError(\"folded run-start pinned settings contract drifted\")\n return values\n\n# --- Run profiles (config-first presets) -------------------------------------------------------\n# A `profile` is a NAMED BUNDLE of setting defaults — nothing more. It only fills fields the user\n# did NOT set explicitly (via file / --set / LOOPLAB_* env / init-kwargs), so any explicit knob\n# always wins (see `_apply_profile`). Most engine intelligence lives behind individual flags. The\n# PART IV/V cross-run + concept-graph machinery (concept_pivot, graded_novelty, cross_run_concepts /\n# _advisory / _structured_claims / _curation / _read_tools, fingerprint_universal, foresight_verify)\n# now ships ON in the product `Settings` default. That is an explicit experimental product choice,\n# not a claim that a frozen A/B or workload-wide cost/quality gate has cleared: the bundle can steer\n# prompts and candidate generation, change graded-novelty admission / pre-execution choices, and add\n# paid LLM work when its client and memory prerequisites are present. The bare-library `EngineOptions`\n# default stays lean (see tests/test_options_divergence.py — product side is the aggressive one) so a\n# toy `Engine(...)` in a test doesn't fire cross-run LLM work unasked. The trust/confirm/ablate QUALITY\n# bundle still ships OFF and is turned on by `thorough`. Every value here is reachable by hand — the\n# profile is a convenience, never a hidden mode.\n# keep this rationale behavioral and evidence-based; do not relabel read-only storage\n# access or proposal-only governance as a no-op for agent behavior, cost, or proposal admission.\n#\n# `thorough` deliberately touches only QUALITY/TRUST machinery, not spend: it does NOT raise\n# max_nodes / eval_parallel (those are cost knobs the user owns). It enables multi-seed confirmation\n# (demote seed-lucky leaders), the reward-hack / leakage / critic monitors AND flips them from\n# advisory to *gating* (`trust_gate=\"gate\"` — a\n# flagged win can no longer be selected as best), ablation-driven refinement, and the prompt cues\n# (complexity / budget / failure-reflection / watchdog-reflection). Agentic novelty and the experimental\n# Part IV/V bundle are already product defaults, not profile changes. Effect is large; marginal cost is a\n# few extra evals.\nPROFILES: dict[str, dict] = {\n \"default\": {}, # explicit no-op alias for \"ship-as-is\" (== omitting profile)\n \"fast\": {}, # today's lean defaults, named for symmetry with `thorough`\n \"thorough\": {\n \"confirm_top_k\": 3,\n \"confirm_seeds\": 3,\n # novelty_gate intentionally NOT enabled here: duplicate-detection is the agentic Researcher's\n # job (it reads past experiments + find_analogous_across_runs and DECIDES), not an algorithmic\n # param-distance / embedding auto-reject that mis-fired (it nudged a good fresh idea onto a\n # twice-dead lineage — live node 61). Search stays as tools; the LLM judges novelty.\n \"operator_bandit\": True, # P4: adaptive operator mix over folded yields (GreedyTree)\n \"reward_hack_detect\": True,\n \"code_leakage_detect\": True,\n \"critic_check\": True,\n \"trust_gate\": \"gate\", # detectors stop gating a flagged node from WINNING (not just audit)\n \"ablate_every\": 3,\n \"complexity_cue\": True,\n \"budget_aware\": True,\n \"failure_reflection\": True,\n \"watchdog_reflection\": True, # feed recent live-watchdog (train-monitor/ASHA) flags to proposals\n \"reflection_priors\": True, # no-op unless memory_dir is set (cross-run priors)\n },\n}\n\n\n# The shipped agent-governance matrix (who may change which setting at runtime). Defined as a module\n# constant so the Engine's direct-construction default matches this shipped default exactly (the\n# EngineOptions \"Engine() == shipped defaults\" invariant) instead of diverging to an all-locked map.\nDEFAULT_AGENT_CONTROL: dict[str, list[str]] = {\n \"timeout\": [\"researcher\", \"strategist\"],\n # Layer-2 canonical names are the presented/default governance surface. `_agent_may` falls back\n # to an old snapshot's legacy grant only when its canonical counterpart is absent, so removing\n # duplicate defaults preserves resume authority while an explicit canonical revocation still wins.\n \"eval_parallel\": [\"strategist\"],\n \"llm_parallel\": [\"strategist\"],\n # Strategy-only pseudo-setting: the closed per-lane allocation inside the canonical LLM total.\n # It is not a launch Settings field; operators pin it through `set_strategy` and the Strategist\n # may reallocate it only while this grant is present.\n \"llm_lane_limits\": [\"strategist\"],\n \"max_nodes\": [\"strategist\", \"boss\"],\n \"max_eval_seconds\": [\"strategist\", \"boss\"],\n \"policy\": [\"strategist\"],\n # The policy NAME and its PARAMS are gated independently in `_apply_strategy`; grant both by default\n # so the Strategist that may switch to (say) MCTS may also apply the `c`/`eta` it decided — else its\n # tuned params are silently dropped and the recorded `active_strategy` diverges from the live engine.\n \"policy_params\": [\"strategist\"],\n \"n_seeds\": [\"strategist\"],\n \"ablate_every\": [\"strategist\"],\n \"merge_mode\": [\"strategist\"],\n \"complexity_cue\": [\"strategist\"],\n \"ablate_code_blocks\": [\"strategist\"],\n \"prefer_sweep\": [\"strategist\"],\n \"novelty_stance\": [\"strategist\"],\n \"developer\": [\"strategist\"],\n \"fidelity\": [\"strategist\", \"researcher\"],\n}\n\n\ndef default_agent_control() -> dict[str, list[str]]:\n \"\"\"A FRESH deep-ish copy of the shipped governance matrix (each role list copied so a caller can\n mutate its own map without touching the module constant). The SINGLE constructor of the default,\n called by both `Settings.agent_control`'s default_factory AND the Engine's direct-construction\n default — so \"Engine() == shipped defaults\" can't drift if the copy depth ever needs to change\n (e.g. nested values requiring copy.deepcopy), instead of two verbatim copy expressions.\"\"\"\n return {k: list(v) for k, v in DEFAULT_AGENT_CONTROL.items()}\n\n\nclass Settings(BaseSettings):\n \"\"\"The engine settings schema (every knob a run accepts).\n\n Timeout family — six distinct knobs, each owned by a different subsystem:\n - `timeout`: per-eval wall-clock budget for ONE experiment's evaluation (engine/eval).\n - `max_eval_timeout`: hard ceiling for a governed per-node Researcher timeout override.\n - `llm_timeout`: LLM request idle timeout — inter-token stall limit in stream mode\n (core.llm OpenAICompatibleClient).\n - `llm_header_timeout`: LLM first-byte (response-headers) window for stream attempts (core.llm).\n - `agent_time_budget_s`: wall-clock ceiling across a tool-using agent loop's turns (agents).\n - `dep_install_timeout`: per-package budget for auto-installing a missing dep (env self-prep).\n\n Config-source precedence is owned by `looplab.core.appconfig` (the loader) — see its module\n docstring for the one canonical order.\n \"\"\"\n # Config precedence: see appconfig.py — the loader owns the full order. The `.env` is read\n # so `looplab run`/`looplab ui` pick up LOOPLAB_LLM_BASE_URL etc. without exporting them by hand\n # (keys MUST carry the LOOPLAB_ prefix, same as the env vars). utf-8-sig tolerates a BOM from\n # Windows editors. The test suite disables this (tests/conftest.py) so a dev's real .env in the\n # repo root can't leak into assertions.\n model_config = SettingsConfigDict(\n env_prefix=\"LOOPLAB_\",\n env_file=\".env\",\n env_file_encoding=\"utf-8-sig\",\n extra=\"ignore\",\n allow_inf_nan=False,\n )\n\n @classmethod\n def settings_customise_sources(\n cls, settings_cls, init_settings, env_settings, dotenv_settings,\n file_secret_settings):\n \"\"\"Preserve source precedence across canonical/legacy parallelism names.\n\n Pydantic ranks init > env > dotenv correctly, but it ranks field names rather than semantic\n alias families. Promoting a legacy value inside each source before the merge makes an old\n explicit snapshot/file value beat a lower-priority canonical env value. ``appconfig`` also\n promotes its file/typed/--set sub-layers before combining them into the init source.\n \"\"\"\n def _with_parallel_aliases(source):\n return lambda: canonicalize_parallelism_source(source())\n\n return (\n _with_parallel_aliases(init_settings),\n _with_parallel_aliases(env_settings),\n _with_parallel_aliases(dotenv_settings),\n _with_parallel_aliases(file_secret_settings),\n )\n\n # Run profile (config-first preset, see PROFILES above). \"default\"/\"fast\" = today's lean\n # defaults; \"thorough\" turns the built quality/trust machinery ON in one word. A profile only\n # fills fields the user did NOT set — every knob it touches stays individually overridable.\n profile: str = \"default\"\n\n # Bounds (review C/⚪): a non-positive node/seed budget or timeout is never valid. Upper bounds are\n # equally load-bearing — the UI /start path writes these straight into the engine's env, and an\n # unbounded `eval_parallel` (or legacy `max_parallel`; e.g. 100000 from a crafted preflight) would\n # fan out that many sandboxes → resource exhaustion. Reject at config time, not mid-run. Ceilings\n # are generous; tests top out near 50.\n # Pydantic's lax mode coerces a JSON boolean into any numeric field (`true` -> 1), so a UI/API type\n # error used to silently collapse a run budget to ONE node instead of returning 422. Rejected for\n # EVERY numeric field by `_reject_bool_for_numeric_fields` below, not just the ones bounded here.\n n_seeds: int = Field(default=3, ge=1, le=1024)\n max_nodes: int = Field(default=8, ge=1, le=1_000_000)\n # legacy evaluation-width input. `eval_parallel` below is canonical and wins when set;\n # keep this field for old env/config/snapshots and direct Engine call sites. A width > 1 admits\n # concurrent evals, whose resource reservations may be CPU-only or span one/more GPUs — there is no\n # one-node/one-GPU promise. `max_parallel=0` is launch-time AUTO (detected GPU count, at least 1).\n max_parallel: int = Field(default=1, ge=0, le=1024)\n # legacy build-width input. `llm_parallel` below is canonical and also controls the\n # shared provider-call total when positive. A width > 1 uses fresh pooled researcher/developer pairs;\n # it says nothing about the later eval's CPU/GPU reservation. Startup `0` derives build fan-out from\n # resolved eval width; a live Strategist/operator zero settles to 1. Without role_factory it clamps to 1.\n parallel_build: int = Field(default=1, ge=0, le=64)\n # Hypothesis-card Kanban re-architecture (docs/23, Layer 2). Two INDEPENDENT concurrency axes,\n # decoupled from each other so cost (LLM work) can be tuned separately from throughput (GPU evals).\n # These are the CANONICAL names going forward; `max_parallel`/`parallel_build` above are kept as thin\n # back-compat aliases (env vars / snapshots / ~100 Engine(...) call sites depend on the exact names,\n # so they cannot be renamed — CLAUDE.md). Resolution (orchestrator.__init__): a NEW name, when set,\n # WINS over its legacy alias; None => fall back to the legacy field => byte-identical to today.\n # `eval_parallel`: concurrent EVALS (the GPU/experiment consumer). None -> max_parallel; 0 = AUTO\n # (one experiment per detected GPU). This is the in-Run eval-concurrency ceiling; the resource\n # scheduler bin-packs declared footprints separately. Sibling local GPU-owning Runs serialize\n # through the conservative host-pool lease; external execution needs its own admission boundary.\n # `llm_parallel`: total concurrent LLM provider calls + BUILD fan-out. None -> legacy\n # parallel_build semantics without a shared total; 0 = startup AUTO (= resolved eval_parallel\n # for build fan-out, historical research overlap unbounded). A positive canonical value enables\n # the named-lane broker the Strategist can reallocate at cadence.\n eval_parallel: int | None = Field(default=None, ge=0, le=1024)\n llm_parallel: int | None = Field(default=None, ge=0, le=64)\n # Training-log monitor (I-series watchdog family): a per-eval background observer that tails the live\n # training log while a (often multi-hour) declared stage runs in a worker thread. ON in the product\n # surface (Settings). Its alert event is fold-ignored and cannot directly change lifecycle/champion;\n # with watchdog_reflection on, that alert is nevertheless advice in a later Researcher\n # prompt and can change future proposals. It only fires on the command-eval path with an LLM client;\n # bare-library EngineOptions stays OFF. See test_options_divergence.\n # `_interval_s` is the BASE tick cadence (the effective one adapts to the per-experiment budget).\n train_monitor: bool = True\n train_monitor_interval_s: float = Field(default=600.0, gt=0)\n # Phase 3 — INTERVENTION (opt-in, separate from observation): let the monitor tree-kill a training the\n # LLM judges 'broken' (diverged / silent CPU fallback / not learning) EARLY, instead of burning the\n # whole budget. Off by default — the observer only WATCHES unless this is on. A kill only fires on a\n # 'broken' verdict (a plateau is 'watch', never killed) with confidence >= the threshold; the node then\n # fails normally (reason='monitor_broken'), so replay reconstructs it from that one terminal event.\n train_monitor_kill: bool = False\n train_monitor_kill_confidence: float = Field(default=0.8, ge=0.0, le=1.0)\n # ASHA live-curve watchdog (sibling of the training monitor): reads the latest INTERMEDIATE value of\n # the objective metric off the live log (reusing the eval's OWN metric reader). Finished-endpoint rank\n # remains advisory. An opt-in KILL additionally requires an operator-declared metric.resource_key and\n # enough sibling observations at exactly that resource; absent comparable evidence cannot stop a run.\n # Records a fold-ignored `asha_rank` diagnostic + trace span; the library default is OFF (off == today).\n asha_live: bool = True\n # Opt-in INTERVENTION (separate from the advisory signal): let the watchdog tree-kill a node whose\n # intermediate metric stays below the SAME-RESOURCE bar past a short grace window. Off by default —\n # it only surfaces endpoint rank unless this is on. A kill fails normally (asha_underperforming).\n asha_live_kill: bool = False\n # The bar sits at `asha_live_quantile` along a WORST->BEST ordering of the finished siblings' finals:\n # 0.5 = the median (a node worse than the median peer flags); a SMALLER value is more conservative —\n # it lowers the bar toward the WORST finished peer, so only a node worse than nearly all peers flags\n # (0.0 = worse than the worst). `min_siblings` is the floor of finished peers required before it ranks\n # at all, so it never acts on too little evidence.\n asha_live_quantile: float = Field(default=0.5, ge=0.0, le=1.0)\n asha_live_min_siblings: int = Field(default=3, ge=1)\n timeout: float = Field(default=30.0, gt=0)\n # Hard ceiling for the Researcher's governed per-node `Idea.eval_timeout`. One hour preserves the\n # existing heavy/neural-net authoring example while staying well below the sandbox's defensive\n # 24-hour subprocess ceiling. This is operator-owned run config: agents may request less or more,\n # but the accepted action is always clamped here after the `agent_control.timeout` permission gate.\n max_eval_timeout: float = Field(default=3600.0, gt=0, le=24 * 3600.0)\n # Intra-node sweep: a sweep node runs a whole grid in one process, so it gets this multiple of\n # `timeout` as its wall-clock budget (solution.py path; RepoTasks use their per-profile timeout).\n sweep_timeout_mult: float = Field(default=8.0, ge=1)\n # Eval STALL watchdog CAP (seconds): a stage that is completely SILENT on stdout/stderr for this\n # long — while still alive and BELOW its wall-clock deadline — is tree-killed early with a STALLED\n # marker (a hung dataloader/deadlock dies in minutes instead of burning a multi-hour timeout). The\n # per-stage window is `min(this, the stage's own timeout)`, so a short scorer's deadline fires first.\n # `0` DISABLES the watchdog entirely (only the hard deadline applies) — for a legitimately quiet\n # non-Python stage (block-buffered stdout, a script that logs only to its own file). Threaded into\n # the eval and surfaced to the Developer so its code can emit periodic progress to stay alive.\n eval_stall_timeout_s: float = Field(default=1800.0, ge=0)\n # Sandbox tier (ADR-13): \"trusted_local\" (subprocess, no Docker) for the CLI;\n # \"untrusted\" (Docker --network none, shared-kernel runtime) for hosted/multi-tenant UI;\n # \"hostile\" (untrusted + a true-isolation OCI runtime, gVisor `runsc` by default / Kata) for\n # running untrusted code. See runtime/sandbox.py::make_sandbox for the tier selection.\n trust_mode: str = \"trusted_local\"\n # Image for the untrusted command-eval tier (RepoTask, Phase 4): the framework's deps\n # should be baked in (the container runs --network none, so a pip setup can't fetch).\n docker_image: str = \"python:3.12-slim\"\n # Resource caps for the untrusted/hostile Docker tier (passed to `docker run --memory/--cpus`).\n # The default 4g bound protects other tenants from an OOM, but a model-training MLE-bench eval\n # routinely needs more — raise it (e.g. \"16g\") for such runs. \"\" disables the cap (unbounded).\n # Ignored by the trusted_local (subprocess) tier.\n sandbox_memory: str = \"4g\"\n sandbox_cpus: str = \"\"\n # Best-effort host-OOM guard for the TRUSTED-LOCAL (subprocess) tier. When set (e.g. \"8g\"), each\n # eval child gets an RLIMIT_AS address-space cap so a runaway trainer hits MemoryError instead of\n # OOM-killing the whole host (and the engine). POSIX only; a no-op on Windows. Default \"\" = OFF:\n # RLIMIT_AS bounds VIRTUAL memory, and CUDA/torch reserve tens of GB of virtual without using it,\n # so a default cap would spuriously kill GPU evals — leave it off for those and use the Docker\n # tier's real --memory cgroup bound (sandbox_memory) instead. Ideal for CPU/numpy/sklearn runs.\n sandbox_memory_local: str = \"\"\n # Best-effort disk-fill guard for the TRUSTED-LOCAL tier (P1-5): an RLIMIT_FSIZE cap on the size of\n # any single file an eval child writes (e.g. \"2g\"), so a runaway that writes an unbounded file gets\n # SIGXFSZ instead of filling the host disk. POSIX only. Default \"\" = OFF (large model checkpoints\n # need big files); set it for tasks that write only small artifacts.\n sandbox_fsize_local: str = \"\"\n # Search policy (ADR-2): \"greedy\" | \"evolutionary\" | \"mcts\" | \"asha\" | \"bohb\".\n # `bohb` is a registered policy (`search/policy.py::_REGISTRY` aliases it to the ASHA factory,\n # which BOHB reuses as its racing half) and is advertised by `appconfig.render_template` and the\n # `bohb` template kind; this comment used to omit it and undersell the valid set. The value is\n # not validated at config time, but `make_policy` raises on an unknown name — loud-late rather\n # than silent, unlike `developer_backend` above.\n policy: str = \"greedy\"\n # Ablation-driven refinement (I7): every N improves, ablate the best to find the\n # highest-impact parameter and refine it. 0 = off. (GreedyTree only.)\n ablate_every: int = 0\n # A0a (MLE-STAR): when ablating, treat each generated pipeline *code block* (not just a\n # numeric param) as an ablation unit — neutralize a block, measure the metric delta, refine\n # the highest-impact block. Off => the classic numeric-param ablation. Config-first knob.\n ablate_code_blocks: bool = False\n # A0b: real merge/ensembling. \"mean\" = legacy mean-param merge; \"ensemble\" = the Developer\n # writes a code-recombination ensemble over the two parents' solutions (verified: agent-proposed\n # ensembling 37.9%->43.9%). \"auto\" (default) = ensemble whenever the Developer actually\n # generates code (LLM/agent backends; declared via `is_code_generating`), mean for the\n # templated/toy developers where a code ensemble is meaningless — the evidence says code\n # recombination is the strongest single merge (removing it costs ~9 pp), so it is the default\n # wherever it can work. Falls back to mean when the Developer can't ensemble.\n merge_mode: str = \"auto\"\n # A0d (AIRA): inject a dynamic complexity hint into the draft/improve prompt keyed on the\n # node's child count (few children -> keep minimal; many -> escalate to ensembling/HPO).\n complexity_cue: bool = False\n # I1 (CAAFE-style) LLM feature-engineering: instruct the proposer/developer to add\n # semantically-meaningful engineered features (ratios, interactions, aggregations) as code. The\n # existing cross-validation eval + best-selection is the CV GATE — a feature set that doesn't\n # improve CV is never selected (feature engineering is non-universal, so this gate is mandatory).\n # Tabular tasks (those exposing column data). Off by default.\n feature_engineering: bool = False\n # A5: surface the REMAINING eval-compute budget into the proposal prompt so the Researcher can\n # reason over how much compute is left (explore broad while flush, exploit/cheapen when low).\n # No-op unless a max_eval_seconds budget is set.\n budget_aware: bool = False\n # A4 (LATS-style): feed a summary of the most recent FAILED branches (operator + error reason)\n # back into the proposal prompt so the proposer reflects on and avoids repeating them. ON by\n # default: it is SELECTIVE by construction (injects only when recent failures exist — the\n # evidenced form of reflection; reflecting at every step shows no gains, arXiv:2506.12928).\n failure_reflection: bool = True\n # Feed the LIVE-WATCHDOG observations (train-monitor health verdicts + ASHA intermediate-rank flags)\n # of recent experiments back into the proposal prompt, so the Researcher avoids re-proposing a config\n # whose TRAINING was already observed to be weak — even when the watchdog kills are off (default) and\n # the node ran to completion (those diagnostics are fold-ignored, so failure_reflection never sees\n # them). ON by default and SELECTIVE like failure_reflection (injects only when a recent flag exists).\n watchdog_reflection: bool = True\n # C3 deep test-driven repair: hand the Developer the failure taxonomy + a structured \"reproduce\n # then fix\" directive on debug, not just the raw stderr tail. ON by default (product surface); the\n # conservative library default in EngineOptions stays off, per that module's contract.\n deep_repair: bool = True\n # === Inline crash repair ==============================================================\n # Hybrid in-node crash repair: when an LLM-generated node CRASHES at runtime (mechanical errors\n # — bad import, removed kwarg, typo), the agent triages it and may repair the code IN PLACE within\n # the same eval (cheap; does NOT consume max_nodes and does NOT add a node to the search tree).\n # Semantic failures still flow to a new idea/debug node via the policy. ON by default; set False\n # to restore the prior behavior (every crash waits for a budgeted inter-node debug node).\n inline_repair: bool = True\n # Max in-place repair attempts per node before it fails normally. Default 0 = UNLIMITED: the same\n # agent keeps fixing + re-evaluating IN THE SAME node until it produces a clean metric — no new\n # node just to retry. Runaway is prevented not by a count but by the anti-stuck guard\n # (`inline_repair_stuck_repeat`): when the SAME error signature repeats with no progress, or the\n # agent's crash-triage says \"abandon\", the node fails. Set a positive N to cap the retries instead.\n inline_repair_attempts: int = Field(default=0, ge=0)\n # Anti-stuck: abandon in-node repair when the SAME error recurs this many times in a row (no\n # progress). Keeps \"unlimited\" repair from looping forever on an unfixable error.\n inline_repair_stuck_repeat: int = Field(default=4, ge=2)\n # Which failure reasons (_failure_reason: crash|timeout|oom|setup|no_metric|drift) are eligible for\n # inline repair. Default: mechanical crashes, timeouts AND OOM-kills — a timeout/OOM means the code\n # was too slow / too memory-hungry for the budget (or the pod's cgroup limit), not that the idea is\n # wrong, so the agent repairs it by reducing compute/memory (fewer estimators/epochs/folds/seeds, a\n # lighter model, a smaller batch, subsampling) instead of letting the node die unrecovered. Env\n # override expects a JSON array.\n inline_repair_reasons: tuple[str, ...] = (\"crash\", \"timeout\", \"oom\")\n # Cost ceiling for in-node repair of a MULTI-STAGE eval: the max number of FULL pipeline re-runs\n # (i.e. re-trains from the first stage) the inline-repair loop may do before abandoning to the\n # inter-node debug operator. A late-stage failure (a broken `score`/eval script that didn't touch\n # `train`) REUSES the completed train checkpoint and re-runs only from the failed stage — that is\n # CHEAP and NOT counted here; only a repair that changes an EARLIER stage's code (train.py/loss.py/…)\n # forces a full re-train, which each costs the whole training time. Without this bound, a repair that\n # keeps changing training code could burn many full trains (the anti-stuck guard is error-signature\n # based, not cost based). 0 = unlimited (legacy behavior). Single-command evals ignore it.\n inline_repair_retrain_cap: int = Field(default=2, ge=0)\n # Environment self-prep: when a solution crashes purely because a KNOWN library isn't installed\n # (ModuleNotFoundError), the engine pip-installs it into the eval interpreter and re-runs — so a\n # torch/XGBoost/CatBoost idea (e.g. a GRU model) actually runs on a fresh box instead of being\n # thrown away as `idea_rejected`. Trusted_local tier only (the untrusted/hostile Docker tiers run\n # --network none). A typo'd or local helper module is NOT installed (that's a code bug to repair).\n auto_install_deps: bool = True\n # Per-package wall-clock budget for an auto-install (seconds). Generous: large wheels like torch\n # can take minutes to download/build.\n dep_install_timeout: float = Field(default=900.0, gt=0)\n # --- Agent governance: who may change a setting at runtime (per-setting allow-list of roles) ---\n # Roles: \"strategist\" (A7 meta-controller; run-wide), \"boss\" (run-chat operator-proxy; run-wide),\n # \"researcher\" (per-experiment proposer; PER-NODE — e.g. set a longer eval timeout for a neural-net\n # idea). A setting ABSENT from this map is LOCKED — no agent may change it, only a human via the\n # snapshot/UI. The sole conditional exception is Strategy-only `card_scoring`: Card mode grants\n # it implicitly without changing the default flag-off snapshot, while an explicit [] revokes it.\n # The gate is ENFORCED at every AGENT seam via `_agent_may` — the strategist's whole\n # control surface (policy/operators/novelty_stance/prefer_sweep/developer/fidelity/timeout/\n # eval_parallel/llm_parallel) is checked in `_apply_strategy`, so removing a role from a knob locks it,\n # not just greys out the UI pill. The boss/max_* grants document boss-relevant knobs, but a\n # `budget_extend` is a HUMAN control intent (the boss action-builder can only emit `add_nodes`), so\n # it is applied as-is — the human always wins via the UI/snapshot. Defaults are conservative: resource/\n # search-shape knobs the agents already reason about; provider infra (llm_model/base_url/api_key,\n # docker_image) stays locked. The UI shows S/B/R pills per setting. `timeout` granted to the\n # researcher IS the \"auto\" per-node mode — the researcher sizes each experiment's wall-clock\n # (Idea.eval_timeout); the config `timeout` is the fallback for nodes it doesn't size. Env override\n # expects a JSON object. (`fidelity`/`novelty_stance`/`prefer_sweep`/`developer` are governance keys\n # for the strategist's per-node dials — not 1:1 Settings fields, but gated the same way.) The default\n # lives in the module-level DEFAULT_AGENT_CONTROL so the Engine's direct-construction default matches.\n agent_control: dict[str, list[str]] = Field(default_factory=default_agent_control)\n # C1 (Agentless localization): for repo tasks, rank the source files most relevant to the most\n # recent failure (traceback + identifiers) and surface them in the proposal/repair prompt so the\n # Developer edits the right place. Off by default; repo tasks only.\n localize_faults: bool = False\n # A2 surrogate-guided proposal (BO-lite): fit a k-NN surrogate over (params->metric) and propose\n # by acquisition instead of random/hill-climb. Numeric-bounds tasks only; bootstraps via the\n # base Researcher. Off by default. `surrogate_explore` = UCB-style exploration weight.\n surrogate_proposer: bool = False\n surrogate_explore: float = 0.1\n # E2 researcher panel: generate K candidate ideas per proposal and keep the one ranked best by a\n # cheap empirical surrogate over the (params->metric) history (NOT an LLM-judge). 1 = off.\n researcher_panel: int = 1\n # A1 ASHA / successive-halving: reduction factor (keep top 1/eta per rung) and rung budget.\n asha_eta: int = Field(default=3, ge=2)\n # Rung-0 width for ASHA/BOHB (the wide base of cheap drafts). 0 = use n_seeds (default,\n # preserving prior behavior); set >0 to seed a wider/narrower base independent of n_seeds.\n asha_rung_nodes: int = Field(default=0, ge=0)\n # A6 proxy/predictive scoring: cheaply rank a candidate's potential from early-stage signals\n # and skip a full eval for the doomed bottom fraction. 0.0 = off (no candidate skipped).\n proxy_scoring: bool = False\n proxy_kill_fraction: float = Field(default=0.0, ge=0.0, le=0.9)\n # === Novelty / dedup ==================================================================\n # E1 novelty/dedup gate: reject near-duplicate proposals (normalized param-space distance below\n # `novelty_epsilon`) by deterministically nudging them off the duplicate — stops the search\n # wasting evals re-trying the same idea. Audit event `novelty_rejected`. Off by default.\n # Novelty/dedup MODE selector: how a fresh proposal is checked against already-tried experiments.\n # \"off\" — no explicit gate; the agentic Researcher's own read-the-history judgment is trusted.\n # \"algo\" — the deterministic gate: numeric param-distance (`novelty_epsilon`) + optional embedding\n # similarity (`novelty_semantic`). Cheap, no LLM call, but can't explain itself.\n # \"llm\" — an LLM adjudicates (reads the real experiments via tools) whether the idea is a\n # near-duplicate and, if so, asks the Researcher once more for something different. One\n # extra LLM call per proposal — highest quality, follows the \"let the LLM decide\" line.\n novelty_mode: str = \"llm\"\n # Legacy sub-toggles, honored by the \"algo\" mode (and back-compat: novelty_gate=True forces \"algo\"):\n novelty_gate: bool = False\n novelty_epsilon: float = 0.05\n # T5 semantic novelty (active whenever the deterministic gate runs — novelty_mode=algo,\n # novelty_gate=true which is the legacy alias for algo, OR the Strategist novelty stance is\n # \"explore\"; a no-op only under novelty_mode=llm/off with a non-explore stance): reject a proposal\n # whose idea TEXT embeds within\n # `novelty_semantic_threshold` cosine of an existing node's. OFF BY DEFAULT: novelty is the agentic\n # Researcher's call, made by READING the actual prior experiments (read_experiment /\n # find_analogous_across_runs / list_experiments), NOT an embedding-similarity auto-reject that can't\n # explain itself and mis-fires on paraphrases. Semantic/param search stays available as TOOLS in the\n # Researcher's context; the LLM decides whether an idea is a true duplicate.\n novelty_semantic: bool = False\n novelty_semantic_threshold: float = Field(default=0.92, ge=0.5, le=1.0)\n # T10: debug-lineage depth bound for every policy — how many error-feedback repairs a failing\n # lineage gets before it is abandoned. The old default (1) abandoned lineages after ONE repair;\n # multi-turn/deeper debugging is a verified lever (AIRA2 ReAct debug +5.5 percentile points).\n debug_depth: int = Field(default=2, ge=1)\n # P4 operator bandit (GreedyTree): replace the fixed merge/ablate cadences with a\n # deterministic UCB over per-operator yield (Δmetric per eval-second, folded from the run).\n # Off by default (the cadences are well-tested and the bandit has no direct published\n # ablation); `thorough` turns it on.\n operator_bandit: bool = False\n # P1 hypothesis ledger: ask the Researcher to state the one-line hypothesis each experiment tests,\n # register deep-research directions as hypotheses, and track them to a verdict on the board. ON by\n # default. The board is shown to the Researcher and can be foresight-ordered, so it can\n # steer later proposals even though it never directly re-ranks already-evaluated nodes. Set False\n # to drop the prompt nudge + registration.\n track_hypotheses: bool = True\n # E4/M2/M3 reflection-memory -> priors (gradient-free cross-run meta-learning): at run end distill\n # the winner into `<memory_dir>/meta_notes.jsonl` AND structured lessons (incl. NEGATIVE results:\n # tested/abandoned hypotheses + failure themes) into `lessons.jsonl` with a task fingerprint; at run\n # start, inject exact-task notes + fingerprint-matched lessons from SIMILAR tasks into the proposal\n # prompt. ON by default — but a NO-OP until `memory_dir` is set (that's where cross-run memory lives).\n reflection_priors: bool = True\n # M6 comparative lessons (MARS \"comparative reflective memory\", doc 13 §7 item 2): distill\n # credit-assigned lessons from PAIRS of solutions — which SPECIFIC change made a child beat\n # (or regress from) its parent, and what fixed a failure — on top of the one-shot run-end\n # reflection. At most one extra LLM call per distillation (all pairs batched); offline a\n # deterministic param-diff credit stands in. Gated on reflection_priors + memory_dir like the\n # rest of cross-run memory — and since memory_dir has a default (~/.looplab/memory), this is\n # ACTIVE out of the box; clear memory_dir to disable cross-run memory wholesale.\n comparative_lessons: bool = True\n # M6 mid-run cadences (doc 13 §7 item 5 — the AgentRxiv live-share pattern): every N created\n # nodes, WRITE comparative lessons to the shared cross-run store during the run (lessons_every\n # — one batched LLM call per firing) and RE-READ the store so lessons distilled by CONCURRENT\n # runs reach this run's proposals (lessons_refresh_every; file re-read, no LLM call, skipped\n # when the store is unchanged). ON by default (every 4 nodes) and, like comparative_lessons\n # above, live out of the box because memory_dir defaults on; 0 = off — run-end write /\n # run-start read only, the pre-M6 behavior.\n lessons_every: int = Field(default=4, ge=0)\n lessons_refresh_every: int = Field(default=4, ge=0)\n # B3 output redaction: mask credentials (known key shapes + high-entropy tokens) in the\n # stdout/stderr tail before it is persisted to the event log / spans / UI — a leaked secret in a\n # print()/traceback must not land in the durable log. Off by default; recommend on for untrusted.\n redact_output: bool = False\n # B5 reward-hacking detector: a host-side monitor that flags suspicious wins (grader/answer-key\n # access, runtime writes to frozen files, suspiciously-perfect metrics) as a `reward_hack_suspected`\n # audit event in the Trust panel. Off by default. Whether a flag CHANGES selection is governed by\n # `trust_gate` below (default: audit-only, never changes selection).\n reward_hack_detect: bool = False\n # Trust enforcement (T2): what a reward-hack / data-leakage flag actually DOES to selection.\n # \"audit\" (default) = surface it in the Trust panel, never change selection (today's behavior);\n # \"gate\" = a flagged node can no longer be selected as BEST **and isn't bred/confirmed from**\n # (breed_excluded — see replay._apply_trust_gate / models.breedable_nodes), but stays\n # FEASIBLE so it still counts for diversity/audit — closes the \"a hacked/leaky node can\n # win OR seed its lineage\" hole;\n # \"block\" = additionally mark it fully INFEASIBLE (removed from feasible_nodes entirely).\n # Deliberately gates only high-precision CHEATING/LEAKAGE signals. Broad `critic:*` findings stay\n # advisory; `critic:hardcoded_metric` is the one narrow high-precision critic exception\n # and gates because it requires a literal metric with no computed assignment anywhere.\n trust_gate: str = \"audit\"\n # I3 data-centric: static code-leakage scan of each evaluated solution (fit-before-split,\n # fit-on-test) surfaced into the Trust panel alongside reward-hack flags. Off by default.\n code_leakage_detect: bool = False\n # 4.4 sandbox instrumentation (needs reward_hack_detect): after each eval, flag a RUNTIME write\n # to a protected/frozen file (an answer key or grader tampered mid-run) — behavioral evidence a\n # static code scan misses. ON by default; only fires when the reward-hack detector is on.\n workdir_audit: bool = True\n # D8 decoupled research Verifier: check every Deep-Research memo's CLAIMS against their cited\n # evidence (node ids / urls) before recording — synthesis is the documented weak link (Kosmos\n # 57.9% accurate). Deterministic layer always; an LLM rubric pass when the DeepResearcher has a\n # client. The verdict cannot change this run's node/champion, but finalize uses aligned supported\n # verdicts as the evidence gate for persisted D8 cross-run claims; unavailable/misaligned stays\n # unverified. ON by default (no-op with no memo/claims).\n research_verify: bool = True\n # C4 independent critic: an execution-free critic of each solution (stub / hardcoded-metric /\n # params-ignored; on host-graded tasks the metric checks become a submission-output check)\n # surfaced in the Trust panel. Broad findings are advisory; `critic:hardcoded_metric` can gate under\n # trust_gate=gate/block. Off by default.\n critic_check: bool = False\n # A7 Strategist (NEW, user-requested): optional LLM/rule meta-controller that picks the search\n # policy/allocator + operator mix + fidelity (+ Developer backend) per situation. Config-first:\n # every choice the Strategist makes is also a direct knob. Defaults to \"agent\" so the agent adapts\n # the search policy/operators/fidelity per situation; set \"off\" for static config-driven search.\n # \"agent\" = a tool-using Strategist that READS the run/data/sibling-runs/KB/memory (B1-guarded)\n # before deciding, instead of the single-shot \"llm\" call over aggregate stats.\n strategist_backend: str = \"agent\" # \"off\" | \"rule\" | \"llm\" | \"agent\" — default AGENTIC: the\n # Strategist READS the run/data/sibling-runs/KB/memory with tools before deciding, not a single-shot\n # call over aggregate stats. \"llm\" = the old non-agentic single-shot; \"rule\"/\"off\" = no LLM.\n strategist_every: int = Field(default=3, ge=1) # consult cadence (created nodes)\n # PART V (F1): concept CLASSIFIER re-tag + consolidation cadence, DECOUPLED from strategist_every. The\n # LLM concept map is heavier and slower-moving than a strategy consult, so it refreshes on its own\n # (sparser) interval. Researcher-authored idea.concepts still fold immediately at node_created — this\n # only paces the classifier-evidence + consolidation refresh (and the concept-coverage pivot snapshot).\n concept_retag_every: int = Field(default=30, ge=1)\n # === Confirmation & holdout ===========================================================\n # Multi-seed confirmation (I12, ADR-15): confirm the top-k under N seeds before\n # finishing. 0 disables (default). Only meaningful when eval has variance.\n confirm_top_k: int = 0\n confirm_seeds: int = 0\n # D1 seed-holdout discipline: the FIRST seed the confirm phase uses. Search evals run with the\n # implicit seed 0 (LOOPLAB_EVAL_SEED unset -> \"0\"), so a base of 1 keeps every confirm split\n # DISJOINT from anything the search optimized against — the confirm metric is then a\n # generalization signal, not a re-measurement (AIRA: selecting on a signal the search saw\n # overfits by 9-13 pp). Set 0 to restore the legacy overlapping seeds 0..N-1.\n confirm_seed_base: int = Field(default=1, ge=0)\n # D1 holdout-gated promotion (B6, Arbor-style): for host-graded tasks, reserve this fraction of\n # the held-out labels as a FINAL holdout partition the search never sees — every search/confirm\n # eval is scored on the remaining rows only, and at finish the val-top-k are re-scored on the\n # holdout partition (free: the predictions already exist). 0.0 = off (legacy full-label scoring).\n holdout_fraction: float = Field(default=0.25, ge=0.0, le=0.9)\n # Champion selection prefers the HOLDOUT metric among the nodes that have one (the val-top-k).\n # Recorded in run_started so replay applies the same rule; old logs fold to False (legacy pick).\n # The per-node val-holdout `generalization_gap` folds into the Trust panel either way.\n holdout_select: bool = True\n # How many val-leaders get a holdout evaluation at finish (host-graded tasks).\n holdout_top_k: int = Field(default=3, ge=1)\n # R1-c / Part IV — calibrated §12-verifier tie-break in best-selection. When a fresh node's robust\n # metric EXACTLY TIES an already-eligible node, the engine runs the §12 verifier (grounded on each\n # node's realized result, `selection_criteria`) and records a soundness score per node. The tie-break\n # then applies across the WHOLE selection path: the mean pick breaks a robust_metric tie by the score,\n # and — when `holdout_select` is on — the holdout-slot ranking prefers the sounder node so it isn't\n # denied a holdout eval, and the final holdout override breaks a holdout_metric tie by it too. Strictly\n # a TIE-BREAK — it can NEVER move a node ahead of a strictly-better metric (§21.7 advisory-never-\n # overrides). Best-effort: verification is LAZY (only fires on an actual tie) and bounded per cadence,\n # so a large/late tie stays on deterministic metric+id order unless one atomic treatment scores every\n # member. Recorded in run_started so\n # replay applies the same rule; old logs fold to False (legacy pick). Opt-in (default off); needs a\n # reachable LLM client. Enable only after validating calibration offline with `verifier.calibrate`.\n # See trust/verifier.py + core/fitness.py.\n select_verifier: bool = False\n # R1-d (§21.19): widen the verifier tie-break from EXACT-metric to a conservative STATISTICAL tie.\n # The band is capped by both the metric leader's SE and the pooled SE-of-difference, so challenger\n # variance cannot widen it and a significant difference is never a tie (§21.7). Off -> exact only.\n verifier_ci_tie: bool = False\n # Verifier sample count for `select_verifier` (the §12 repeated-sampling expectation). 3 tames the\n # single-shot variance; 1 = single-shot (cheaper, noisier).\n select_verifier_samples: int = Field(default=3, ge=1, le=32)\n # Budget (I13): hard wall-clock ceiling; the run aborts cleanly when exceeded.\n max_seconds: float | None = None\n # Eval-compute budget (#2): hard ceiling on cumulative time spent INSIDE evals (training\n # runs), separate from wall-clock. Survives resume (summed from the event log). The real\n # guard against a silent multi-hour sweep when an eval is a minutes-hours training run.\n max_eval_seconds: float | None = None\n # Cross-run memory (I19, ADR-10): if set, the best result of each run is stored as\n # a case here, and the cases become retrievable knowledge for future runs.\n memory_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"memory\"))\n # HITL (I21, ADR-11): pause for human approval of the final best before finishing.\n require_approval: bool = False\n # Diversity archive (I22): niche bucket width in parameter space.\n archive_resolution: float = 1.0\n # Coverage read-model (narrowing signal): at the strategist cadence, compute a breadth summary\n # (themes / param-niches / theme entropy / dominant-theme fraction) from the folded run, record\n # it as a `coverage_snapshot` audit event, and surface it into the Strategist's decision context.\n # Deterministic, cheap, and purely additive context — no search-behavior change on its own. On by\n # default (like the rest of the always-on situational context). See search/coverage.py.\n coverage_context: bool = True\n # PART IV Phase 2a live steering (§21.11/§21.13). When on, the `concept_retag_every` cadence (NOT\n # `strategist_every` — the producer gates on `_should_consult_concepts`, which uses the seed boundary\n # + `concept_retag_every`, default 30) records a\n # concept-graph coverage + uncovered-region snapshot (deterministic heuristic tagger over the\n # task-type skeleton) and, on an `explore` stance, the Researcher's novelty hint names the exact\n # uncovered regions (\"0 coverage in {negatives/external-mining, distillation} — go there\") instead\n # of the vague \"broaden\". ON by default in the product Settings (ce4a379), EngineOptions off: it\n # only enriches an already-mode-gated explore hint, never forces exploration, and no-ops for a task\n # with no curated concept skeleton. The snapshot is auditable, but its prompt cue can change future\n # candidate generation; it does not directly rewrite best-metric selection. See search/concept_graph.py.\n # DESIGN NOTE (2026-07-17 critique): now ON unconditionally, but a toy / non-ML task yields EMPTY concept\n # tags while still paying the per-node LLM concept cost (~10 calls/node with the full Part IV/V bundle).\n # Consider AUTO-QUIESCING: if the heuristic tagger returns empty for the first N nodes (no concept signal),\n # skip the LLM concept work for the run — keeps it on for ML tasks, free on toy/degenerate ones.\n concept_pivot: bool = True\n # PART IV Phase 2b — D3 graded novelty into the LIVE novelty gate (§21.4/§21.13). When on and the\n # task has a curated concept skeleton, the novelty gate first grades a proposal over the concept\n # graph: a level-4 \"same direction, DIFFERENT implementation\" or a level-5 \"re-opens a\n # wrongly-abandoned FAILED direction\" proposal is ALLOWED through unchanged — the flat LLM/semantic\n # dedup gate can't tell \"this DCL tweak\" from \"the whole DCL branch\" and would wrongly reject a\n # legitimate variant or never re-open a sound-but-killed direction (the node_63 archetype). Levels\n # 1/2/3 (identical / near-dup / prior-run) still fall through to the existing gate. Deterministic\n # (heuristic tagger, no LLM), audit event `novelty_graded`, replay-safe. ON by default in the product\n # Settings (ce4a379), EngineOptions off; no-ops for a task with no skeleton. See search/graded_novelty.py.\n graded_novelty: bool = True\n # PART IV Phase 2b — D7 capability-expansion forced-jump DIRECTIVE (§21.8/§21.13, issue #7). When on\n # and the concept-graph cadence detects action-space LOCK-IN (the search has stayed inside one D5\n # branch for a long consecutive streak) on an `explore` stance, the Researcher's novelty hint\n # ESCALATES from \"broaden\" to \"expand the ACTION SPACE — build the missing infra (external mining,\n # a new data pipeline, a different eval), do NOT swap another {locked} lever.\" The same\n # gate also stamps the proposal's authoritative operator as `expand`; that node competes normally\n # under SearchFitness and its separate operator yield is measured. It does not force a winner.\n # Reads the 2a concept-coverage snapshot, so it needs `concept_pivot` to record one. OPT-IN\n # (default off). See engine/proposal_cues.py + search/lock_in.py.\n capability_expansion: bool = False\n # PART IV cross-run Step 0 (§21.20.12/§21.20.13). Universal task-fingerprint tokenization: drop the\n # ASCII-only `[a-z0-9]` allowlist on goal keywords so a non-Latin goal (Russian, CJK, …) is NOT\n # silently dropped from its cross-run fingerprint (today such a goal keys only on kind/dir/metric and\n # never reaches SIMILAR-task priors/lessons/cases). Uses `[^\\W_]+`/`.casefold()` — same splitting as\n # before, any script. ON by default in the product Settings (ce4a379); the bare-library EngineOptions\n # default stays off, so a run pinned to it won't silently re-key a portfolio mid-flight. See engine/memory.py.\n fingerprint_universal: bool = True\n # PART IV cross-run Step 2 (§21.20). Fill graded-novelty's `prior_concepts` from the cross-run\n # ConceptCapsuleStore: at run end write a per-run concept capsule (the shipped `node_concepts` tags +\n # best outcome, keyed by task_fingerprint) to `memory_dir`; when a later SIMILAR run proposes an idea\n # whose concept was tried before, SURFACE the prior outcome as a `cross_run_prior` audit event (never\n # reject — D3 level 3). Audit-only, off the selection path; ON by default in the product Settings\n # (ce4a379), EngineOptions off. Needs a `memory_dir` to share capsules.\n # See engine/memory.py (ConceptCapsuleStore) + engine/novelty.py.\n # PREREQUISITES (this flag is NOT standalone): the WRITE needs per-run concept tags\n # (`node_concepts`, produced by `concept_pivot`/F1 tagging) — no tags => no capsule; the SURFACE path\n # runs inside the graded-novelty precheck, so it also needs `graded_novelty` on. With only this flag +\n # `memory_dir` it persists/surfaces nothing. Enable `concept_pivot` + `graded_novelty` alongside it.\n # A non-Latin (Russian/CJK) portfolio should ALSO set `fingerprint_universal`, else the capsule's goal\n # tokens are dropped and its fingerprint over-matches on kind/dir/metric alone (the novelty direction\n # gate narrows this, but goal-word similarity is still lost).\n cross_run_concepts: bool = True\n # PART V (B): run-base + node-DELTA concept authoring. When on, once the first evaluated node has\n # authored concepts the engine seeds RunState.run_base_concepts from them (a one-shot EV_RUN_CONCEPTS,\n # idempotent because it only fires while the base is empty), and the Researcher's proposal hint tells it\n # to author only `concepts_added`/`concepts_removed` vs the run base + its parent — minimal per-node\n # annotations that inherit down the DAG (a swap propagates). Off -> every node authors its full concept\n # set as before. Product-on; the bare EngineOptions mirror defaults off (test_options_divergence).\n concept_run_base: bool = True\n # PART IV cross-run Step 5 advisory (§21.20.5). Fold the bounded cross-run CONTEXT PACK — evidence-\n # grounded claims with BOTH support and counter-evidence (Step 4) + a portfolio-coverage line (Step 3) —\n # into the Researcher's proposal prompt, exactly like the E4 cross-run prior note. Advisory ONLY: it is\n # prompt-grounding: it can change the Researcher's later candidates, but does not directly rewrite\n # best-metric selection (§21.7). Reads `memory_dir` (lessons.jsonl + concept_capsules.jsonl); \"\" when\n # empty. ON by explicit experimental product choice; no frozen A/B validation is claimed. The\n # bare-library EngineOptions default stays off (engine/options.py, frozen in\n # test_options_divergence). See engine/proposal_cues.py.\n cross_run_advisory: bool = True\n # PART IV cross-run §21.20.13 (full CR of the lean fuzzy claim merge). Switch the claim read-model to the\n # SCOPE+POLARITY-safe STRUCTURED claim key (engine/claim_key.py): claims from different tasks never\n # merge, opposite polarity (\"X helps\" vs \"X never helps\") is surfaced as a CONTRADICTION instead of being\n # collapsed, paraphrase/inflection variants group by exact structured key (no transitive over-merge), and\n # operator governance is scope-precise (a decision in task A cannot reach a same-worded claim in task B).\n # Affects the `cross_run_advisory` context pack only; ON by default in the product Settings (ce4a379);\n # the bare-library EngineOptions default stays off (engine/options.py). See engine/claims.py.\n cross_run_structured_claims: bool = True\n # PART IV cross-run §22.4 (AGENTIC taxonomy steward). At finalize, when an LLM client is available, let\n # the concept steward (engine/concept_steward.py) review the freshly-updated portfolio concept graph and\n # PROPOSE a curation — merge duplicate slugs / split conflated ones / purge noise. Proposals are LOGGED to\n # `concept_curation_log.jsonl` for operator ratification (via the serve/CLI governance surface); the LLM\n # never mutates fold state. The calls run synchronously during finalize, so they add latency and may add\n # paid inference; a caught steward failure does not prevent terminal completion or trigger a retry.\n # ON by explicit experimental product choice when `memory_dir` + an LLM backend are present; the\n # bare-library EngineOptions default stays off (engine/options.py). See engine/lessons.py.\n cross_run_curation: bool = True\n # Deprecated compatibility flag. Steward output is untrusted and finalize is proposal-only regardless\n # of this value; retained so old snapshots still validate and logs can disclose that auto was requested.\n cross_run_curation_auto: bool = False\n # Role backend (ADR-7/14): \"toy\" (offline optimizer) | \"llm\" (live model). Validated in\n # `_check_trust_gate` alongside the other enum-ish strings — every consumer tests `== \"llm\"`\n # exactly (cli/__init__.py, adapters/tasks.py), so an untyped `--set`/file/env typo (or a mis-cased\n # \"LLM\") used to fall through to the OFFLINE toy roles and quietly produce a run that never called\n # the model. Left as `str`, not `Literal`, so old snapshots still validate through the same path.\n backend: str = \"toy\"\n # Developer backend (ADR-7): \"default\" (templated/LLM from the task) or an external\n # CLI coding agent: \"opencode\" | \"aider\" | \"goose\" | \"continue\". A CLOSED enum (\"default\" +\n # cli_agent.PRESETS keys), validated loudly in `_check_trust_gate` — an unknown value used to reach\n # adapters/tasks.py (`developer_backend not in PRESETS`) and silently wire the DEFAULT in-house\n # developer, the same silent downgrade the `backend` guard closes.\n developer_backend: str = \"default\"\n # C2 best-of-N: generate N candidate implementations per node and keep the best by an\n # execution-free reward (static validity + metric-print). 1 = off. In-house LLM developer only\n # (not external agents — ADR-7 cost rule). The top SWE-bench reliability lever for weak models.\n best_of_n: int = 1\n # D10 list-wise best-of-N selection: when the top static-scorers TIE, break the tie with a\n # comparative LLM selection over the candidates presented together (+~3 pts vs pointwise on\n # GAIA, arXiv:2506.12928). The static score stays the primary filter — the LLM is a weak\n # comparative prior, never the sole oracle. ON by default (no-op when best_of_n==1).\n best_of_n_listwise: bool = True\n # === Foresight / predict-before-execute ===============================================\n # FOREAGENT predict-before-execute (arXiv:2601.05930): use the LLM as an IMPLICIT WORLD MODEL to\n # predict — WITHOUT executing — which candidate / hypothesis will score best, primed with a\n # Verified Data Analysis Report (data profile / brief) + the accumulated experiment memory. Master\n # switch, ON by default; a no-op wherever there is nothing to rank (best_of_n==1 and\n # foresight_panel==1) or no LLM client. See `looplab/search/foresight.py`.\n foresight: bool = True\n # Hypothesis foresight breadth: generate K candidate ideas per proposal and keep the one PREDICTED\n # best pre-execution by the world model — it ranks the STRUCTURAL / text ideas the numeric k-NN\n # surrogate (researcher_panel) is blind to, primed with the data profile + memory (the synergy).\n # >1 enables it (LLM backend only; needs `foresight` on); 2 = on by default at modest cost, 1 = off.\n foresight_panel: int = 2\n # AGENTIC foresight: run the ranking (hypothesis-board prioritization + K-idea pick) as a TOOL-USING\n # loop that can pull actual experiment results / data facts before deciding, instead of a one-shot\n # prediction from a pre-baked report. ON by default (needs foresight + a client; falls back to the\n # one-shot predictor on any hiccup). A few extra LLM calls per proposal; set False for the cheaper\n # single-call ranker.\n foresight_agentic: bool = True\n # §1 foresight confidence gate: the minimum predicted confidence at which a predict-before-execute\n # pick is ACTED ON. Below it the ranker abstains — the K-idea panel falls back to the first\n # proposal, best-of-N falls through to the D10 tie-break — instead of committing a low-confidence\n # choice. 0.0 (default) = OFF (act on every pick, the historical behavior); raise toward ~0.5 to\n # make the world model defer when it isn't sure. Pairs with the foresight track record (§1) the\n # predictor is now primed with. Range 0.0-1.0.\n # BOUNDED like its confidence/fraction siblings (train_monitor_kill_confidence,\n # asha_live_quantile). Unbounded, a typo'd 5.0 validated silently into a gate no score can ever\n # clear, so every foresight pick abstained forever with nothing in the snapshot to say why.\n foresight_min_confidence: float = Field(default=0.0, ge=0.0, le=1.0)\n # PART IV Phase 2c — replace the world model's SELF-REPORTED confidence (measured Pearson≈0 with the\n # realized outcome, §21.12) with a CALIBRATED §12-verifier score. When on and a client is available,\n # after the K-idea ranker picks the predicted-best candidate the §12 verifier scores it (grounded +\n # repeated + criteria-decomposed: `foresight_criteria` — likely to improve the objective, and\n # sound/feasible) and THAT calibrated score becomes the confidence the `foresight_min_confidence` gate\n # and the telemetry use. Degrades to the self-reported confidence without a client or on any verifier\n # error (the telemetry records which source was used). ON by default in the product Settings (ce4a379),\n # EngineOptions off; a few extra LLM calls per acted-on proposal. See looplab/trust/verifier.py + search/foresight.py.\n # REVISIT (cost/quality, not yet resolved): a live classification run measured ~10 LLM calls & ~3.8 min/node\n # on deepseek-v4-flash with the full Part IV/V default bundle; the calibrated verifier is part of that per-node\n # tax. Kept ON deliberately — it raises decision quality (self-reported foresight confidence has ≈0 Pearson\n # with realized outcome), and lowering quality to save calls is the wrong trade. TODO: run a frozen quality/cost\n # A/B (verifier-on vs -off) to quantify the recall@objective gain per extra call before considering a default flip.\n foresight_verify: bool = True\n # Verifier sample count for `foresight_verify` (the §12 repeated-sampling expectation on a no-logprob\n # backend). 3 tames the single-shot variance §21.12 measured; 1 = single-shot (cheaper, noisier).\n # RESUME-critical: this value is persisted in config.snapshot.json, so a hard `le` schema bound would\n # make an old run started with a larger LOOPLAB_FORESIGHT_VERIFY_SAMPLES un-resumable (Settings(**snapshot)\n # would raise on `looplab resume` / finalize-recovery / serve replay). CLAMP into [1, MAX] via a validator\n # instead of rejecting — the runtime cap is preserved without breaking the snapshots/env-compat rule.\n foresight_verify_samples: int = 3\n\n @field_validator(\"foresight_verify_samples\")\n @classmethod\n def _clamp_foresight_verify_samples(cls, v: int) -> int:\n # Coerce a persisted/env value into [1, MAX] rather than reject (see field note above).\n try:\n v = int(v)\n except (TypeError, ValueError):\n return 3\n return max(1, min(v, MAX_FORESIGHT_VERIFY_SAMPLES))\n\n # T7 LLM response cache: serve an identical DETERMINISTIC (temperature 0) request from an\n # in-process content-addressed cache instead of re-hitting the model — cuts cost on\n # retry/panel/verify flows. NEVER caches sampling calls (temp>0: best-of-N / panel / novelty\n # re-propose must vary). Off by default (most role calls run at temperature>0 anyway).\n llm_cache: bool = False\n agent_cmd: str | None = None # override the agent's launcher (path / wrapper)\n # External-agent validation (ADR-7): wrap a CLI-agent Developer with a validator that\n # audits each output (no-op/syntax/crash/timeout), retries with feedback, and falls\n # back to the in-house LLM Developer. Off -> use the raw agent output unchecked.\n validate_agent: bool = True\n agent_max_retries: int = 1 # re-prompts of the agent on an invalid result\n # Patch-gated multi-file agent (ADR-7 Rule 3): run the CLI agent in a git worktree and\n # accept only edits whose paths match `agent_surface` (reject-not-strip out-of-surface\n # touches). Lets the agent create helper modules, not just solution.py. Degrades to\n # whole-file readback if git is unavailable.\n agent_patch_gate: bool = True\n agent_surface: list[str] = [\"*.py\"] # edit-surface allow-list (globs)\n # Trust policy for an agent-authored eval/metric adapter (RepoTask onboarding, Phase 3):\n # \"ratify_freeze\" waits for human approval, \"autonomous\" accepts the proposed spec without that\n # gate, and \"ratify_freeze_drift\" also enforces the configured independent drift cross-check.\n eval_trust_mode: str = \"ratify_freeze\"\n # RepoTask node seeding policy (run-wide fallback; a task/editable `seed_mode` overrides):\n # \"auto\" (default) copies git-tracked files when the editable is a git repo (so a tree bloated\n # with untracked artifacts isn't deep-copied per node), else a full copy; \"tracked\" forces\n # code-only; \"all\" forces a full recursive copy (legacy). Genesis authors per-task from the\n # user's words; this is the global default when unspecified.\n seed_mode: str = \"auto\"\n llm_model: str = \"qwen3:8b\"\n # === LLM / transport ==================================================================\n llm_base_url: str = \"http://localhost:11434/v1\" # Ollama OpenAI-compatible endpoint\n llm_temperature: float = 0.6\n # H3 per-role model presets (5090 recipe): optionally run the Researcher and Developer on\n # DIFFERENT models/endpoints (e.g. Developer=Qwen3-Coder-30B for code, a fast model for breadth).\n # Blank = use the shared llm_model/llm_base_url. Generalizes the role-backend swap to per-role.\n researcher_model: str | None = None\n developer_model: str | None = None\n researcher_base_url: str | None = None\n developer_base_url: str | None = None\n # Per-role sampling temperature (§4.1): the Researcher wants breadth (higher temp = more diverse\n # ideas), the Developer wants determinism (lower temp = fewer syntax slips), the Strategist a\n # steady hand. Each overrides the shared `llm_temperature` for that role only; None = use the\n # shared value (byte-identical to before). Flat + nullable, mirroring the per-role model fields.\n researcher_temperature: float | None = None\n developer_temperature: float | None = None\n strategist_temperature: float | None = None\n # The Strategist had per-role TEMPERATURE but no model/endpoint of its own, so in split mode it\n # always ran on the shared llm_model however the other roles were pointed. These close that gap\n # with the same names/shape as the pair above; blank = its profile, else the shared value.\n strategist_model: str | None = None\n strategist_base_url: str | None = None\n # === Connection profiles (multi-provider) =============================================\n # A \"profile\" is a NAMED connection: {model, base_url, temperature, api_key_env, provider}. Roles\n # point at one by name through `role_profiles`; `llm_profile` is the default for every role that\n # doesn't. All three are EMPTY by default and the resolver short-circuits to the shared\n # llm_model/llm_base_url/llm_api_key path when they are — an operator running one model never\n # needs to know the word \"profile\".\n #\n # A profile stores `api_key_env`, the NAME of an environment variable, never the key itself. That\n # is ADR-11's \"a secret is a reference, not a value\" turned from a convention into a data type:\n # the whole profile map is then safe to write into config.snapshot.json, hand back over HTTP and\n # render into LOOPLAB_* — there is nothing in it to mask. It is also what makes per-role\n # CREDENTIALS expressible at all: two roles on one provider can carry different keys, which a\n # key-per-endpoint map could not say.\n llm_profiles: dict[str, dict] = Field(default_factory=dict)\n llm_profile: str | None = None\n role_profiles: dict[str, str] = Field(default_factory=dict)\n # Unified self-driving agent: one LLM identity that plays Researcher + Developer (+ Strategist)\n # across pipeline stages, choosing its own model/toolset per stage. ON by default — the agent\n # drives the loop (incl. crash triage: repair/abandon/reject_idea) and, via\n # `agent_drives_actions`, picks the next macro action within a pure legal-action gate. Set both\n # to False for the legacy byte-identical split-role behavior. (No-op unless backend=\"llm\".)\n unified_agent: bool = True\n agent_drives_actions: bool = True\n # Layer 3 Card queue ownership. False preserves the existing policy/unified-pilot action path.\n # True is pinned in run_started because changing the selector on resume would mix two search\n # treatments in one run. When both this and agent_drives_actions are true, Card selection wins.\n card_driven_selection: bool = False\n # Layer 5 bounded speculative Card buffer. Zero is a hard OFF switch and preserves the\n # historical alternating build/eval spine; positive values are pinned by run_started so a\n # resume cannot silently mix search treatments after a snapshot/default edit.\n speculation_depth: int = Field(default=0, ge=0, le=64)\n # Local evidence receipt produced by ``looplab speculation-gate``. This is deliberately absent\n # from the curated Settings UI: positive-depth execution is a calibrated rollout decision, not a\n # casual tuning knob. The Engine validates the receipt against the current scorer,\n # implementation, GPU inventory and raw paired-run evidence before it enables speculation.\n speculation_gate_receipt: str | None = Field(default=None, max_length=4096)\n # Per-stage model/endpoint overrides for the unified agent. Recognized keys (see\n # tasks.build_unified_agent): `propose` (researcher), `implement`/`repair` (developer),\n # `strategy`, `pilot`. Unlisted keys are ignored. Empty = the per-role researcher_*/developer_*\n # models, then the shared llm_model. Explicit map wins. No-op unless `unified_agent` is true.\n agent_stage_models: dict = {}\n agent_stage_base_urls: dict = {}\n llm_parser: str = \"tool_call\"\n # Reasoning/thinking toggle sent IN the request (providers differ — see `llm.reasoning_body`):\n # Qwen3 on vLLM/SGLang -> chat_template_kwargs.enable_thinking (bool)\n # OpenAI/Ollama-v1/DeepSeek -> reasoning_effort (low|medium|high|none)\n # `llm_reasoning`: \"\" = send nothing (server default — current behavior); off = actively disable;\n # on = enable at default depth; low|medium|high = enable at that effort.\n # Defaults to \"high\" so the agent reasons before proposing/repairing/triaging (better code, fewer\n # crashes); set \"\" to send nothing (server default) or \"off\" to disable. The model's chain-of-thought\n # is CAPTURED either way (reasoning_content field or inline <think>). NOTE: to get a SEPARATE\n # reasoning_content field from SGLang/vLLM, the server also needs `--reasoning-parser qwen3`.\n llm_reasoning: str = \"high\"\n llm_reasoning_style: str = \"auto\" # auto | qwen | effort | none — how to shape the request param\n llm_reasoning_extra: dict = {} # raw fields merged into the body (escape hatch, e.g. Anthropic thinking)\n # H1: drive structured calls with the endpoint's constrained decoding (vLLM/SGLang guided_json +\n # response_format json_schema) so weak models can't emit invalid JSON. Off for Ollama (default).\n llm_guided_json: bool = False\n # Stream every LLM request (SSE) and reassemble it, so the request `timeout` is an INTER-TOKEN\n # idle timeout rather than a whole-request deadline: a long-but-alive generation is never cut off\n # (tokens keep resetting the timer), while a genuinely STALLED endpoint (no token for `timeout` s)\n # is detected and retried on a fresh connection — the fix for silent multi-minute hangs. Set False\n # to use one blocking read (old per-op timeout semantics) if an endpoint streams badly.\n llm_stream: bool = True\n # LLM IDLE timeout (seconds). Stream mode: the INTER-TOKEN stall limit — a steady generation is\n # never cut off, only a silent endpoint. Non-stream: bounds the whole read. Raise for endpoints\n # with a long prefill on huge prompts; lower to fail fast on a flaky shared endpoint.\n llm_timeout: float = Field(default=180.0, gt=0)\n # First-byte (response-headers) window for STREAM attempts, seconds: an SSE response sends its\n # headers on admission, so no-headers ≈ a black-holed request — fail over to a fresh connection\n # fast instead of waiting the full idle timeout. Clamped to llm_timeout. Non-stream attempts are\n # NOT bounded by this (their headers arrive only after the whole generation). The default mirrors\n # `DEFAULT_HEADER_TIMEOUT_S` in looplab/core/llm.py is the single source (imported above).\n llm_header_timeout: float = Field(default=DEFAULT_HEADER_TIMEOUT_S, gt=0)\n # Honour HTTP(S)_PROXY / NO_PROXY / SSL_CERT_FILE env vars for LLM calls. Default False: the\n # transport connects DIRECTLY (many internal/OLAP endpoints are reachable only by bypassing an\n # ambient proxy — a picked-up proxy yields \"connection refused\"). Set true when the endpoint is\n # reachable ONLY via a corporate proxy or needs a custom CA bundle from the environment.\n llm_trust_env: bool = False\n # H4/C2: compact the growing agentic tool-call history once it exceeds this many chars (auto_summary\n # LLM-summarizes the stale middle; else middle-truncate). Sized to the model's real context window so\n # files read earlier STAY in context instead of being compacted away and re-read: ~1,000,000 chars ≈\n # 250k tokens (≈4 chars/token), matching the configured deepseek-v4-flash (≥200k tokens confirmed).\n # The old 120k-char (~30k-token) default was ~8× too aggressive and drove a read-thrash loop. 0 = off.\n context_budget_chars: int = 1_000_000\n # M5: the Researcher's always-on experiments digest budget (chars). 0 = AUTO — scales with the\n # run size (60 chars/node, bounded [1200, 6000]) instead of one flat cap for an 8-node toy run\n # and a 200-node benchmark run alike. Set a positive value to pin it.\n digest_char_cap: int = Field(default=0, ge=0)\n # D11 compression model slot (open_deep_research's four-slot pattern): a dedicated CHEAP model\n # for agent-history summarization (C2 auto-summary), so compression doesn't pay the main\n # model's price. Blank = use each loop's own model (legacy). `compressor_base_url` blank =\n # the shared llm_base_url.\n compressor_model: str | None = None\n compressor_base_url: str | None = None\n # === Agentic tool-loop ================================================================\n # Agentic tool-loop limits — apply to EVERY tool-using agent (the LLM Researcher, the unified\n # agent's pilot + crash-triage, Deep-Research, the run-chat Boss, the genesis repo-scout, and the\n # cross-run report synthesizer). The loop lets the model call read-only tools across turns before\n # emitting its structured result. Both default to UNLIMITED so the agent takes as many turns / as\n # long as it needs — it is never cut off mid-reasoning; set a positive value here (or per-run, or\n # in the UI settings) to bound latency/cost. These used to be hardcoded per call site (e.g. the\n # boss at max_turns=3 / 45s), which silently dropped a slow reasoning model to a no-op reply.\n agent_max_turns: int = 0 # max tool turns before the emit is forced (0 = unlimited)\n agent_time_budget_s: float = 0.0 # wall-clock ceiling across the loop's turns (0 = no cap)\n # G · Soft convergence SAFETY NET (not a research cap). Two thresholds on the agent's tool-turn count:\n # at `agent_emit_after` the loop NUDGES the agent once (\"you've investigated enough — emit now\"); at\n # `agent_emit_force` it FORCES the emit from what was gathered. Set GENEROUSLY — a focused researcher\n # may legitimately read many files/experiments before it can propose a grounded idea; these only catch\n # a genuine runaway that keeps issuing DIFFERENT calls forever (which stuck-detection, keyed on\n # repeats, misses). NB: the old 499-read runaway (node 63) was 79% REDUNDANT re-reads caused by a\n # broken read_file — fixing read_file (pagination) is the real cure; this is just insurance. 0 = off.\n agent_emit_after: int = 300\n agent_emit_force: int = 500\n # B1 · No-progress / stuck detection — the safety net that makes \"unlimited turns\" safe. The\n # turn/time ceilings above are only BACKSTOPS; this is what actually stops a runaway loop, on the\n # cheapest signal: when the model repeats the SAME tool call (or ping-pongs between two, or keeps\n # hitting the SAME error) with no progress, the loop forces the final emit and finishes. ON by\n # default; reading DIFFERENT files or running ONE long command never trips it. (OpenHands-style.)\n agent_stuck_detection: bool = True\n agent_stuck_repeat: int = 4 # identical calls in a row that count as \"stuck\" (>=2)\n agent_stuck_alternate: int = 4 # ping-pong cycles between two calls that count as \"stuck\" (>=2)\n # C1 · Self-plan (TodoWrite-style): expose an `update_plan` tool so a long-running agent keeps its\n # OWN working TODO and re-surfaces it every `agent_plan_reinject_every` turns — keeps the goal in\n # view across a long loop so the agent \"writes its own context\" (Claude Code style). ON by default.\n agent_self_plan: bool = True\n agent_plan_reinject_every: int = 5\n # C2 · Auto-summary (ON by default): when the tool-loop history grows long, LLM-summarize the\n # stale middle instead of only middle-truncating it. The trigger is `context_budget_chars` if set,\n # else a built-in high-water mark (context_budget.DEFAULT_SUMMARY_CHARS ≈ 120k chars) so short\n # loops are untouched. Falls back to truncation on any summarizer error; one extra model call per\n # over-budget turn.\n agent_auto_summary: bool = True\n # C4 · Repo-developer plan decomposition. A big feature attempted in ONE developer session made a\n # non-converging reasoning model loop indefinitely — it kept writing + exploring without ever\n # emitting `done` (10k+ LLM calls / hours; agentic stuck-detection can't catch VARIED exploration,\n # only literal repeats). Fix: the developer first proposes an ordered plan of ATOMIC steps; a\n # multi-step plan is executed step-by-step, each in a FRESH bounded session with only that step's\n # scope + the files accumulated so far, syntax-validated per write. Every session stays\n # small-context and convergent. ON by default for repo tasks (a 1-step plan == the old single pass).\n developer_plan_decompose: bool = True\n developer_plan_min_steps: int = 2 # a proposed plan with >= this many steps runs step-by-step\n developer_plan_max_steps: int = 8 # cap on plan length (a runaway planner can't spawn 100 steps)\n # Hard per-session backstop for the repo developer — the ONE write-heavy agent that can run away\n # even with stuck-detection on (varied writes/reads never trip the repeat/ping-pong signal). Bounds\n # the plan phase, EACH step, and the single-session fallback. Unlike the global agent_* limits\n # (0 = unlimited, meant for read-only agents), the developer always gets a FINITE ceiling so a\n # model that never emits `done` fails cleanly with the code it wrote so far, instead of looping.\n developer_session_max_turns: int = 500\n developer_session_time_budget_s: float = 1200.0 # 20 min wall-clock per developer session\n # Phase-handoff summaries. Each LLM phase in a node build (Researcher·propose → Developer·stages →\n # plan → implement) ends with ONE extra LLM call that distills its transcript — the repo structure\n # it mapped, files/data confirmed, decisions made — into a brief injected into the NEXT phase (even\n # across the role boundary). The next phase TRUSTS that instead of re-reading the same files, which\n # cuts the biggest source of tool-call thrash (stages/plan/implement each re-exploring the repo).\n # One summary call per phase in exchange for many fewer read calls downstream. ON by default.\n phase_handoff_summary: bool = True\n # Observability (ADR-17): capture each LLM call's full prompt + completion into the active\n # span (spans.jsonl) so the UI can show exactly what the model read and wrote per node.\n # Diagnostics only — `replay.fold` never reads spans. Default on for local single-user; set\n # LOOPLAB_TRACE_LLM_IO=0 to suppress (e.g. to keep spans.jsonl small or avoid storing prompts).\n trace_llm_io: bool = True\n # Run-introspection tools (ON by default): make the LLM Researcher (and DeepResearcher) a\n # tool-using agent that can read its OWN experiments (list/read/find-analogous/themes/code) and\n # the task data (schema/profile/asset) mid-loop, instead of seeing only best+parent. Advisory —\n # never changes best-selection. Off = the legacy single-shot Researcher (richer digest still added).\n researcher_tools: bool = True\n # Cross-run introspection: give the agentic Researcher / pilot read-only tools to look at\n # SIBLING runs (same task_id, same run-root) — list them, read an experiment / its code, and\n # find analogous configs across runs — so a run can build on what neighbouring runs already\n # learned instead of rediscovering it. Advisory; never changes best-selection. Needs the run's\n # own dir wired through (no-op for unit-built roles). Off = the legacy single-run view only.\n cross_run_tools: bool = True\n # Read-only access to EVERY run on this machine ACROSS ALL TASKS (not just same-task siblings):\n # the Developer/Researcher get list_all_runs + read_run_code + read_run_experiment so they can read\n # the code + result of ANY past experiment anywhere and reuse an approach. Broader than\n # `cross_run_tools` (same-task only); the agent decides when a foreign run is relevant. Advisory;\n # never changes best-selection. Needs the run's own dir wired through (no-op for unit-built roles).\n all_runs_tools: bool = True\n # PART V §22 — read-only CROSS-RUN KNOWLEDGE tool for the reasoning roles (Researcher, Strategist,\n # deep-research, and a Developer-scoped variant). Adds `cross_run_prior_attempts` / `cross_run_claims`\n # / `cross_run_atlas` over the §21.20 read-models (concept overview + claim assessments + atlas), read\n # from `memory_dir` (lessons.jsonl + concept_capsules.jsonl). Read-only is a storage-authority\n # boundary, not a behavioral no-op: retrieved content can steer later proposals, and a tool-using\n # reasoning loop can consume additional model calls. The tools never mutate cross-run truth (facts\n # are engine-written, verdicts operator-ratified, §22.4) or directly select a winner. ON by explicit\n # experimental product choice, EngineOptions off; stores only exist once Part-IV features have run.\n cross_run_read_tools: bool = True\n # Agentic retrieval (ADR-16): if set, the LLM Researcher gets grep/kb_search/read\n # tools over this directory of markdown notes and chooses when to use them.\n knowledge_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"knowledge\"))\n # T4 real embeddings: model id for an OpenAI-compatible `/embeddings` endpoint used for\n # kb_search / case retrieval. Blank (default) = the dependency-free lexical `hash_embed` (today's\n # behavior). Set e.g. \"nomic-embed-text\" (Ollama) or \"text-embedding-3-small\" for SEMANTIC\n # retrieval; a misconfigured/offline endpoint degrades back to hash_embed at call time (never\n # crashes a run). `embed_base_url` blank = reuse `llm_base_url`; the shared `llm_api_key` is used.\n embed_model: str | None = None\n embed_base_url: str | None = None\n # Harmonic memory (Memora, ICML'26 — idea import). When ON, cross-run cases and knowledge notes are\n # indexed by a short ABSTRACTION + cue ANCHORS instead of their raw text, near-duplicate memories are\n # CONSOLIDATED into one entry under a matching abstraction, and `kb_search`/case retrieval EXPAND\n # through the top hits' anchors to surface related-but-not-similar memories. ON by default. Set\n # `memora=false` to restore the pre-Memora raw-text index. Never a source of truth — abstractions\n # live only in the derived, rebuildable retrieval index (never the event log or canonical cases.jsonl).\n memora: bool = True\n # Write abstractions with the wired chat model (richer than the deterministic lexical fallback). ON\n # by default; results are CACHED by content hash (see `memora_cache`) so a re-built index doesn't\n # re-call the model on unchanged notes/cases, and any endpoint failure degrades to lexical at call\n # time — so an offline box just gets lexical abstractions, never a crash. Set false to force lexical.\n memora_llm: bool = True\n # === Cross-run memory / Memora ========================================================\n memora_anchors: int = 6 # max cue anchors kept per memory\n # Cosine similarity at/above which a new case is CONSOLIDATED into an existing one (same evolving\n # topic) rather than stored as a separate entry. Only used when `memora` is on.\n # CLAUDE REVIEW: [EDGE-CASE] unbounded 0..1 cosine knob with no Field bounds. Consumers compare\n # `cosine(...) >= consolidate_threshold` (engine/memory.py, tools/knowledge_tools.py) and cosine is\n # capped at 1.0, so a typo'd value >1.0 validates cleanly and SILENTLY DISABLES consolidation forever\n # (a negative one consolidates everything). Its 0..1 sibling novelty_semantic_threshold carries\n # Field(ge=0.5, le=1.0); this should be bounded the same way (e.g. ge=0.0, le=1.0) to fail loud.\n memora_consolidate_threshold: float = 0.86\n # Where the LLM-abstraction cache lives (JSON). Blank (default) derives it from `memory_dir`\n # (`<memory_dir>/memora_cache.json`) or else `knowledge_dir` (`<knowledge_dir>/.memora_cache.json`);\n # with neither set the cache is in-memory only (per process). No-op unless `memora_llm` is on.\n memora_cache: str | None = None\n # E3 literature-grounded ideation (network-OPTIONAL): give the agentic Researcher an arXiv search\n # tool to ground ideas in real techniques. Off by default (egress is unreliable on some boxes);\n # fails gracefully if the network is blocked.\n literature_search: bool = False\n # Deep-Research stage (network-OPTIONAL): give the DeepResearcher a general web search/fetch\n # tool (DuckDuckGo) on top of arXiv to read across results + the web before steering the next\n # batch. Off by default (egress is unreliable on some boxes); fails gracefully when blocked.\n web_search: bool = False\n # Cadence for the Deep-Research stage: run it automatically every N created nodes. Zero disables\n # only the cadence; manual `deep_research` and Strategist `request_research` triggers still fire.\n # Default 3: deep research analyzes the accumulating results and steers the next batch (its\n # directions become hints + open hypotheses); paired with `concurrent_research` it overlaps the\n # think with the GPU-bound eval, so the cadence costs LLM tokens but no wall-clock on the search.\n deep_research_every: int = 3\n # Overlap a DUE deep-research \"think\" with the GPU-bound eval instead of running it in its own\n # serial step (the agent is otherwise idle while a node trains). research() computes from a state\n # snapshot; the joined background task records only allowlisted, order-tolerant\n # research/hint/hypothesis events as soon as the memo finishes. ON by default: the LLM is typically\n # remote (no GPU contention with eval), so overlapping hides latency behind long training.\n concurrent_research: bool = True\n # Concurrent-research REPEAT — don't idle a multi-DAY eval. The overlapped \"think\" fires ONCE per\n # window by default; ON here, it RE-RUNS on an adaptive time cadence for the WHOLE eval window\n # (a two-day training must not leave the reasoning agents idle after one memo). Self-paced: it\n # only records a memo whose CONTENT is new (identical re-runs are skipped, so the log/board don't\n # bloat) and backs off geometrically as the analysis converges, capped so it always re-checks.\n # The appended records never rewrite the current metric champion, but their hints/open hypotheses\n # deliberately steer later proposals and replay reconstructs that advice. The library default is\n # one-shot (== today, byte-identical); this product default turns the repetition on.\n concurrent_research_repeat: bool = True\n # Base cadence (seconds) between repeated concurrent-research passes. Research is EXPENSIVE\n # (multi-turn LLM + web/arXiv), so this is a FLOOR, not a ceiling: the effective cadence is\n # max(this, ~5% of the per-experiment time budget) — a two-day eval is re-researched about hourly,\n # a short eval not at all (the first tick outlasts it). Only matters when the repeat is on.\n concurrent_research_interval_s: float = Field(default=1800.0, gt=0)\n # Per-eval-window backstop on repeated-research LLM calls (0 = cadence-only, no cap). The adaptive\n # cadence + convergence backoff are the primary budget control; this bounds a pathological\n # never-converging window. Past it the loop stops calling the LLM (the health monitor still runs).\n concurrent_research_max_calls: int = Field(default=40, ge=0)\n # Concurrent hypothesis-board CONSOLIDATION during the eval window. Repeated research keeps ADDING\n # near-duplicate directions to the open board (each memo registers up to 5 as open hypotheses), so a\n # long eval's board bloats before the between-nodes merge runs. This dedups/merges it on the SAME\n # background loop so the board stays clean for the next proposal. In Card-driven mode the same merge\n # changes ownership/readiness, so it is deferred to the joined main-task cadence instead. Needs the\n # reflect client + `track_hypotheses`.\n # Only active while `concurrent_research` runs the overlap loop. Library default off (== today).\n concurrent_consolidate: bool = True\n # Cadence for the agent-authored run report: regenerate the conclusion-first narrative every N\n # created nodes (0 = off; it still regenerates on a manual `report_refresh` from the UI). The\n # deterministic report always renders from the node set regardless of this knob.\n report_every: int = 3\n # Agent Skills (I18, ADR-9): dir of SKILL.md the Researcher can list/load as tools.\n skills_dir: str | None = None\n # Prompt store (I18, ADR-8): dir of editable, hot-reloaded role prompt .md files.\n prompt_dir: str | None = None\n # API key as a reference, never serialized as a value (ADR-11). Local servers\n # ignore it; default to a placeholder.\n llm_api_key: SecretStr | None = None\n\n @model_validator(mode=\"before\")\n @classmethod\n def _reject_bool_for_numeric_fields(cls, data):\n \"\"\"A JSON boolean is a TYPE ERROR for a numeric setting, not the number 1.\n\n Pydantic runs in lax mode here (`model_config` sets no `strict`), and `isinstance(True, int)`\n is True — so `{\"max_nodes\": true}` from a UI/API type slip validated cleanly as a budget of\n ONE node, and `{\"eval_parallel\": true}` collapsed the width to one. The run then looked\n configured and simply did almost nothing, with no 422 and nothing in the snapshot to show why.\n Rejecting at the boundary covers every numeric field at once, including the many that carry no\n `Field(ge=...)` bound of their own to trip over.\n \"\"\"\n if not isinstance(data, dict):\n return data\n for key, val in data.items():\n if type(val) is bool and _is_numeric_annotation(\n getattr(cls.model_fields.get(key), \"annotation\", None)):\n raise ValueError(f\"{key} must be a number, not a boolean (got {val!r})\")\n return data\n\n @model_validator(mode=\"before\")\n @classmethod\n def _apply_profile(cls, data):\n \"\"\"Expand `profile` into a bundle of setting defaults BEFORE validation.\n\n pydantic-settings hands this validator the fully-merged input from ALL explicit sources\n (init kwargs + LOOPLAB_* env + .env), with fields the user left unset simply ABSENT (they\n fall to field defaults afterward). So a profile value is applied only when its key is not\n already present — i.e. any explicit file/CLI/env setting wins over the profile, and the\n profile wins over the bare field default. This makes `thorough` a true convenience preset,\n never an override of what the user asked for.\"\"\"\n if not isinstance(data, dict):\n return data\n prof = data.get(\"profile\", \"default\")\n # env may deliver the key as \"profile\" (env_prefix stripped) — normalize to a str\n prof = str(prof).strip().lower() if prof is not None else \"default\"\n overrides = PROFILES.get(prof)\n if overrides is None:\n raise ValueError(f\"unknown profile {prof!r}; known: {sorted(PROFILES)}\")\n for key, val in overrides.items():\n if key not in data: # explicit source (file/CLI/env) always wins\n data[key] = val\n return data\n\n @model_validator(mode=\"after\")\n def _check_trust_gate(self):\n if self.trust_gate not in (\"audit\", \"gate\", \"block\"):\n raise ValueError(\n f\"trust_gate must be audit|gate|block, got {self.trust_gate!r}\")\n if self.merge_mode not in (\"auto\", \"mean\", \"ensemble\"):\n raise ValueError(\n f\"merge_mode must be auto|mean|ensemble, got {self.merge_mode!r}\")\n # novelty_mode drives the dedup/novelty gate; an out-of-set value (e.g. a mis-cased\n # LOOPLAB_NOVELTY_MODE=LLM, or \"on\") used to fall through _apply_novelty_gate silently as a\n # NO-OP — turning the gate off with no diagnostic. Fail loudly at config time like trust_gate/\n # merge_mode do. (seed_mode / eval_trust_mode / strategist_backend share the pattern and are\n # validated in this same block, just below.)\n if self.novelty_mode not in (\"off\", \"algo\", \"llm\"):\n raise ValueError(\n f\"novelty_mode must be off|algo|llm, got {self.novelty_mode!r}\")\n # The remaining enum-ish string fields (arch-review §5 P3): a typo used to be accepted at\n # construction and only fail-safe/later-loud downstream (e.g. a mis-cased strategist_backend\n # silently ran the default). Fail loudly here like the fields above.\n if self.strategist_backend not in (\"off\", \"rule\", \"llm\", \"agent\"):\n raise ValueError(\n f\"strategist_backend must be off|rule|llm|agent, got {self.strategist_backend!r}\")\n if self.eval_trust_mode not in (\"ratify_freeze\", \"autonomous\", \"ratify_freeze_drift\"):\n raise ValueError(\n \"eval_trust_mode must be ratify_freeze|autonomous|ratify_freeze_drift, \"\n f\"got {self.eval_trust_mode!r}\")\n if self.seed_mode not in (\"auto\", \"tracked\", \"all\"):\n raise ValueError(f\"seed_mode must be auto|tracked|all, got {self.seed_mode!r}\")\n if self.backend not in (\"toy\", \"llm\"):\n raise ValueError(f\"backend must be toy|llm, got {self.backend!r}\")\n # developer_backend is a CLOSED enum: \"default\" (in-house developer) plus the external\n # coding-agent keys in agents/cli_agent.py::PRESETS. The CLI --developer-backend flag guards it\n # (_DEV_BACKENDS), but a file/env/`--set` value reached here unchecked and adapters/tasks.py then\n # silently wired the DEFAULT developer for anything not in PRESETS. Validate against the SAME\n # authoritative registry the runtime consumer uses (imported lazily to keep config import-light),\n # so a typo fails loud instead of downgrading — mirroring the `backend` guard above.\n from looplab.agents.cli_agent import PRESETS as _DEV_PRESETS\n _dev_backends = (\"default\", *_DEV_PRESETS)\n if self.developer_backend not in _dev_backends:\n raise ValueError(\n f\"developer_backend must be {'|'.join(_dev_backends)}, \"\n f\"got {self.developer_backend!r}\")\n # `parse_structured` resolves an unknown name to the DEFAULT fallback order, so a typo'd\n # llm_parser is indistinguishable from asking for the default. Validate against the parser\n # registry itself (imported here, not at module scope, to keep config import-light).\n from looplab.core.parse import _ORDER as _PARSER_ORDER\n if self.llm_parser not in _PARSER_ORDER:\n raise ValueError(f\"llm_parser must be one of {'|'.join(sorted(_PARSER_ORDER))}, \"\n f\"got {self.llm_parser!r}\")\n self._check_llm_profiles()\n return self\n\n def _check_llm_profiles(self) -> None:\n \"\"\"Validate the connection-profile maps LOUDLY, at construction.\n\n Assignment is not validated anywhere in this project, and these two maps are the kind of\n thing a typo makes into a silent no-op: an unknown role name would simply never be read, and\n a reference to a missing profile would fall back to the shared model while the operator\n believed a second provider was in play. Both are errors here instead.\n\n The `api_key_env` rules exist for a specific reason. It must LOOK like a secret to\n `runtime/sandbox.py::SECRET_ENV`, because that is the filter that strips the operator's keys\n out of the environment handed to generated candidate code — a key parked in a variable named\n `MY_HANDLE` would be invisible to it and ride straight into the sandbox. This pattern is\n deliberately the STRICTER of the two (the sandbox has to recognize whatever already exists;\n this names a variable the operator is about to create), and it is duplicated below rather than\n imported because layering forbids `core` from importing `runtime`.\n `tests/test_secret_env_pattern.py` pins the two together — including the invariant that every\n name accepted here is one the sandbox strips. And a literal key inside a profile is refused\n outright: the profile map is written verbatim into config.snapshot.json and served over HTTP\n precisely because it is supposed to contain no secrets.\"\"\"\n from looplab.core.llm import LLM_ROLE_KEYS\n if not isinstance(self.llm_profiles, dict):\n raise ValueError(\"llm_profiles must be a map of profile name -> profile\")\n for name, entry in self.llm_profiles.items():\n if not isinstance(entry, dict):\n raise ValueError(f\"llm_profiles[{name!r}] must be an object\")\n # BEFORE the unknown-field check: none of these is a known field, so checking them\n # afterwards could never fire and someone who pasted a live key was told it was a typo.\n for banned in (\"api_key\", \"key\", \"token\", \"secret\"):\n if banned in entry:\n raise ValueError(\n f\"llm_profiles[{name!r}] must not contain {banned!r}: a profile stores \"\n \"api_key_env (the NAME of an environment variable), never a key value — \"\n \"the whole map is written to config.snapshot.json and served over HTTP\")\n unknown = set(entry) - _PROFILE_FIELDS\n if unknown:\n raise ValueError(\n f\"llm_profiles[{name!r}] has unknown field(s) {sorted(unknown)}; \"\n f\"known: {sorted(_PROFILE_FIELDS)}\")\n env = entry.get(\"api_key_env\")\n if env is not None and not _SECRET_ENV_NAME.match(str(env)):\n raise ValueError(\n f\"llm_profiles[{name!r}].api_key_env={env!r} is not a usable variable name: it \"\n \"must be UPPER_SNAKE and contain KEY/SECRET/TOKEN/PASSWORD/PASSWD/CREDENTIAL, so \"\n \"the sandbox's secret-name filter strips it from generated code's environment\")\n temp = entry.get(\"temperature\")\n if temp is not None and (isinstance(temp, bool) or not isinstance(temp, (int, float))):\n raise ValueError(f\"llm_profiles[{name!r}].temperature must be a number\")\n if not isinstance(self.role_profiles, dict):\n raise ValueError(\"role_profiles must be a map of role name -> profile name\")\n for role, profile in self.role_profiles.items():\n if role not in LLM_ROLE_KEYS:\n raise ValueError(\n f\"role_profiles has unknown role {role!r}; known: {sorted(LLM_ROLE_KEYS)}\")\n if profile not in self.llm_profiles:\n raise ValueError(\n f\"role_profiles[{role!r}] names profile {profile!r}, which is not in llm_profiles\")\n if self.llm_profile is not None and self.llm_profile not in self.llm_profiles:\n raise ValueError(\n f\"llm_profile={self.llm_profile!r} is not in llm_profiles\")\n\n def masked_snapshot(self) -> dict:\n # Snapshots are a JSON contract (they are written verbatim to config.snapshot.json), not a\n # Python-object dump. In particular tuple-valued schema fields such as\n # ``inline_repair_reasons`` must become JSON arrays before callers hash the runtime envelope;\n # otherwise the strict speculation-scope canonicalizer sees a different/non-JSON preimage\n # from the one that ``json.dumps`` eventually persists.\n d = self.model_dump(mode=\"json\")\n if self.llm_api_key is not None:\n d[\"llm_api_key\"] = \"***\"\n else:\n d[\"llm_api_key\"] = None\n # The snapshot's own FORMAT version — not a Settings field (it is never settable by env or\n # file, and it describes the document, not the run). `settings_from_snapshot` fails closed on\n # a version it does not understand; see CONFIG_SNAPSHOT_SCHEMA.\n d[CONFIG_SNAPSHOT_SCHEMA_KEY] = CONFIG_SNAPSHOT_SCHEMA\n return d\n\n\n# The config snapshot's document-format version, written by `masked_snapshot` and enforced by\n# `settings_from_snapshot`. Bump it when a snapshot written by this binary would be MISREAD by an\n# older one — i.e. when a new field changes paid, concurrency or selection behaviour and silently\n# defaulting it would resume the same event history under different semantics. `Settings` is\n# `extra=\"ignore\"`, so without this an older binary drops what it does not recognize and resumes\n# anyway, with no diagnostic; a version it does not know is now a loud refusal instead.\n# Version 1 is the first VERSIONED format. An absent key means a pre-versioning snapshot, which stays\n# readable through `LEGACY_CONFIG_SNAPSHOT_DEFAULTS` — that historical contract is unchanged.\nCONFIG_SNAPSHOT_SCHEMA = 1\nCONFIG_SNAPSHOT_SCHEMA_KEY = \"config_snapshot_schema\"\n\n\nclass ConfigSnapshotVersionError(ValueError):\n \"\"\"A config snapshot was written by a NEWER binary than the one reading it.\"\"\"\n\n\n# Pre-versioned snapshots are full Settings dumps, so absence is a reliable per-field version marker.\n# Keep their historical effective behavior when newer product defaults become active: re-entry must not\n# silently add paid calls, interventions, concurrency or a different selection policy to an old run.\nLEGACY_CONFIG_SNAPSHOT_DEFAULTS: dict[str, object] = {\n \"parallel_build\": 1,\n \"eval_parallel\": None,\n \"llm_parallel\": None,\n \"train_monitor\": False,\n \"train_monitor_interval_s\": 600.0,\n \"train_monitor_kill\": False,\n \"train_monitor_kill_confidence\": 0.8,\n \"asha_live\": False,\n \"asha_live_kill\": False,\n \"asha_live_quantile\": 0.5,\n \"asha_live_min_siblings\": 3,\n # Before this operator ceiling existed, the sandbox's 24-hour process limit was authoritative.\n \"max_eval_timeout\": 24 * 3600.0,\n \"watchdog_reflection\": False,\n \"card_driven_selection\": False,\n \"speculation_depth\": 0,\n \"speculation_gate_receipt\": None,\n \"concurrent_research_repeat\": False,\n \"concurrent_research_interval_s\": 1800.0,\n # Zero means unlimited; 40 was the first bounded default, and is latent while repeat is disabled.\n \"concurrent_research_max_calls\": 40,\n \"concurrent_consolidate\": False,\n # The map originally covered only the flags added in the days just before it was written\n # (the entries above are all 2026-07-19..07-21; the map itself landed 07-22). Everything below was\n # added 2026-06-24..07-17 — AFTER `config.snapshot.json` existed (it ships in the initial commit,\n # 2026-06-23) and BEFORE this map did — so a snapshot from that window carries none of these keys\n # and, without an entry here, resumed with today's paid product default ACTIVE. That is precisely\n # the \"silently add paid calls ... to an old run\" the paragraph above forbids: the resumed half of\n # the run would make predict-before-execute and cross-run LLM calls the original never made, and\n # admit candidates under a different novelty policy.\n # `setdefault` makes every one of these a no-op for a snapshot that DOES carry the key, so only\n # genuinely older runs are affected. Each value is the behaviour before the field existed: absent\n # feature = off, absent cadence = 0 (the same \"conservative library\" value the frozen\n # Settings-vs-EngineOptions divergence table records for these fields). Each was dated against the\n # tree before being listed — e.g. `report_every` (2026-06-25) is 0 because the run-report writer\n # itself did not exist before that commit, so an older run wrote no report to preserve.\n \"foresight\": False,\n \"foresight_agentic\": False,\n \"foresight_verify\": False,\n \"concept_pivot\": False,\n \"graded_novelty\": False,\n \"cross_run_concepts\": False,\n \"cross_run_advisory\": False,\n \"cross_run_structured_claims\": False,\n \"cross_run_curation\": False,\n \"cross_run_read_tools\": False,\n \"concept_run_base\": False,\n \"fingerprint_universal\": False,\n \"memora_llm\": False,\n \"comparative_lessons\": False,\n \"lessons_every\": 0,\n \"lessons_refresh_every\": 0,\n \"unified_agent\": False,\n \"deep_research_every\": 0,\n \"report_every\": 0,\n \"failure_reflection\": False,\n \"reflection_priors\": False,\n \"agent_drives_actions\": False,\n \"concurrent_research\": False,\n \"deep_repair\": False,\n # CLAUDE REVIEW: [QUALITY] possible gap — `merge_mode` is absent. options.py's docstring names it as a\n # product-vs-library divergence in the SAME breath as report_every (\"merge_mode='auto', report_every=3\"),\n # and report_every IS in this map. Settings defaults \"auto\", which resolves to \"ensemble\" for a\n # code-generating developer (orchestrator.__init__ L715) — a PAID code-recombination merge producing a\n # DIFFERENT merged solution than the frozen legacy default \"mean\" (EngineOptions/orchestrator._DEFAULTS).\n # So a pre-field snapshot resumed on an LLM backend silently gains ensemble merges it never did — the\n # exact \"add paid calls / a different selection policy to an old run\" this map guards against, with a\n # pointable before-value (\"mean\"). Add `\"merge_mode\": \"mean\"` here IF merge_mode postdates 2026-06-23\n # (unverifiable in this squashed history — confirm against the real commit before acting).\n # WHAT THIS MAP IS NOT. It is a hand-maintained list of FEATURE switches whose before-the-field\n # value is unambiguous, not a complete partition of `Settings`. Two classes stay out on purpose,\n # because for them a wrong entry is worse than a missing one — it would silently REMOVE behaviour\n # the original run really had:\n # * knobs that are latent while their parent feature is off — `foresight_panel`,\n # `foresight_verify_samples`, `memora_anchors`, `holdout_top_k`, `asha_eta`: the parent above\n # already pins them out of play, and their own before-the-field value is not recoverable;\n # * magnitudes and depths for behaviour that ALREADY existed — `debug_depth`,\n # `context_budget_chars`, `developer_plan_max_steps`, `agent_emit_after`: guessing 0/off there\n # disables working machinery rather than preserving history.\n # A new field belongs here only when it (a) postdates 2026-06-23, (b) defaults to adding paid\n # calls / interventions / concurrency / a different selection policy, and (c) has a historical\n # value you can point at a commit for. When (c) fails, leave it out and say so here.\n}\n\n\ndef migrate_config_snapshot(data: dict) -> dict:\n \"\"\"Return an effective copy of one legacy/current snapshot without rewriting evidence bytes.\"\"\"\n if not isinstance(data, dict):\n raise TypeError(\"config snapshot must be a JSON object\")\n migrated = dict(data)\n for key, value in LEGACY_CONFIG_SNAPSHOT_DEFAULTS.items():\n migrated.setdefault(key, value)\n return migrated\n\n\ndef config_snapshot_schema(data: dict) -> int:\n \"\"\"The snapshot's declared format version. 0 = pre-versioning (the key did not exist yet).\n\n A malformed value is treated as UNREADABLE rather than as 0: a snapshot whose own version marker\n is corrupt cannot be assumed to be the oldest format.\"\"\"\n raw = data.get(CONFIG_SNAPSHOT_SCHEMA_KEY, 0) if isinstance(data, dict) else 0\n if isinstance(raw, bool) or not isinstance(raw, int) or raw < 0:\n raise ConfigSnapshotVersionError(\n f\"config snapshot has a malformed {CONFIG_SNAPSHOT_SCHEMA_KEY}: {raw!r}\")\n return raw\n\n\ndef settings_from_snapshot(data: dict) -> Settings:\n \"\"\"Build re-entry Settings from a masked snapshot under its historical missing-field contract.\n\n Fails closed on a snapshot written by a NEWER binary. `Settings` is `extra=\"ignore\"`, so such a\n snapshot would otherwise load with every unrecognized control silently dropped — the run would\n resume on the same event history under different paid/concurrency/selection semantics, with\n nothing to show why. Refusing is the only honest answer: this binary cannot know what it is\n dropping. Older and pre-versioning snapshots keep loading exactly as before.\"\"\"\n found = config_snapshot_schema(data)\n if found > CONFIG_SNAPSHOT_SCHEMA:\n raise ConfigSnapshotVersionError(\n f\"this run's config.snapshot.json declares format v{found}, but this build understands \"\n f\"at most v{CONFIG_SNAPSHOT_SCHEMA}. It was written by a newer LoopLab; resuming here \"\n \"would silently drop settings this build does not know. Upgrade LoopLab to resume it.\")\n migrated = migrate_config_snapshot(data)\n migrated.pop(\"llm_api_key\", None)\n migrated.pop(CONFIG_SNAPSHOT_SCHEMA_KEY, None) # a document marker, never a Settings field\n return Settings(**migrated)\n"}, "4e361cc8735776d4eb6ff0c1b9ab346faaa7fbad": {"t": 1785768730.6543574, "n": 120325, "h": "44c958a2074341de", "c": "\"\"\"Engine config: the schema (what settings exist). (I0, ADR-11)\n\nOne pydantic-settings class declaring every engine knob, its default, and its docs. The loader —\nhow a run file / CLI / env becomes a `Settings` — is `looplab.core.appconfig`, which also owns\nthe config-precedence order. A resolved, secret-masked snapshot is written next to each run for\nreproducibility. (No real secrets in P0, but the masking discipline is in place.)\n\"\"\"\nfrom __future__ import annotations\n\nimport re\nimport types\nimport typing\nfrom pathlib import Path\n\nfrom pydantic import Field, SecretStr, field_validator, model_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n# Single source of the LLM first-byte (response-headers) default — see core/llm.py.\nfrom looplab.core.llm import DEFAULT_HEADER_TIMEOUT_S\n\n# Default home for cross-run memory + the knowledge base (shared across every run). Both are ON by\n# default now (a user asked for it) so a fresh install accumulates + consults knowledge with no setup;\n# set the env var to \"\" to disable, or to a path to relocate. `~/.looplab/` keeps them out of any one\n# project + shared across projects.\n_LL_HOME = Path.home() / \".looplab\"\n\n# What one `llm_profiles` entry may contain. Deliberately closed: an unrecognized field is a typo the\n# operator would otherwise never see fail, and the map is a public artifact (config.snapshot.json,\n# HTTP, LOOPLAB_*), so it must never accrete free-form keys.\n_PROFILE_FIELDS = frozenset({\"model\", \"base_url\", \"temperature\", \"api_key_env\"})\n# A variable name that `runtime/sandbox.py::SECRET_ENV` will recognize as holding a secret and strip\n# from generated code's environment. Duplicated here, not imported: layering forbids `core` from\n# importing `runtime`. `tests/test_secret_env_pattern.py` holds the two in agreement.\n_SECRET_ENV_NAME = re.compile(\n r\"\\A(?=[A-Z][A-Z0-9_]{0,63}\\Z)(?=.*(KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL)).*\\Z\")\n\n\ndef _is_numeric_annotation(ann) -> bool:\n \"\"\"True when a field's annotation admits int/float but NOT bool.\n\n Used by `Settings._reject_bool_for_numeric_fields`: pydantic resolves annotations at class build,\n so `model_fields[...].annotation` is a real type object here even under\n `from __future__ import annotations`. Optionals are numeric when every non-None member is\n (`int | None` yes, `int | str` no), so a boolean can never slip in through a union arm.\"\"\"\n if ann is bool:\n return False # a bool field legitimately takes a bool\n if ann is int or ann is float:\n return True\n args = [a for a in typing.get_args(ann) if a is not type(None)]\n origin = typing.get_origin(ann)\n if args and origin in (typing.Union, types.UnionType):\n return all(_is_numeric_annotation(a) for a in args)\n return False\n\n# Autonomous roles that may be granted per-setting write access (see Settings.agent_control).\n# strategist = A7 meta-controller (run-wide); boss = run-chat operator-proxy (run-wide);\n# researcher = per-experiment proposer (per-node sizing, e.g. eval timeout for a heavy model).\nAGENT_ROLES = (\"strategist\", \"boss\", \"researcher\")\n\n# A foresight score is computed for every acted-on proposal. Keep this lower than the generic\n# verifier's emergency ceiling so a typo cannot multiply paid synchronous calls across a run.\nMAX_FORESIGHT_VERIFY_SAMPLES = 8\n\n\n# Layer-2 parallelism renamed two public settings without removing the durable legacy fields. Keep\n# the equivalence in one place: config-source precedence, governance migration, and live strategy\n# application must all agree which names describe the same authority/value.\nPARALLELISM_ALIASES: dict[str, str] = {\n \"max_parallel\": \"eval_parallel\",\n \"parallel_build\": \"llm_parallel\",\n}\n\n\ndef parallelism_aliases(setting: str) -> tuple[str, ...]:\n \"\"\"Return every spelling in ``setting``'s alias family, canonical name first.\"\"\"\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if setting in (legacy, canonical):\n return canonical, legacy\n return (setting,)\n\n\ndef canonicalize_parallelism_source(\n values: dict, *, promote_build_to_llm_parallel: bool = False,\n) -> dict:\n \"\"\"Promote legacy parallel keys *within one config-precedence source*.\n\n Applying this only after file/CLI/env were flattened would let a lower-priority canonical env\n value shadow a higher-priority legacy file value (or a file canonical value shadow a CLI legacy\n override), so callers with sub-layers normalize each layer independently.\n\n ``max_parallel``/``eval_parallel`` are true aliases (pure eval width, no extra semantics) and are\n always promoted. ``parallel_build``/``llm_parallel`` are NOT: a positive ``llm_parallel`` also flips\n on the finite shared LLM broker (``orchestrator._startup_llm_total``), while legacy ``parallel_build``\n means \"legacy build width, unbounded overlap, no shared total\". So promoting a legacy-only\n ``parallel_build`` layer (e.g. just ``LOOPLAB_PARALLEL_BUILD=1`` in env) would silently serialize\n every provider call — contradicting llm_broker.py's docstring, orchestrator's \"legacy-only preserves\n unbounded overlap\" note, the broker-neutral live-control path, and the configuration.md ``llm_parallel``\n row (\"falls back ... without enabling a shared total\"). Config/startup loads therefore keep broker\n opt-in tied to an EXPLICITLY-spelled canonical ``llm_parallel``; the orchestrator's own width fallback\n (``llm_parallel if not None else parallel_build``) still carries the legacy build width. Only the\n Strategist live-control path (``canonicalize_strategy_parallelism``) opts into\n ``promote_build_to_llm_parallel``, where ``parallel_build`` is a deliberate full alias of the\n canonical knob (see strategy.py ``_apply_strategy``).\n \"\"\"\n if not isinstance(values, dict):\n return values\n out = dict(values)\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if canonical == \"llm_parallel\" and not promote_build_to_llm_parallel:\n continue\n # None is the durable legacy mode for llm_parallel, not a missing value. A normal snapshot\n # contains llm_parallel:null plus parallel_build:1; promoting null to 1 would unexpectedly enable\n # the global one-call broker. Promote aliases only when the canonical key is absent, and pin\n # snapshot round-trip parity. Canonicalize inside each precedence layer, never across the\n # flattened result, so the alias migration cannot invert CLI > file > env precedence.\n if canonical not in out and out.get(legacy) is not None:\n out[canonical] = out[legacy]\n return out\n\n\ndef flatten_parallelism_layers(layers) -> dict:\n \"\"\"Flatten same-tier precedence layers (LOWEST priority first) and resolve the legacy pair.\n\n PRECEDENCE REPAIR for the non-promoted `parallel_build`/`llm_parallel` pair. Declining to promote\n in `canonicalize_parallelism_source` is right (a legacy-only layer must not flip on the shared\n broker), but it let a LOWER-priority canonical value survive a HIGHER-priority legacy override:\n file `llm_parallel: 2` + CLI `-s parallel_build=8` flattened to BOTH keys, and the orchestrator\n prefers `llm_parallel`, so the operator's explicit CLI width was silently discarded. When a higher\n layer names the legacy width over a lower layer's canonical one, mask with the LEGACY SENTINEL\n (`llm_parallel=None` is the durable \"legacy mode — build width from parallel_build, no shared\n total\"), so the higher layer wins with the semantics it asked for.\n\n The mask lives HERE, not in the per-layer canonicalizer, because only a flatten knows the layer\n ORDER. Stamping it per layer meant any layer naming `parallel_build` emitted `llm_parallel=None`\n even when NO layer in the tier set the canonical key — and since pydantic-settings ranks init\n kwargs above env, that sentinel then silently overrode a `LOOPLAB_LLM_PARALLEL` the operator had\n exported as a deliberate provider rate/cost ceiling. Precedence is per FIELD: a tier that never\n spelled `llm_parallel` has not set it, and it must fall through to env/.env/defaults — which is\n exactly what `build_settings`' docstring promises (\"the merged dict wins only where it actually\n sets a value\").\n \"\"\"\n merged: dict = {}\n legacy_at = canonical_at = -1\n for index, layer in enumerate(layers):\n if not isinstance(layer, dict):\n continue\n merged.update(layer)\n if layer.get(\"parallel_build\") is not None:\n legacy_at = index\n if \"llm_parallel\" in layer:\n canonical_at = index\n # Only when this tier DID set the canonical key, at a strictly lower layer than the legacy one.\n if canonical_at >= 0 and legacy_at > canonical_at:\n merged[\"llm_parallel\"] = None\n return merged\n\n# Settings whose semantics are committed by ``run_started`` and restored from the folded event log\n# on every re-entry. Changing one only in ``config.snapshot.json`` would mix incompatible evidence\n# (a different holdout split) or make the live producer disagree with replay's selection policy.\n# The HTTP config editor and the engine both consume this one contract; keep deliberately mutable\n# controls such as ``trust_gate`` out (that field has its own durable change event).\nRUN_START_PINNED_FIELDS = frozenset({\n \"card_driven_selection\",\n \"speculation_depth\",\n \"holdout_fraction\",\n \"holdout_select\",\n \"select_verifier\",\n \"select_verifier_samples\",\n \"verifier_ci_tie\",\n})\n\n\ndef run_start_pinned_settings(state) -> dict:\n \"\"\"Return the effective Settings names committed by a folded run-start record.\n\n Pre-pin legacy logs have no recorded holdout fraction, so their snapshot remains authoritative\n for both holdout knobs. Verifier fields, however, have always been re-pinned from the legacy-safe\n fold defaults whenever a valid ``run_started`` identity exists.\n \"\"\"\n if not getattr(state, \"run_id\", \"\"):\n return {}\n values = {\n # Layer 3 changes which queue owns macro-action selection. Once a run starts, replay and\n # resume must keep the same owner even if the snapshot or ambient default changes later.\n \"card_driven_selection\": bool(getattr(state, \"card_driven_selection\", False)),\n # Layer 5 overlaps Card production with evaluation only when this pinned treatment is\n # positive. Old logs omit it and therefore retain the exact serial build/eval spine.\n \"speculation_depth\": int(getattr(state, \"speculation_depth\", 0)),\n \"select_verifier\": bool(getattr(state, \"select_verifier_tiebreak\", False)),\n \"select_verifier_samples\": int(getattr(state, \"select_verifier_samples\", 3)),\n \"verifier_ci_tie\": bool(getattr(state, \"verifier_ci_tie\", False)),\n }\n holdout_fraction = getattr(state, \"holdout_fraction\", None)\n if holdout_fraction is not None:\n values.update({\n \"holdout_fraction\": holdout_fraction,\n \"holdout_select\": bool(getattr(state, \"holdout_select\", False)),\n })\n if not values.keys() <= RUN_START_PINNED_FIELDS:\n raise RuntimeError(\"folded run-start pinned settings contract drifted\")\n return values\n\n# --- Run profiles (config-first presets) -------------------------------------------------------\n# A `profile` is a NAMED BUNDLE of setting defaults — nothing more. It only fills fields the user\n# did NOT set explicitly (via file / --set / LOOPLAB_* env / init-kwargs), so any explicit knob\n# always wins (see `_apply_profile`). Most engine intelligence lives behind individual flags. The\n# PART IV/V cross-run + concept-graph machinery (concept_pivot, graded_novelty, cross_run_concepts /\n# _advisory / _structured_claims / _curation / _read_tools, fingerprint_universal, foresight_verify)\n# now ships ON in the product `Settings` default. That is an explicit experimental product choice,\n# not a claim that a frozen A/B or workload-wide cost/quality gate has cleared: the bundle can steer\n# prompts and candidate generation, change graded-novelty admission / pre-execution choices, and add\n# paid LLM work when its client and memory prerequisites are present. The bare-library `EngineOptions`\n# default stays lean (see tests/test_options_divergence.py — product side is the aggressive one) so a\n# toy `Engine(...)` in a test doesn't fire cross-run LLM work unasked. The trust/confirm/ablate QUALITY\n# bundle still ships OFF and is turned on by `thorough`. Every value here is reachable by hand — the\n# profile is a convenience, never a hidden mode.\n# keep this rationale behavioral and evidence-based; do not relabel read-only storage\n# access or proposal-only governance as a no-op for agent behavior, cost, or proposal admission.\n#\n# `thorough` deliberately touches only QUALITY/TRUST machinery, not spend: it does NOT raise\n# max_nodes / eval_parallel (those are cost knobs the user owns). It enables multi-seed confirmation\n# (demote seed-lucky leaders), the reward-hack / leakage / critic monitors AND flips them from\n# advisory to *gating* (`trust_gate=\"gate\"` — a\n# flagged win can no longer be selected as best), ablation-driven refinement, and the prompt cues\n# (complexity / budget / failure-reflection / watchdog-reflection). Agentic novelty and the experimental\n# Part IV/V bundle are already product defaults, not profile changes. Effect is large; marginal cost is a\n# few extra evals.\nPROFILES: dict[str, dict] = {\n \"default\": {}, # explicit no-op alias for \"ship-as-is\" (== omitting profile)\n \"fast\": {}, # today's lean defaults, named for symmetry with `thorough`\n \"thorough\": {\n \"confirm_top_k\": 3,\n \"confirm_seeds\": 3,\n # novelty_gate intentionally NOT enabled here: duplicate-detection is the agentic Researcher's\n # job (it reads past experiments + find_analogous_across_runs and DECIDES), not an algorithmic\n # param-distance / embedding auto-reject that mis-fired (it nudged a good fresh idea onto a\n # twice-dead lineage — live node 61). Search stays as tools; the LLM judges novelty.\n \"operator_bandit\": True, # P4: adaptive operator mix over folded yields (GreedyTree)\n \"reward_hack_detect\": True,\n \"code_leakage_detect\": True,\n \"critic_check\": True,\n \"trust_gate\": \"gate\", # detectors stop gating a flagged node from WINNING (not just audit)\n \"ablate_every\": 3,\n \"complexity_cue\": True,\n \"budget_aware\": True,\n \"failure_reflection\": True,\n \"watchdog_reflection\": True, # feed recent live-watchdog (train-monitor/ASHA) flags to proposals\n \"reflection_priors\": True, # no-op unless memory_dir is set (cross-run priors)\n },\n}\n\n\n# The shipped agent-governance matrix (who may change which setting at runtime). Defined as a module\n# constant so the Engine's direct-construction default matches this shipped default exactly (the\n# EngineOptions \"Engine() == shipped defaults\" invariant) instead of diverging to an all-locked map.\nDEFAULT_AGENT_CONTROL: dict[str, list[str]] = {\n \"timeout\": [\"researcher\", \"strategist\"],\n # Layer-2 canonical names are the presented/default governance surface. `_agent_may` falls back\n # to an old snapshot's legacy grant only when its canonical counterpart is absent, so removing\n # duplicate defaults preserves resume authority while an explicit canonical revocation still wins.\n \"eval_parallel\": [\"strategist\"],\n \"llm_parallel\": [\"strategist\"],\n # Strategy-only pseudo-setting: the closed per-lane allocation inside the canonical LLM total.\n # It is not a launch Settings field; operators pin it through `set_strategy` and the Strategist\n # may reallocate it only while this grant is present.\n \"llm_lane_limits\": [\"strategist\"],\n \"max_nodes\": [\"strategist\", \"boss\"],\n \"max_eval_seconds\": [\"strategist\", \"boss\"],\n \"policy\": [\"strategist\"],\n # The policy NAME and its PARAMS are gated independently in `_apply_strategy`; grant both by default\n # so the Strategist that may switch to (say) MCTS may also apply the `c`/`eta` it decided — else its\n # tuned params are silently dropped and the recorded `active_strategy` diverges from the live engine.\n \"policy_params\": [\"strategist\"],\n \"n_seeds\": [\"strategist\"],\n \"ablate_every\": [\"strategist\"],\n \"merge_mode\": [\"strategist\"],\n \"complexity_cue\": [\"strategist\"],\n \"ablate_code_blocks\": [\"strategist\"],\n \"prefer_sweep\": [\"strategist\"],\n \"novelty_stance\": [\"strategist\"],\n \"developer\": [\"strategist\"],\n \"fidelity\": [\"strategist\", \"researcher\"],\n}\n\n\ndef default_agent_control() -> dict[str, list[str]]:\n \"\"\"A FRESH deep-ish copy of the shipped governance matrix (each role list copied so a caller can\n mutate its own map without touching the module constant). The SINGLE constructor of the default,\n called by both `Settings.agent_control`'s default_factory AND the Engine's direct-construction\n default — so \"Engine() == shipped defaults\" can't drift if the copy depth ever needs to change\n (e.g. nested values requiring copy.deepcopy), instead of two verbatim copy expressions.\"\"\"\n return {k: list(v) for k, v in DEFAULT_AGENT_CONTROL.items()}\n\n\nclass Settings(BaseSettings):\n \"\"\"The engine settings schema (every knob a run accepts).\n\n Timeout family — six distinct knobs, each owned by a different subsystem:\n - `timeout`: per-eval wall-clock budget for ONE experiment's evaluation (engine/eval).\n - `max_eval_timeout`: hard ceiling for a governed per-node Researcher timeout override.\n - `llm_timeout`: LLM request idle timeout — inter-token stall limit in stream mode\n (core.llm OpenAICompatibleClient).\n - `llm_header_timeout`: LLM first-byte (response-headers) window for stream attempts (core.llm).\n - `agent_time_budget_s`: wall-clock ceiling across a tool-using agent loop's turns (agents).\n - `dep_install_timeout`: per-package budget for auto-installing a missing dep (env self-prep).\n\n Config-source precedence is owned by `looplab.core.appconfig` (the loader) — see its module\n docstring for the one canonical order.\n \"\"\"\n # Config precedence: see appconfig.py — the loader owns the full order. The `.env` is read\n # so `looplab run`/`looplab ui` pick up LOOPLAB_LLM_BASE_URL etc. without exporting them by hand\n # (keys MUST carry the LOOPLAB_ prefix, same as the env vars). utf-8-sig tolerates a BOM from\n # Windows editors. The test suite disables this (tests/conftest.py) so a dev's real .env in the\n # repo root can't leak into assertions.\n model_config = SettingsConfigDict(\n env_prefix=\"LOOPLAB_\",\n env_file=\".env\",\n env_file_encoding=\"utf-8-sig\",\n extra=\"ignore\",\n allow_inf_nan=False,\n )\n\n @classmethod\n def settings_customise_sources(\n cls, settings_cls, init_settings, env_settings, dotenv_settings,\n file_secret_settings):\n \"\"\"Preserve source precedence across canonical/legacy parallelism names.\n\n Pydantic ranks init > env > dotenv correctly, but it ranks field names rather than semantic\n alias families. Promoting a legacy value inside each source before the merge makes an old\n explicit snapshot/file value beat a lower-priority canonical env value. ``appconfig`` also\n promotes its file/typed/--set sub-layers before combining them into the init source.\n \"\"\"\n def _with_parallel_aliases(source):\n return lambda: canonicalize_parallelism_source(source())\n\n return (\n _with_parallel_aliases(init_settings),\n _with_parallel_aliases(env_settings),\n _with_parallel_aliases(dotenv_settings),\n _with_parallel_aliases(file_secret_settings),\n )\n\n # Run profile (config-first preset, see PROFILES above). \"default\"/\"fast\" = today's lean\n # defaults; \"thorough\" turns the built quality/trust machinery ON in one word. A profile only\n # fills fields the user did NOT set — every knob it touches stays individually overridable.\n profile: str = \"default\"\n\n # Bounds (review C/⚪): a non-positive node/seed budget or timeout is never valid. Upper bounds are\n # equally load-bearing — the UI /start path writes these straight into the engine's env, and an\n # unbounded `eval_parallel` (or legacy `max_parallel`; e.g. 100000 from a crafted preflight) would\n # fan out that many sandboxes → resource exhaustion. Reject at config time, not mid-run. Ceilings\n # are generous; tests top out near 50.\n # Pydantic's lax mode coerces a JSON boolean into any numeric field (`true` -> 1), so a UI/API type\n # error used to silently collapse a run budget to ONE node instead of returning 422. Rejected for\n # EVERY numeric field by `_reject_bool_for_numeric_fields` below, not just the ones bounded here.\n n_seeds: int = Field(default=3, ge=1, le=1024)\n max_nodes: int = Field(default=8, ge=1, le=1_000_000)\n # legacy evaluation-width input. `eval_parallel` below is canonical and wins when set;\n # keep this field for old env/config/snapshots and direct Engine call sites. A width > 1 admits\n # concurrent evals, whose resource reservations may be CPU-only or span one/more GPUs — there is no\n # one-node/one-GPU promise. `max_parallel=0` is launch-time AUTO (detected GPU count, at least 1).\n max_parallel: int = Field(default=1, ge=0, le=1024)\n # legacy build-width input. `llm_parallel` below is canonical and also controls the\n # shared provider-call total when positive. A width > 1 uses fresh pooled researcher/developer pairs;\n # it says nothing about the later eval's CPU/GPU reservation. Startup `0` derives build fan-out from\n # resolved eval width; a live Strategist/operator zero settles to 1. Without role_factory it clamps to 1.\n parallel_build: int = Field(default=1, ge=0, le=64)\n # Hypothesis-card Kanban re-architecture (docs/23, Layer 2). Two INDEPENDENT concurrency axes,\n # decoupled from each other so cost (LLM work) can be tuned separately from throughput (GPU evals).\n # These are the CANONICAL names going forward; `max_parallel`/`parallel_build` above are kept as thin\n # back-compat aliases (env vars / snapshots / ~100 Engine(...) call sites depend on the exact names,\n # so they cannot be renamed — CLAUDE.md). Resolution (orchestrator.__init__): a NEW name, when set,\n # WINS over its legacy alias; None => fall back to the legacy field => byte-identical to today.\n # `eval_parallel`: concurrent EVALS (the GPU/experiment consumer). None -> max_parallel; 0 = AUTO\n # (one experiment per detected GPU). This is the in-Run eval-concurrency ceiling; the resource\n # scheduler bin-packs declared footprints separately. Sibling local GPU-owning Runs serialize\n # through the conservative host-pool lease; external execution needs its own admission boundary.\n # `llm_parallel`: total concurrent LLM provider calls + BUILD fan-out. None -> legacy\n # parallel_build semantics without a shared total; 0 = startup AUTO (= resolved eval_parallel\n # for build fan-out, historical research overlap unbounded). A positive canonical value enables\n # the named-lane broker the Strategist can reallocate at cadence.\n eval_parallel: int | None = Field(default=None, ge=0, le=1024)\n llm_parallel: int | None = Field(default=None, ge=0, le=64)\n # Training-log monitor (I-series watchdog family): a per-eval background observer that tails the live\n # training log while a (often multi-hour) declared stage runs in a worker thread. ON in the product\n # surface (Settings). Its alert event is fold-ignored and cannot directly change lifecycle/champion;\n # with watchdog_reflection on, that alert is nevertheless advice in a later Researcher\n # prompt and can change future proposals. It only fires on the command-eval path with an LLM client;\n # bare-library EngineOptions stays OFF. See test_options_divergence.\n # `_interval_s` is the BASE tick cadence (the effective one adapts to the per-experiment budget).\n train_monitor: bool = True\n train_monitor_interval_s: float = Field(default=600.0, gt=0)\n # Phase 3 — INTERVENTION (opt-in, separate from observation): let the monitor tree-kill a training the\n # LLM judges 'broken' (diverged / silent CPU fallback / not learning) EARLY, instead of burning the\n # whole budget. Off by default — the observer only WATCHES unless this is on. A kill only fires on a\n # 'broken' verdict (a plateau is 'watch', never killed) with confidence >= the threshold; the node then\n # fails normally (reason='monitor_broken'), so replay reconstructs it from that one terminal event.\n train_monitor_kill: bool = False\n train_monitor_kill_confidence: float = Field(default=0.8, ge=0.0, le=1.0)\n # ASHA live-curve watchdog (sibling of the training monitor): reads the latest INTERMEDIATE value of\n # the objective metric off the live log (reusing the eval's OWN metric reader). Finished-endpoint rank\n # remains advisory. An opt-in KILL additionally requires an operator-declared metric.resource_key and\n # enough sibling observations at exactly that resource; absent comparable evidence cannot stop a run.\n # Records a fold-ignored `asha_rank` diagnostic + trace span; the library default is OFF (off == today).\n asha_live: bool = True\n # Opt-in INTERVENTION (separate from the advisory signal): let the watchdog tree-kill a node whose\n # intermediate metric stays below the SAME-RESOURCE bar past a short grace window. Off by default —\n # it only surfaces endpoint rank unless this is on. A kill fails normally (asha_underperforming).\n asha_live_kill: bool = False\n # The bar sits at `asha_live_quantile` along a WORST->BEST ordering of the finished siblings' finals:\n # 0.5 = the median (a node worse than the median peer flags); a SMALLER value is more conservative —\n # it lowers the bar toward the WORST finished peer, so only a node worse than nearly all peers flags\n # (0.0 = worse than the worst). `min_siblings` is the floor of finished peers required before it ranks\n # at all, so it never acts on too little evidence.\n asha_live_quantile: float = Field(default=0.5, ge=0.0, le=1.0)\n asha_live_min_siblings: int = Field(default=3, ge=1)\n timeout: float = Field(default=30.0, gt=0)\n # Hard ceiling for the Researcher's governed per-node `Idea.eval_timeout`. One hour preserves the\n # existing heavy/neural-net authoring example while staying well below the sandbox's defensive\n # 24-hour subprocess ceiling. This is operator-owned run config: agents may request less or more,\n # but the accepted action is always clamped here after the `agent_control.timeout` permission gate.\n max_eval_timeout: float = Field(default=3600.0, gt=0, le=24 * 3600.0)\n # Intra-node sweep: a sweep node runs a whole grid in one process, so it gets this multiple of\n # `timeout` as its wall-clock budget (solution.py path; RepoTasks use their per-profile timeout).\n sweep_timeout_mult: float = Field(default=8.0, ge=1)\n # Eval STALL watchdog CAP (seconds): a stage that is completely SILENT on stdout/stderr for this\n # long — while still alive and BELOW its wall-clock deadline — is tree-killed early with a STALLED\n # marker (a hung dataloader/deadlock dies in minutes instead of burning a multi-hour timeout). The\n # per-stage window is `min(this, the stage's own timeout)`, so a short scorer's deadline fires first.\n # `0` DISABLES the watchdog entirely (only the hard deadline applies) — for a legitimately quiet\n # non-Python stage (block-buffered stdout, a script that logs only to its own file). Threaded into\n # the eval and surfaced to the Developer so its code can emit periodic progress to stay alive.\n eval_stall_timeout_s: float = Field(default=1800.0, ge=0)\n # Sandbox tier (ADR-13): \"trusted_local\" (subprocess, no Docker) for the CLI;\n # \"untrusted\" (Docker --network none, shared-kernel runtime) for hosted/multi-tenant UI;\n # \"hostile\" (untrusted + a true-isolation OCI runtime, gVisor `runsc` by default / Kata) for\n # running untrusted code. See runtime/sandbox.py::make_sandbox for the tier selection.\n trust_mode: str = \"trusted_local\"\n # Image for the untrusted command-eval tier (RepoTask, Phase 4): the framework's deps\n # should be baked in (the container runs --network none, so a pip setup can't fetch).\n docker_image: str = \"python:3.12-slim\"\n # Resource caps for the untrusted/hostile Docker tier (passed to `docker run --memory/--cpus`).\n # The default 4g bound protects other tenants from an OOM, but a model-training MLE-bench eval\n # routinely needs more — raise it (e.g. \"16g\") for such runs. \"\" disables the cap (unbounded).\n # Ignored by the trusted_local (subprocess) tier.\n sandbox_memory: str = \"4g\"\n sandbox_cpus: str = \"\"\n # Best-effort host-OOM guard for the TRUSTED-LOCAL (subprocess) tier. When set (e.g. \"8g\"), each\n # eval child gets an RLIMIT_AS address-space cap so a runaway trainer hits MemoryError instead of\n # OOM-killing the whole host (and the engine). POSIX only; a no-op on Windows. Default \"\" = OFF:\n # RLIMIT_AS bounds VIRTUAL memory, and CUDA/torch reserve tens of GB of virtual without using it,\n # so a default cap would spuriously kill GPU evals — leave it off for those and use the Docker\n # tier's real --memory cgroup bound (sandbox_memory) instead. Ideal for CPU/numpy/sklearn runs.\n sandbox_memory_local: str = \"\"\n # Best-effort disk-fill guard for the TRUSTED-LOCAL tier (P1-5): an RLIMIT_FSIZE cap on the size of\n # any single file an eval child writes (e.g. \"2g\"), so a runaway that writes an unbounded file gets\n # SIGXFSZ instead of filling the host disk. POSIX only. Default \"\" = OFF (large model checkpoints\n # need big files); set it for tasks that write only small artifacts.\n sandbox_fsize_local: str = \"\"\n # Search policy (ADR-2): \"greedy\" | \"evolutionary\" | \"mcts\" | \"asha\".\n # CLAUDE REVIEW: [DOCS-MISMATCH] this enum list omits \"bohb\", which IS a registered policy\n # (search/policy.py::_REGISTRY aliases it to the ASHA factory) and IS advertised by\n # appconfig.render_template (\"greedy | evolutionary | mcts | asha | bohb\") and the `bohb` template\n # kind. The schema comment (the documented value set) undersells the valid values. (policy itself is\n # unvalidated at config time, but make_policy raises on an unknown name, so a typo fails loud-late\n # rather than silently — unlike developer_backend above.)\n policy: str = \"greedy\"\n # Ablation-driven refinement (I7): every N improves, ablate the best to find the\n # highest-impact parameter and refine it. 0 = off. (GreedyTree only.)\n ablate_every: int = 0\n # A0a (MLE-STAR): when ablating, treat each generated pipeline *code block* (not just a\n # numeric param) as an ablation unit — neutralize a block, measure the metric delta, refine\n # the highest-impact block. Off => the classic numeric-param ablation. Config-first knob.\n ablate_code_blocks: bool = False\n # A0b: real merge/ensembling. \"mean\" = legacy mean-param merge; \"ensemble\" = the Developer\n # writes a code-recombination ensemble over the two parents' solutions (verified: agent-proposed\n # ensembling 37.9%->43.9%). \"auto\" (default) = ensemble whenever the Developer actually\n # generates code (LLM/agent backends; declared via `is_code_generating`), mean for the\n # templated/toy developers where a code ensemble is meaningless — the evidence says code\n # recombination is the strongest single merge (removing it costs ~9 pp), so it is the default\n # wherever it can work. Falls back to mean when the Developer can't ensemble.\n merge_mode: str = \"auto\"\n # A0d (AIRA): inject a dynamic complexity hint into the draft/improve prompt keyed on the\n # node's child count (few children -> keep minimal; many -> escalate to ensembling/HPO).\n complexity_cue: bool = False\n # I1 (CAAFE-style) LLM feature-engineering: instruct the proposer/developer to add\n # semantically-meaningful engineered features (ratios, interactions, aggregations) as code. The\n # existing cross-validation eval + best-selection is the CV GATE — a feature set that doesn't\n # improve CV is never selected (feature engineering is non-universal, so this gate is mandatory).\n # Tabular tasks (those exposing column data). Off by default.\n feature_engineering: bool = False\n # A5: surface the REMAINING eval-compute budget into the proposal prompt so the Researcher can\n # reason over how much compute is left (explore broad while flush, exploit/cheapen when low).\n # No-op unless a max_eval_seconds budget is set.\n budget_aware: bool = False\n # A4 (LATS-style): feed a summary of the most recent FAILED branches (operator + error reason)\n # back into the proposal prompt so the proposer reflects on and avoids repeating them. ON by\n # default: it is SELECTIVE by construction (injects only when recent failures exist — the\n # evidenced form of reflection; reflecting at every step shows no gains, arXiv:2506.12928).\n failure_reflection: bool = True\n # Feed the LIVE-WATCHDOG observations (train-monitor health verdicts + ASHA intermediate-rank flags)\n # of recent experiments back into the proposal prompt, so the Researcher avoids re-proposing a config\n # whose TRAINING was already observed to be weak — even when the watchdog kills are off (default) and\n # the node ran to completion (those diagnostics are fold-ignored, so failure_reflection never sees\n # them). ON by default and SELECTIVE like failure_reflection (injects only when a recent flag exists).\n watchdog_reflection: bool = True\n # C3 deep test-driven repair: hand the Developer the failure taxonomy + a structured \"reproduce\n # then fix\" directive on debug, not just the raw stderr tail. ON by default (product surface); the\n # conservative library default in EngineOptions stays off, per that module's contract.\n deep_repair: bool = True\n # === Inline crash repair ==============================================================\n # Hybrid in-node crash repair: when an LLM-generated node CRASHES at runtime (mechanical errors\n # — bad import, removed kwarg, typo), the agent triages it and may repair the code IN PLACE within\n # the same eval (cheap; does NOT consume max_nodes and does NOT add a node to the search tree).\n # Semantic failures still flow to a new idea/debug node via the policy. ON by default; set False\n # to restore the prior behavior (every crash waits for a budgeted inter-node debug node).\n inline_repair: bool = True\n # Max in-place repair attempts per node before it fails normally. Default 0 = UNLIMITED: the same\n # agent keeps fixing + re-evaluating IN THE SAME node until it produces a clean metric — no new\n # node just to retry. Runaway is prevented not by a count but by the anti-stuck guard\n # (`inline_repair_stuck_repeat`): when the SAME error signature repeats with no progress, or the\n # agent's crash-triage says \"abandon\", the node fails. Set a positive N to cap the retries instead.\n inline_repair_attempts: int = Field(default=0, ge=0)\n # Anti-stuck: abandon in-node repair when the SAME error recurs this many times in a row (no\n # progress). Keeps \"unlimited\" repair from looping forever on an unfixable error.\n inline_repair_stuck_repeat: int = Field(default=4, ge=2)\n # Which failure reasons (_failure_reason: crash|timeout|oom|setup|no_metric|drift) are eligible for\n # inline repair. Default: mechanical crashes, timeouts AND OOM-kills — a timeout/OOM means the code\n # was too slow / too memory-hungry for the budget (or the pod's cgroup limit), not that the idea is\n # wrong, so the agent repairs it by reducing compute/memory (fewer estimators/epochs/folds/seeds, a\n # lighter model, a smaller batch, subsampling) instead of letting the node die unrecovered. Env\n # override expects a JSON array.\n inline_repair_reasons: tuple[str, ...] = (\"crash\", \"timeout\", \"oom\")\n # Cost ceiling for in-node repair of a MULTI-STAGE eval: the max number of FULL pipeline re-runs\n # (i.e. re-trains from the first stage) the inline-repair loop may do before abandoning to the\n # inter-node debug operator. A late-stage failure (a broken `score`/eval script that didn't touch\n # `train`) REUSES the completed train checkpoint and re-runs only from the failed stage — that is\n # CHEAP and NOT counted here; only a repair that changes an EARLIER stage's code (train.py/loss.py/…)\n # forces a full re-train, which each costs the whole training time. Without this bound, a repair that\n # keeps changing training code could burn many full trains (the anti-stuck guard is error-signature\n # based, not cost based). 0 = unlimited (legacy behavior). Single-command evals ignore it.\n inline_repair_retrain_cap: int = Field(default=2, ge=0)\n # Environment self-prep: when a solution crashes purely because a KNOWN library isn't installed\n # (ModuleNotFoundError), the engine pip-installs it into the eval interpreter and re-runs — so a\n # torch/XGBoost/CatBoost idea (e.g. a GRU model) actually runs on a fresh box instead of being\n # thrown away as `idea_rejected`. Trusted_local tier only (the untrusted/hostile Docker tiers run\n # --network none). A typo'd or local helper module is NOT installed (that's a code bug to repair).\n auto_install_deps: bool = True\n # Per-package wall-clock budget for an auto-install (seconds). Generous: large wheels like torch\n # can take minutes to download/build.\n dep_install_timeout: float = Field(default=900.0, gt=0)\n # --- Agent governance: who may change a setting at runtime (per-setting allow-list of roles) ---\n # Roles: \"strategist\" (A7 meta-controller; run-wide), \"boss\" (run-chat operator-proxy; run-wide),\n # \"researcher\" (per-experiment proposer; PER-NODE — e.g. set a longer eval timeout for a neural-net\n # idea). A setting ABSENT from this map is LOCKED — no agent may change it, only a human via the\n # snapshot/UI. The sole conditional exception is Strategy-only `card_scoring`: Card mode grants\n # it implicitly without changing the default flag-off snapshot, while an explicit [] revokes it.\n # The gate is ENFORCED at every AGENT seam via `_agent_may` — the strategist's whole\n # control surface (policy/operators/novelty_stance/prefer_sweep/developer/fidelity/timeout/\n # eval_parallel/llm_parallel) is checked in `_apply_strategy`, so removing a role from a knob locks it,\n # not just greys out the UI pill. The boss/max_* grants document boss-relevant knobs, but a\n # `budget_extend` is a HUMAN control intent (the boss action-builder can only emit `add_nodes`), so\n # it is applied as-is — the human always wins via the UI/snapshot. Defaults are conservative: resource/\n # search-shape knobs the agents already reason about; provider infra (llm_model/base_url/api_key,\n # docker_image) stays locked. The UI shows S/B/R pills per setting. `timeout` granted to the\n # researcher IS the \"auto\" per-node mode — the researcher sizes each experiment's wall-clock\n # (Idea.eval_timeout); the config `timeout` is the fallback for nodes it doesn't size. Env override\n # expects a JSON object. (`fidelity`/`novelty_stance`/`prefer_sweep`/`developer` are governance keys\n # for the strategist's per-node dials — not 1:1 Settings fields, but gated the same way.) The default\n # lives in the module-level DEFAULT_AGENT_CONTROL so the Engine's direct-construction default matches.\n agent_control: dict[str, list[str]] = Field(default_factory=default_agent_control)\n # C1 (Agentless localization): for repo tasks, rank the source files most relevant to the most\n # recent failure (traceback + identifiers) and surface them in the proposal/repair prompt so the\n # Developer edits the right place. Off by default; repo tasks only.\n localize_faults: bool = False\n # A2 surrogate-guided proposal (BO-lite): fit a k-NN surrogate over (params->metric) and propose\n # by acquisition instead of random/hill-climb. Numeric-bounds tasks only; bootstraps via the\n # base Researcher. Off by default. `surrogate_explore` = UCB-style exploration weight.\n surrogate_proposer: bool = False\n surrogate_explore: float = 0.1\n # E2 researcher panel: generate K candidate ideas per proposal and keep the one ranked best by a\n # cheap empirical surrogate over the (params->metric) history (NOT an LLM-judge). 1 = off.\n researcher_panel: int = 1\n # A1 ASHA / successive-halving: reduction factor (keep top 1/eta per rung) and rung budget.\n asha_eta: int = Field(default=3, ge=2)\n # Rung-0 width for ASHA/BOHB (the wide base of cheap drafts). 0 = use n_seeds (default,\n # preserving prior behavior); set >0 to seed a wider/narrower base independent of n_seeds.\n asha_rung_nodes: int = Field(default=0, ge=0)\n # A6 proxy/predictive scoring: cheaply rank a candidate's potential from early-stage signals\n # and skip a full eval for the doomed bottom fraction. 0.0 = off (no candidate skipped).\n proxy_scoring: bool = False\n proxy_kill_fraction: float = Field(default=0.0, ge=0.0, le=0.9)\n # === Novelty / dedup ==================================================================\n # E1 novelty/dedup gate: reject near-duplicate proposals (normalized param-space distance below\n # `novelty_epsilon`) by deterministically nudging them off the duplicate — stops the search\n # wasting evals re-trying the same idea. Audit event `novelty_rejected`. Off by default.\n # Novelty/dedup MODE selector: how a fresh proposal is checked against already-tried experiments.\n # \"off\" — no explicit gate; the agentic Researcher's own read-the-history judgment is trusted.\n # \"algo\" — the deterministic gate: numeric param-distance (`novelty_epsilon`) + optional embedding\n # similarity (`novelty_semantic`). Cheap, no LLM call, but can't explain itself.\n # \"llm\" — an LLM adjudicates (reads the real experiments via tools) whether the idea is a\n # near-duplicate and, if so, asks the Researcher once more for something different. One\n # extra LLM call per proposal — highest quality, follows the \"let the LLM decide\" line.\n novelty_mode: str = \"llm\"\n # Legacy sub-toggles, honored by the \"algo\" mode (and back-compat: novelty_gate=True forces \"algo\"):\n novelty_gate: bool = False\n novelty_epsilon: float = 0.05\n # T5 semantic novelty (active whenever the deterministic gate runs — novelty_mode=algo,\n # novelty_gate=true which is the legacy alias for algo, OR the Strategist novelty stance is\n # \"explore\"; a no-op only under novelty_mode=llm/off with a non-explore stance): reject a proposal\n # whose idea TEXT embeds within\n # `novelty_semantic_threshold` cosine of an existing node's. OFF BY DEFAULT: novelty is the agentic\n # Researcher's call, made by READING the actual prior experiments (read_experiment /\n # find_analogous_across_runs / list_experiments), NOT an embedding-similarity auto-reject that can't\n # explain itself and mis-fires on paraphrases. Semantic/param search stays available as TOOLS in the\n # Researcher's context; the LLM decides whether an idea is a true duplicate.\n novelty_semantic: bool = False\n novelty_semantic_threshold: float = Field(default=0.92, ge=0.5, le=1.0)\n # T10: debug-lineage depth bound for every policy — how many error-feedback repairs a failing\n # lineage gets before it is abandoned. The old default (1) abandoned lineages after ONE repair;\n # multi-turn/deeper debugging is a verified lever (AIRA2 ReAct debug +5.5 percentile points).\n debug_depth: int = Field(default=2, ge=1)\n # P4 operator bandit (GreedyTree): replace the fixed merge/ablate cadences with a\n # deterministic UCB over per-operator yield (Δmetric per eval-second, folded from the run).\n # Off by default (the cadences are well-tested and the bandit has no direct published\n # ablation); `thorough` turns it on.\n operator_bandit: bool = False\n # P1 hypothesis ledger: ask the Researcher to state the one-line hypothesis each experiment tests,\n # register deep-research directions as hypotheses, and track them to a verdict on the board. ON by\n # default. The board is shown to the Researcher and can be foresight-ordered, so it can\n # steer later proposals even though it never directly re-ranks already-evaluated nodes. Set False\n # to drop the prompt nudge + registration.\n track_hypotheses: bool = True\n # E4/M2/M3 reflection-memory -> priors (gradient-free cross-run meta-learning): at run end distill\n # the winner into `<memory_dir>/meta_notes.jsonl` AND structured lessons (incl. NEGATIVE results:\n # tested/abandoned hypotheses + failure themes) into `lessons.jsonl` with a task fingerprint; at run\n # start, inject exact-task notes + fingerprint-matched lessons from SIMILAR tasks into the proposal\n # prompt. ON by default — but a NO-OP until `memory_dir` is set (that's where cross-run memory lives).\n reflection_priors: bool = True\n # M6 comparative lessons (MARS \"comparative reflective memory\", doc 13 §7 item 2): distill\n # credit-assigned lessons from PAIRS of solutions — which SPECIFIC change made a child beat\n # (or regress from) its parent, and what fixed a failure — on top of the one-shot run-end\n # reflection. At most one extra LLM call per distillation (all pairs batched); offline a\n # deterministic param-diff credit stands in. Gated on reflection_priors + memory_dir like the\n # rest of cross-run memory — and since memory_dir has a default (~/.looplab/memory), this is\n # ACTIVE out of the box; clear memory_dir to disable cross-run memory wholesale.\n comparative_lessons: bool = True\n # M6 mid-run cadences (doc 13 §7 item 5 — the AgentRxiv live-share pattern): every N created\n # nodes, WRITE comparative lessons to the shared cross-run store during the run (lessons_every\n # — one batched LLM call per firing) and RE-READ the store so lessons distilled by CONCURRENT\n # runs reach this run's proposals (lessons_refresh_every; file re-read, no LLM call, skipped\n # when the store is unchanged). ON by default (every 4 nodes) and, like comparative_lessons\n # above, live out of the box because memory_dir defaults on; 0 = off — run-end write /\n # run-start read only, the pre-M6 behavior.\n lessons_every: int = Field(default=4, ge=0)\n lessons_refresh_every: int = Field(default=4, ge=0)\n # B3 output redaction: mask credentials (known key shapes + high-entropy tokens) in the\n # stdout/stderr tail before it is persisted to the event log / spans / UI — a leaked secret in a\n # print()/traceback must not land in the durable log. Off by default; recommend on for untrusted.\n redact_output: bool = False\n # B5 reward-hacking detector: a host-side monitor that flags suspicious wins (grader/answer-key\n # access, runtime writes to frozen files, suspiciously-perfect metrics) as a `reward_hack_suspected`\n # audit event in the Trust panel. Off by default. Whether a flag CHANGES selection is governed by\n # `trust_gate` below (default: audit-only, never changes selection).\n reward_hack_detect: bool = False\n # Trust enforcement (T2): what a reward-hack / data-leakage flag actually DOES to selection.\n # \"audit\" (default) = surface it in the Trust panel, never change selection (today's behavior);\n # \"gate\" = a flagged node can no longer be selected as BEST **and isn't bred/confirmed from**\n # (breed_excluded — see replay._apply_trust_gate / models.breedable_nodes), but stays\n # FEASIBLE so it still counts for diversity/audit — closes the \"a hacked/leaky node can\n # win OR seed its lineage\" hole;\n # \"block\" = additionally mark it fully INFEASIBLE (removed from feasible_nodes entirely).\n # Deliberately gates only high-precision CHEATING/LEAKAGE signals. Broad `critic:*` findings stay\n # advisory; `critic:hardcoded_metric` is the one narrow high-precision critic exception\n # and gates because it requires a literal metric with no computed assignment anywhere.\n trust_gate: str = \"audit\"\n # I3 data-centric: static code-leakage scan of each evaluated solution (fit-before-split,\n # fit-on-test) surfaced into the Trust panel alongside reward-hack flags. Off by default.\n code_leakage_detect: bool = False\n # 4.4 sandbox instrumentation (needs reward_hack_detect): after each eval, flag a RUNTIME write\n # to a protected/frozen file (an answer key or grader tampered mid-run) — behavioral evidence a\n # static code scan misses. ON by default; only fires when the reward-hack detector is on.\n workdir_audit: bool = True\n # D8 decoupled research Verifier: check every Deep-Research memo's CLAIMS against their cited\n # evidence (node ids / urls) before recording — synthesis is the documented weak link (Kosmos\n # 57.9% accurate). Deterministic layer always; an LLM rubric pass when the DeepResearcher has a\n # client. The verdict cannot change this run's node/champion, but finalize uses aligned supported\n # verdicts as the evidence gate for persisted D8 cross-run claims; unavailable/misaligned stays\n # unverified. ON by default (no-op with no memo/claims).\n research_verify: bool = True\n # C4 independent critic: an execution-free critic of each solution (stub / hardcoded-metric /\n # params-ignored; on host-graded tasks the metric checks become a submission-output check)\n # surfaced in the Trust panel. Broad findings are advisory; `critic:hardcoded_metric` can gate under\n # trust_gate=gate/block. Off by default.\n critic_check: bool = False\n # A7 Strategist (NEW, user-requested): optional LLM/rule meta-controller that picks the search\n # policy/allocator + operator mix + fidelity (+ Developer backend) per situation. Config-first:\n # every choice the Strategist makes is also a direct knob. Defaults to \"agent\" so the agent adapts\n # the search policy/operators/fidelity per situation; set \"off\" for static config-driven search.\n # \"agent\" = a tool-using Strategist that READS the run/data/sibling-runs/KB/memory (B1-guarded)\n # before deciding, instead of the single-shot \"llm\" call over aggregate stats.\n strategist_backend: str = \"agent\" # \"off\" | \"rule\" | \"llm\" | \"agent\" — default AGENTIC: the\n # Strategist READS the run/data/sibling-runs/KB/memory with tools before deciding, not a single-shot\n # call over aggregate stats. \"llm\" = the old non-agentic single-shot; \"rule\"/\"off\" = no LLM.\n strategist_every: int = Field(default=3, ge=1) # consult cadence (created nodes)\n # PART V (F1): concept CLASSIFIER re-tag + consolidation cadence, DECOUPLED from strategist_every. The\n # LLM concept map is heavier and slower-moving than a strategy consult, so it refreshes on its own\n # (sparser) interval. Researcher-authored idea.concepts still fold immediately at node_created — this\n # only paces the classifier-evidence + consolidation refresh (and the concept-coverage pivot snapshot).\n concept_retag_every: int = Field(default=30, ge=1)\n # === Confirmation & holdout ===========================================================\n # Multi-seed confirmation (I12, ADR-15): confirm the top-k under N seeds before\n # finishing. 0 disables (default). Only meaningful when eval has variance.\n confirm_top_k: int = 0\n confirm_seeds: int = 0\n # D1 seed-holdout discipline: the FIRST seed the confirm phase uses. Search evals run with the\n # implicit seed 0 (LOOPLAB_EVAL_SEED unset -> \"0\"), so a base of 1 keeps every confirm split\n # DISJOINT from anything the search optimized against — the confirm metric is then a\n # generalization signal, not a re-measurement (AIRA: selecting on a signal the search saw\n # overfits by 9-13 pp). Set 0 to restore the legacy overlapping seeds 0..N-1.\n confirm_seed_base: int = Field(default=1, ge=0)\n # D1 holdout-gated promotion (B6, Arbor-style): for host-graded tasks, reserve this fraction of\n # the held-out labels as a FINAL holdout partition the search never sees — every search/confirm\n # eval is scored on the remaining rows only, and at finish the val-top-k are re-scored on the\n # holdout partition (free: the predictions already exist). 0.0 = off (legacy full-label scoring).\n holdout_fraction: float = Field(default=0.25, ge=0.0, le=0.9)\n # Champion selection prefers the HOLDOUT metric among the nodes that have one (the val-top-k).\n # Recorded in run_started so replay applies the same rule; old logs fold to False (legacy pick).\n # The per-node val-holdout `generalization_gap` folds into the Trust panel either way.\n holdout_select: bool = True\n # How many val-leaders get a holdout evaluation at finish (host-graded tasks).\n holdout_top_k: int = Field(default=3, ge=1)\n # R1-c / Part IV — calibrated §12-verifier tie-break in best-selection. When a fresh node's robust\n # metric EXACTLY TIES an already-eligible node, the engine runs the §12 verifier (grounded on each\n # node's realized result, `selection_criteria`) and records a soundness score per node. The tie-break\n # then applies across the WHOLE selection path: the mean pick breaks a robust_metric tie by the score,\n # and — when `holdout_select` is on — the holdout-slot ranking prefers the sounder node so it isn't\n # denied a holdout eval, and the final holdout override breaks a holdout_metric tie by it too. Strictly\n # a TIE-BREAK — it can NEVER move a node ahead of a strictly-better metric (§21.7 advisory-never-\n # overrides). Best-effort: verification is LAZY (only fires on an actual tie) and bounded per cadence,\n # so a large/late tie stays on deterministic metric+id order unless one atomic treatment scores every\n # member. Recorded in run_started so\n # replay applies the same rule; old logs fold to False (legacy pick). Opt-in (default off); needs a\n # reachable LLM client. Enable only after validating calibration offline with `verifier.calibrate`.\n # See trust/verifier.py + core/fitness.py.\n select_verifier: bool = False\n # R1-d (§21.19): widen the verifier tie-break from EXACT-metric to a conservative STATISTICAL tie.\n # The band is capped by both the metric leader's SE and the pooled SE-of-difference, so challenger\n # variance cannot widen it and a significant difference is never a tie (§21.7). Off -> exact only.\n verifier_ci_tie: bool = False\n # Verifier sample count for `select_verifier` (the §12 repeated-sampling expectation). 3 tames the\n # single-shot variance; 1 = single-shot (cheaper, noisier).\n select_verifier_samples: int = Field(default=3, ge=1, le=32)\n # Budget (I13): hard wall-clock ceiling; the run aborts cleanly when exceeded.\n max_seconds: float | None = None\n # Eval-compute budget (#2): hard ceiling on cumulative time spent INSIDE evals (training\n # runs), separate from wall-clock. Survives resume (summed from the event log). The real\n # guard against a silent multi-hour sweep when an eval is a minutes-hours training run.\n max_eval_seconds: float | None = None\n # Cross-run memory (I19, ADR-10): if set, the best result of each run is stored as\n # a case here, and the cases become retrievable knowledge for future runs.\n memory_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"memory\"))\n # HITL (I21, ADR-11): pause for human approval of the final best before finishing.\n require_approval: bool = False\n # Diversity archive (I22): niche bucket width in parameter space.\n archive_resolution: float = 1.0\n # Coverage read-model (narrowing signal): at the strategist cadence, compute a breadth summary\n # (themes / param-niches / theme entropy / dominant-theme fraction) from the folded run, record\n # it as a `coverage_snapshot` audit event, and surface it into the Strategist's decision context.\n # Deterministic, cheap, and purely additive context — no search-behavior change on its own. On by\n # default (like the rest of the always-on situational context). See search/coverage.py.\n coverage_context: bool = True\n # PART IV Phase 2a live steering (§21.11/§21.13). When on, the `concept_retag_every` cadence (NOT\n # `strategist_every` — the producer gates on `_should_consult_concepts`, which uses the seed boundary\n # + `concept_retag_every`, default 30) records a\n # concept-graph coverage + uncovered-region snapshot (deterministic heuristic tagger over the\n # task-type skeleton) and, on an `explore` stance, the Researcher's novelty hint names the exact\n # uncovered regions (\"0 coverage in {negatives/external-mining, distillation} — go there\") instead\n # of the vague \"broaden\". ON by default in the product Settings (ce4a379), EngineOptions off: it\n # only enriches an already-mode-gated explore hint, never forces exploration, and no-ops for a task\n # with no curated concept skeleton. The snapshot is auditable, but its prompt cue can change future\n # candidate generation; it does not directly rewrite best-metric selection. See search/concept_graph.py.\n # DESIGN NOTE (2026-07-17 critique): now ON unconditionally, but a toy / non-ML task yields EMPTY concept\n # tags while still paying the per-node LLM concept cost (~10 calls/node with the full Part IV/V bundle).\n # Consider AUTO-QUIESCING: if the heuristic tagger returns empty for the first N nodes (no concept signal),\n # skip the LLM concept work for the run — keeps it on for ML tasks, free on toy/degenerate ones.\n concept_pivot: bool = True\n # PART IV Phase 2b — D3 graded novelty into the LIVE novelty gate (§21.4/§21.13). When on and the\n # task has a curated concept skeleton, the novelty gate first grades a proposal over the concept\n # graph: a level-4 \"same direction, DIFFERENT implementation\" or a level-5 \"re-opens a\n # wrongly-abandoned FAILED direction\" proposal is ALLOWED through unchanged — the flat LLM/semantic\n # dedup gate can't tell \"this DCL tweak\" from \"the whole DCL branch\" and would wrongly reject a\n # legitimate variant or never re-open a sound-but-killed direction (the node_63 archetype). Levels\n # 1/2/3 (identical / near-dup / prior-run) still fall through to the existing gate. Deterministic\n # (heuristic tagger, no LLM), audit event `novelty_graded`, replay-safe. ON by default in the product\n # Settings (ce4a379), EngineOptions off; no-ops for a task with no skeleton. See search/graded_novelty.py.\n graded_novelty: bool = True\n # PART IV Phase 2b — D7 capability-expansion forced-jump DIRECTIVE (§21.8/§21.13, issue #7). When on\n # and the concept-graph cadence detects action-space LOCK-IN (the search has stayed inside one D5\n # branch for a long consecutive streak) on an `explore` stance, the Researcher's novelty hint\n # ESCALATES from \"broaden\" to \"expand the ACTION SPACE — build the missing infra (external mining,\n # a new data pipeline, a different eval), do NOT swap another {locked} lever.\" The same\n # gate also stamps the proposal's authoritative operator as `expand`; that node competes normally\n # under SearchFitness and its separate operator yield is measured. It does not force a winner.\n # Reads the 2a concept-coverage snapshot, so it needs `concept_pivot` to record one. OPT-IN\n # (default off). See engine/proposal_cues.py + search/lock_in.py.\n capability_expansion: bool = False\n # PART IV cross-run Step 0 (§21.20.12/§21.20.13). Universal task-fingerprint tokenization: drop the\n # ASCII-only `[a-z0-9]` allowlist on goal keywords so a non-Latin goal (Russian, CJK, …) is NOT\n # silently dropped from its cross-run fingerprint (today such a goal keys only on kind/dir/metric and\n # never reaches SIMILAR-task priors/lessons/cases). Uses `[^\\W_]+`/`.casefold()` — same splitting as\n # before, any script. ON by default in the product Settings (ce4a379); the bare-library EngineOptions\n # default stays off, so a run pinned to it won't silently re-key a portfolio mid-flight. See engine/memory.py.\n fingerprint_universal: bool = True\n # PART IV cross-run Step 2 (§21.20). Fill graded-novelty's `prior_concepts` from the cross-run\n # ConceptCapsuleStore: at run end write a per-run concept capsule (the shipped `node_concepts` tags +\n # best outcome, keyed by task_fingerprint) to `memory_dir`; when a later SIMILAR run proposes an idea\n # whose concept was tried before, SURFACE the prior outcome as a `cross_run_prior` audit event (never\n # reject — D3 level 3). Audit-only, off the selection path; ON by default in the product Settings\n # (ce4a379), EngineOptions off. Needs a `memory_dir` to share capsules.\n # See engine/memory.py (ConceptCapsuleStore) + engine/novelty.py.\n # PREREQUISITES (this flag is NOT standalone): the WRITE needs per-run concept tags\n # (`node_concepts`, produced by `concept_pivot`/F1 tagging) — no tags => no capsule; the SURFACE path\n # runs inside the graded-novelty precheck, so it also needs `graded_novelty` on. With only this flag +\n # `memory_dir` it persists/surfaces nothing. Enable `concept_pivot` + `graded_novelty` alongside it.\n # A non-Latin (Russian/CJK) portfolio should ALSO set `fingerprint_universal`, else the capsule's goal\n # tokens are dropped and its fingerprint over-matches on kind/dir/metric alone (the novelty direction\n # gate narrows this, but goal-word similarity is still lost).\n cross_run_concepts: bool = True\n # PART V (B): run-base + node-DELTA concept authoring. When on, once the first evaluated node has\n # authored concepts the engine seeds RunState.run_base_concepts from them (a one-shot EV_RUN_CONCEPTS,\n # idempotent because it only fires while the base is empty), and the Researcher's proposal hint tells it\n # to author only `concepts_added`/`concepts_removed` vs the run base + its parent — minimal per-node\n # annotations that inherit down the DAG (a swap propagates). Off -> every node authors its full concept\n # set as before. Product-on; the bare EngineOptions mirror defaults off (test_options_divergence).\n concept_run_base: bool = True\n # PART IV cross-run Step 5 advisory (§21.20.5). Fold the bounded cross-run CONTEXT PACK — evidence-\n # grounded claims with BOTH support and counter-evidence (Step 4) + a portfolio-coverage line (Step 3) —\n # into the Researcher's proposal prompt, exactly like the E4 cross-run prior note. Advisory ONLY: it is\n # prompt-grounding: it can change the Researcher's later candidates, but does not directly rewrite\n # best-metric selection (§21.7). Reads `memory_dir` (lessons.jsonl + concept_capsules.jsonl); \"\" when\n # empty. ON by explicit experimental product choice; no frozen A/B validation is claimed. The\n # bare-library EngineOptions default stays off (engine/options.py, frozen in\n # test_options_divergence). See engine/proposal_cues.py.\n cross_run_advisory: bool = True\n # PART IV cross-run §21.20.13 (full CR of the lean fuzzy claim merge). Switch the claim read-model to the\n # SCOPE+POLARITY-safe STRUCTURED claim key (engine/claim_key.py): claims from different tasks never\n # merge, opposite polarity (\"X helps\" vs \"X never helps\") is surfaced as a CONTRADICTION instead of being\n # collapsed, paraphrase/inflection variants group by exact structured key (no transitive over-merge), and\n # operator governance is scope-precise (a decision in task A cannot reach a same-worded claim in task B).\n # Affects the `cross_run_advisory` context pack only; ON by default in the product Settings (ce4a379);\n # the bare-library EngineOptions default stays off (engine/options.py). See engine/claims.py.\n cross_run_structured_claims: bool = True\n # PART IV cross-run §22.4 (AGENTIC taxonomy steward). At finalize, when an LLM client is available, let\n # the concept steward (engine/concept_steward.py) review the freshly-updated portfolio concept graph and\n # PROPOSE a curation — merge duplicate slugs / split conflated ones / purge noise. Proposals are LOGGED to\n # `concept_curation_log.jsonl` for operator ratification (via the serve/CLI governance surface); the LLM\n # never mutates fold state. The calls run synchronously during finalize, so they add latency and may add\n # paid inference; a caught steward failure does not prevent terminal completion or trigger a retry.\n # ON by explicit experimental product choice when `memory_dir` + an LLM backend are present; the\n # bare-library EngineOptions default stays off (engine/options.py). See engine/lessons.py.\n cross_run_curation: bool = True\n # Deprecated compatibility flag. Steward output is untrusted and finalize is proposal-only regardless\n # of this value; retained so old snapshots still validate and logs can disclose that auto was requested.\n cross_run_curation_auto: bool = False\n # Role backend (ADR-7/14): \"toy\" (offline optimizer) | \"llm\" (live model). Validated in\n # `_check_trust_gate` alongside the other enum-ish strings — every consumer tests `== \"llm\"`\n # exactly (cli/__init__.py, adapters/tasks.py), so an untyped `--set`/file/env typo (or a mis-cased\n # \"LLM\") used to fall through to the OFFLINE toy roles and quietly produce a run that never called\n # the model. Left as `str`, not `Literal`, so old snapshots still validate through the same path.\n backend: str = \"toy\"\n # Developer backend (ADR-7): \"default\" (templated/LLM from the task) or an external\n # CLI coding agent: \"opencode\" | \"aider\" | \"goose\" | \"continue\". A CLOSED enum (\"default\" +\n # cli_agent.PRESETS keys), validated loudly in `_check_trust_gate` — an unknown value used to reach\n # adapters/tasks.py (`developer_backend not in PRESETS`) and silently wire the DEFAULT in-house\n # developer, the same silent downgrade the `backend` guard closes.\n developer_backend: str = \"default\"\n # C2 best-of-N: generate N candidate implementations per node and keep the best by an\n # execution-free reward (static validity + metric-print). 1 = off. In-house LLM developer only\n # (not external agents — ADR-7 cost rule). The top SWE-bench reliability lever for weak models.\n best_of_n: int = 1\n # D10 list-wise best-of-N selection: when the top static-scorers TIE, break the tie with a\n # comparative LLM selection over the candidates presented together (+~3 pts vs pointwise on\n # GAIA, arXiv:2506.12928). The static score stays the primary filter — the LLM is a weak\n # comparative prior, never the sole oracle. ON by default (no-op when best_of_n==1).\n best_of_n_listwise: bool = True\n # === Foresight / predict-before-execute ===============================================\n # FOREAGENT predict-before-execute (arXiv:2601.05930): use the LLM as an IMPLICIT WORLD MODEL to\n # predict — WITHOUT executing — which candidate / hypothesis will score best, primed with a\n # Verified Data Analysis Report (data profile / brief) + the accumulated experiment memory. Master\n # switch, ON by default; a no-op wherever there is nothing to rank (best_of_n==1 and\n # foresight_panel==1) or no LLM client. See `looplab/search/foresight.py`.\n foresight: bool = True\n # Hypothesis foresight breadth: generate K candidate ideas per proposal and keep the one PREDICTED\n # best pre-execution by the world model — it ranks the STRUCTURAL / text ideas the numeric k-NN\n # surrogate (researcher_panel) is blind to, primed with the data profile + memory (the synergy).\n # >1 enables it (LLM backend only; needs `foresight` on); 2 = on by default at modest cost, 1 = off.\n foresight_panel: int = 2\n # AGENTIC foresight: run the ranking (hypothesis-board prioritization + K-idea pick) as a TOOL-USING\n # loop that can pull actual experiment results / data facts before deciding, instead of a one-shot\n # prediction from a pre-baked report. ON by default (needs foresight + a client; falls back to the\n # one-shot predictor on any hiccup). A few extra LLM calls per proposal; set False for the cheaper\n # single-call ranker.\n foresight_agentic: bool = True\n # §1 foresight confidence gate: the minimum predicted confidence at which a predict-before-execute\n # pick is ACTED ON. Below it the ranker abstains — the K-idea panel falls back to the first\n # proposal, best-of-N falls through to the D10 tie-break — instead of committing a low-confidence\n # choice. 0.0 (default) = OFF (act on every pick, the historical behavior); raise toward ~0.5 to\n # make the world model defer when it isn't sure. Pairs with the foresight track record (§1) the\n # predictor is now primed with. Range 0.0-1.0.\n # BOUNDED like its confidence/fraction siblings (train_monitor_kill_confidence,\n # asha_live_quantile). Unbounded, a typo'd 5.0 validated silently into a gate no score can ever\n # clear, so every foresight pick abstained forever with nothing in the snapshot to say why.\n foresight_min_confidence: float = Field(default=0.0, ge=0.0, le=1.0)\n # PART IV Phase 2c — replace the world model's SELF-REPORTED confidence (measured Pearson≈0 with the\n # realized outcome, §21.12) with a CALIBRATED §12-verifier score. When on and a client is available,\n # after the K-idea ranker picks the predicted-best candidate the §12 verifier scores it (grounded +\n # repeated + criteria-decomposed: `foresight_criteria` — likely to improve the objective, and\n # sound/feasible) and THAT calibrated score becomes the confidence the `foresight_min_confidence` gate\n # and the telemetry use. Degrades to the self-reported confidence without a client or on any verifier\n # error (the telemetry records which source was used). ON by default in the product Settings (ce4a379),\n # EngineOptions off; a few extra LLM calls per acted-on proposal. See looplab/trust/verifier.py + search/foresight.py.\n # REVISIT (cost/quality, not yet resolved): a live classification run measured ~10 LLM calls & ~3.8 min/node\n # on deepseek-v4-flash with the full Part IV/V default bundle; the calibrated verifier is part of that per-node\n # tax. Kept ON deliberately — it raises decision quality (self-reported foresight confidence has ≈0 Pearson\n # with realized outcome), and lowering quality to save calls is the wrong trade. TODO: run a frozen quality/cost\n # A/B (verifier-on vs -off) to quantify the recall@objective gain per extra call before considering a default flip.\n foresight_verify: bool = True\n # Verifier sample count for `foresight_verify` (the §12 repeated-sampling expectation on a no-logprob\n # backend). 3 tames the single-shot variance §21.12 measured; 1 = single-shot (cheaper, noisier).\n # RESUME-critical: this value is persisted in config.snapshot.json, so a hard `le` schema bound would\n # make an old run started with a larger LOOPLAB_FORESIGHT_VERIFY_SAMPLES un-resumable (Settings(**snapshot)\n # would raise on `looplab resume` / finalize-recovery / serve replay). CLAMP into [1, MAX] via a validator\n # instead of rejecting — the runtime cap is preserved without breaking the snapshots/env-compat rule.\n foresight_verify_samples: int = 3\n\n @field_validator(\"foresight_verify_samples\")\n @classmethod\n def _clamp_foresight_verify_samples(cls, v: int) -> int:\n # Coerce a persisted/env value into [1, MAX] rather than reject (see field note above).\n try:\n v = int(v)\n except (TypeError, ValueError):\n return 3\n return max(1, min(v, MAX_FORESIGHT_VERIFY_SAMPLES))\n\n # T7 LLM response cache: serve an identical DETERMINISTIC (temperature 0) request from an\n # in-process content-addressed cache instead of re-hitting the model — cuts cost on\n # retry/panel/verify flows. NEVER caches sampling calls (temp>0: best-of-N / panel / novelty\n # re-propose must vary). Off by default (most role calls run at temperature>0 anyway).\n llm_cache: bool = False\n agent_cmd: str | None = None # override the agent's launcher (path / wrapper)\n # External-agent validation (ADR-7): wrap a CLI-agent Developer with a validator that\n # audits each output (no-op/syntax/crash/timeout), retries with feedback, and falls\n # back to the in-house LLM Developer. Off -> use the raw agent output unchecked.\n validate_agent: bool = True\n agent_max_retries: int = 1 # re-prompts of the agent on an invalid result\n # Patch-gated multi-file agent (ADR-7 Rule 3): run the CLI agent in a git worktree and\n # accept only edits whose paths match `agent_surface` (reject-not-strip out-of-surface\n # touches). Lets the agent create helper modules, not just solution.py. Degrades to\n # whole-file readback if git is unavailable.\n agent_patch_gate: bool = True\n agent_surface: list[str] = [\"*.py\"] # edit-surface allow-list (globs)\n # Trust policy for an agent-authored eval/metric adapter (RepoTask onboarding, Phase 3):\n # \"ratify_freeze\" waits for human approval, \"autonomous\" accepts the proposed spec without that\n # gate, and \"ratify_freeze_drift\" also enforces the configured independent drift cross-check.\n eval_trust_mode: str = \"ratify_freeze\"\n # RepoTask node seeding policy (run-wide fallback; a task/editable `seed_mode` overrides):\n # \"auto\" (default) copies git-tracked files when the editable is a git repo (so a tree bloated\n # with untracked artifacts isn't deep-copied per node), else a full copy; \"tracked\" forces\n # code-only; \"all\" forces a full recursive copy (legacy). Genesis authors per-task from the\n # user's words; this is the global default when unspecified.\n seed_mode: str = \"auto\"\n llm_model: str = \"qwen3:8b\"\n # === LLM / transport ==================================================================\n llm_base_url: str = \"http://localhost:11434/v1\" # Ollama OpenAI-compatible endpoint\n llm_temperature: float = 0.6\n # H3 per-role model presets (5090 recipe): optionally run the Researcher and Developer on\n # DIFFERENT models/endpoints (e.g. Developer=Qwen3-Coder-30B for code, a fast model for breadth).\n # Blank = use the shared llm_model/llm_base_url. Generalizes the role-backend swap to per-role.\n researcher_model: str | None = None\n developer_model: str | None = None\n researcher_base_url: str | None = None\n developer_base_url: str | None = None\n # Per-role sampling temperature (§4.1): the Researcher wants breadth (higher temp = more diverse\n # ideas), the Developer wants determinism (lower temp = fewer syntax slips), the Strategist a\n # steady hand. Each overrides the shared `llm_temperature` for that role only; None = use the\n # shared value (byte-identical to before). Flat + nullable, mirroring the per-role model fields.\n researcher_temperature: float | None = None\n developer_temperature: float | None = None\n strategist_temperature: float | None = None\n # The Strategist had per-role TEMPERATURE but no model/endpoint of its own, so in split mode it\n # always ran on the shared llm_model however the other roles were pointed. These close that gap\n # with the same names/shape as the pair above; blank = its profile, else the shared value.\n strategist_model: str | None = None\n strategist_base_url: str | None = None\n # === Connection profiles (multi-provider) =============================================\n # A \"profile\" is a NAMED connection: {model, base_url, temperature, api_key_env, provider}. Roles\n # point at one by name through `role_profiles`; `llm_profile` is the default for every role that\n # doesn't. All three are EMPTY by default and the resolver short-circuits to the shared\n # llm_model/llm_base_url/llm_api_key path when they are — an operator running one model never\n # needs to know the word \"profile\".\n #\n # A profile stores `api_key_env`, the NAME of an environment variable, never the key itself. That\n # is ADR-11's \"a secret is a reference, not a value\" turned from a convention into a data type:\n # the whole profile map is then safe to write into config.snapshot.json, hand back over HTTP and\n # render into LOOPLAB_* — there is nothing in it to mask. It is also what makes per-role\n # CREDENTIALS expressible at all: two roles on one provider can carry different keys, which a\n # key-per-endpoint map could not say.\n llm_profiles: dict[str, dict] = Field(default_factory=dict)\n llm_profile: str | None = None\n role_profiles: dict[str, str] = Field(default_factory=dict)\n # Unified self-driving agent: one LLM identity that plays Researcher + Developer (+ Strategist)\n # across pipeline stages, choosing its own model/toolset per stage. ON by default — the agent\n # drives the loop (incl. crash triage: repair/abandon/reject_idea) and, via\n # `agent_drives_actions`, picks the next macro action within a pure legal-action gate. Set both\n # to False for the legacy byte-identical split-role behavior. (No-op unless backend=\"llm\".)\n unified_agent: bool = True\n agent_drives_actions: bool = True\n # Layer 3 Card queue ownership. False preserves the existing policy/unified-pilot action path.\n # True is pinned in run_started because changing the selector on resume would mix two search\n # treatments in one run. When both this and agent_drives_actions are true, Card selection wins.\n card_driven_selection: bool = False\n # Layer 5 bounded speculative Card buffer. Zero is a hard OFF switch and preserves the\n # historical alternating build/eval spine; positive values are pinned by run_started so a\n # resume cannot silently mix search treatments after a snapshot/default edit.\n speculation_depth: int = Field(default=0, ge=0, le=64)\n # Local evidence receipt produced by ``looplab speculation-gate``. This is deliberately absent\n # from the curated Settings UI: positive-depth execution is a calibrated rollout decision, not a\n # casual tuning knob. The Engine validates the receipt against the current scorer,\n # implementation, GPU inventory and raw paired-run evidence before it enables speculation.\n speculation_gate_receipt: str | None = Field(default=None, max_length=4096)\n # Per-stage model/endpoint overrides for the unified agent. Recognized keys (see\n # tasks.build_unified_agent): `propose` (researcher), `implement`/`repair` (developer),\n # `strategy`, `pilot`. Unlisted keys are ignored. Empty = the per-role researcher_*/developer_*\n # models, then the shared llm_model. Explicit map wins. No-op unless `unified_agent` is true.\n agent_stage_models: dict = {}\n agent_stage_base_urls: dict = {}\n llm_parser: str = \"tool_call\"\n # Reasoning/thinking toggle sent IN the request (providers differ — see `llm.reasoning_body`):\n # Qwen3 on vLLM/SGLang -> chat_template_kwargs.enable_thinking (bool)\n # OpenAI/Ollama-v1/DeepSeek -> reasoning_effort (low|medium|high|none)\n # `llm_reasoning`: \"\" = send nothing (server default — current behavior); off = actively disable;\n # on = enable at default depth; low|medium|high = enable at that effort.\n # Defaults to \"high\" so the agent reasons before proposing/repairing/triaging (better code, fewer\n # crashes); set \"\" to send nothing (server default) or \"off\" to disable. The model's chain-of-thought\n # is CAPTURED either way (reasoning_content field or inline <think>). NOTE: to get a SEPARATE\n # reasoning_content field from SGLang/vLLM, the server also needs `--reasoning-parser qwen3`.\n llm_reasoning: str = \"high\"\n llm_reasoning_style: str = \"auto\" # auto | qwen | effort | none — how to shape the request param\n llm_reasoning_extra: dict = {} # raw fields merged into the body (escape hatch, e.g. Anthropic thinking)\n # H1: drive structured calls with the endpoint's constrained decoding (vLLM/SGLang guided_json +\n # response_format json_schema) so weak models can't emit invalid JSON. Off for Ollama (default).\n llm_guided_json: bool = False\n # Stream every LLM request (SSE) and reassemble it, so the request `timeout` is an INTER-TOKEN\n # idle timeout rather than a whole-request deadline: a long-but-alive generation is never cut off\n # (tokens keep resetting the timer), while a genuinely STALLED endpoint (no token for `timeout` s)\n # is detected and retried on a fresh connection — the fix for silent multi-minute hangs. Set False\n # to use one blocking read (old per-op timeout semantics) if an endpoint streams badly.\n llm_stream: bool = True\n # LLM IDLE timeout (seconds). Stream mode: the INTER-TOKEN stall limit — a steady generation is\n # never cut off, only a silent endpoint. Non-stream: bounds the whole read. Raise for endpoints\n # with a long prefill on huge prompts; lower to fail fast on a flaky shared endpoint.\n llm_timeout: float = Field(default=180.0, gt=0)\n # First-byte (response-headers) window for STREAM attempts, seconds: an SSE response sends its\n # headers on admission, so no-headers ≈ a black-holed request — fail over to a fresh connection\n # fast instead of waiting the full idle timeout. Clamped to llm_timeout. Non-stream attempts are\n # NOT bounded by this (their headers arrive only after the whole generation). The default mirrors\n # `DEFAULT_HEADER_TIMEOUT_S` in looplab/core/llm.py is the single source (imported above).\n llm_header_timeout: float = Field(default=DEFAULT_HEADER_TIMEOUT_S, gt=0)\n # Honour HTTP(S)_PROXY / NO_PROXY / SSL_CERT_FILE env vars for LLM calls. Default False: the\n # transport connects DIRECTLY (many internal/OLAP endpoints are reachable only by bypassing an\n # ambient proxy — a picked-up proxy yields \"connection refused\"). Set true when the endpoint is\n # reachable ONLY via a corporate proxy or needs a custom CA bundle from the environment.\n llm_trust_env: bool = False\n # H4/C2: compact the growing agentic tool-call history once it exceeds this many chars (auto_summary\n # LLM-summarizes the stale middle; else middle-truncate). Sized to the model's real context window so\n # files read earlier STAY in context instead of being compacted away and re-read: ~1,000,000 chars ≈\n # 250k tokens (≈4 chars/token), matching the configured deepseek-v4-flash (≥200k tokens confirmed).\n # The old 120k-char (~30k-token) default was ~8× too aggressive and drove a read-thrash loop. 0 = off.\n context_budget_chars: int = 1_000_000\n # M5: the Researcher's always-on experiments digest budget (chars). 0 = AUTO — scales with the\n # run size (60 chars/node, bounded [1200, 6000]) instead of one flat cap for an 8-node toy run\n # and a 200-node benchmark run alike. Set a positive value to pin it.\n digest_char_cap: int = Field(default=0, ge=0)\n # D11 compression model slot (open_deep_research's four-slot pattern): a dedicated CHEAP model\n # for agent-history summarization (C2 auto-summary), so compression doesn't pay the main\n # model's price. Blank = use each loop's own model (legacy). `compressor_base_url` blank =\n # the shared llm_base_url.\n compressor_model: str | None = None\n compressor_base_url: str | None = None\n # === Agentic tool-loop ================================================================\n # Agentic tool-loop limits — apply to EVERY tool-using agent (the LLM Researcher, the unified\n # agent's pilot + crash-triage, Deep-Research, the run-chat Boss, the genesis repo-scout, and the\n # cross-run report synthesizer). The loop lets the model call read-only tools across turns before\n # emitting its structured result. Both default to UNLIMITED so the agent takes as many turns / as\n # long as it needs — it is never cut off mid-reasoning; set a positive value here (or per-run, or\n # in the UI settings) to bound latency/cost. These used to be hardcoded per call site (e.g. the\n # boss at max_turns=3 / 45s), which silently dropped a slow reasoning model to a no-op reply.\n agent_max_turns: int = 0 # max tool turns before the emit is forced (0 = unlimited)\n agent_time_budget_s: float = 0.0 # wall-clock ceiling across the loop's turns (0 = no cap)\n # G · Soft convergence SAFETY NET (not a research cap). Two thresholds on the agent's tool-turn count:\n # at `agent_emit_after` the loop NUDGES the agent once (\"you've investigated enough — emit now\"); at\n # `agent_emit_force` it FORCES the emit from what was gathered. Set GENEROUSLY — a focused researcher\n # may legitimately read many files/experiments before it can propose a grounded idea; these only catch\n # a genuine runaway that keeps issuing DIFFERENT calls forever (which stuck-detection, keyed on\n # repeats, misses). NB: the old 499-read runaway (node 63) was 79% REDUNDANT re-reads caused by a\n # broken read_file — fixing read_file (pagination) is the real cure; this is just insurance. 0 = off.\n agent_emit_after: int = 300\n agent_emit_force: int = 500\n # B1 · No-progress / stuck detection — the safety net that makes \"unlimited turns\" safe. The\n # turn/time ceilings above are only BACKSTOPS; this is what actually stops a runaway loop, on the\n # cheapest signal: when the model repeats the SAME tool call (or ping-pongs between two, or keeps\n # hitting the SAME error) with no progress, the loop forces the final emit and finishes. ON by\n # default; reading DIFFERENT files or running ONE long command never trips it. (OpenHands-style.)\n agent_stuck_detection: bool = True\n agent_stuck_repeat: int = 4 # identical calls in a row that count as \"stuck\" (>=2)\n agent_stuck_alternate: int = 4 # ping-pong cycles between two calls that count as \"stuck\" (>=2)\n # C1 · Self-plan (TodoWrite-style): expose an `update_plan` tool so a long-running agent keeps its\n # OWN working TODO and re-surfaces it every `agent_plan_reinject_every` turns — keeps the goal in\n # view across a long loop so the agent \"writes its own context\" (Claude Code style). ON by default.\n agent_self_plan: bool = True\n agent_plan_reinject_every: int = 5\n # C2 · Auto-summary (ON by default): when the tool-loop history grows long, LLM-summarize the\n # stale middle instead of only middle-truncating it. The trigger is `context_budget_chars` if set,\n # else a built-in high-water mark (context_budget.DEFAULT_SUMMARY_CHARS ≈ 120k chars) so short\n # loops are untouched. Falls back to truncation on any summarizer error; one extra model call per\n # over-budget turn.\n agent_auto_summary: bool = True\n # C4 · Repo-developer plan decomposition. A big feature attempted in ONE developer session made a\n # non-converging reasoning model loop indefinitely — it kept writing + exploring without ever\n # emitting `done` (10k+ LLM calls / hours; agentic stuck-detection can't catch VARIED exploration,\n # only literal repeats). Fix: the developer first proposes an ordered plan of ATOMIC steps; a\n # multi-step plan is executed step-by-step, each in a FRESH bounded session with only that step's\n # scope + the files accumulated so far, syntax-validated per write. Every session stays\n # small-context and convergent. ON by default for repo tasks (a 1-step plan == the old single pass).\n developer_plan_decompose: bool = True\n developer_plan_min_steps: int = 2 # a proposed plan with >= this many steps runs step-by-step\n developer_plan_max_steps: int = 8 # cap on plan length (a runaway planner can't spawn 100 steps)\n # Hard per-session backstop for the repo developer — the ONE write-heavy agent that can run away\n # even with stuck-detection on (varied writes/reads never trip the repeat/ping-pong signal). Bounds\n # the plan phase, EACH step, and the single-session fallback. Unlike the global agent_* limits\n # (0 = unlimited, meant for read-only agents), the developer always gets a FINITE ceiling so a\n # model that never emits `done` fails cleanly with the code it wrote so far, instead of looping.\n developer_session_max_turns: int = 500\n developer_session_time_budget_s: float = 1200.0 # 20 min wall-clock per developer session\n # Phase-handoff summaries. Each LLM phase in a node build (Researcher·propose → Developer·stages →\n # plan → implement) ends with ONE extra LLM call that distills its transcript — the repo structure\n # it mapped, files/data confirmed, decisions made — into a brief injected into the NEXT phase (even\n # across the role boundary). The next phase TRUSTS that instead of re-reading the same files, which\n # cuts the biggest source of tool-call thrash (stages/plan/implement each re-exploring the repo).\n # One summary call per phase in exchange for many fewer read calls downstream. ON by default.\n phase_handoff_summary: bool = True\n # Observability (ADR-17): capture each LLM call's full prompt + completion into the active\n # span (spans.jsonl) so the UI can show exactly what the model read and wrote per node.\n # Diagnostics only — `replay.fold` never reads spans. Default on for local single-user; set\n # LOOPLAB_TRACE_LLM_IO=0 to suppress (e.g. to keep spans.jsonl small or avoid storing prompts).\n trace_llm_io: bool = True\n # Run-introspection tools (ON by default): make the LLM Researcher (and DeepResearcher) a\n # tool-using agent that can read its OWN experiments (list/read/find-analogous/themes/code) and\n # the task data (schema/profile/asset) mid-loop, instead of seeing only best+parent. Advisory —\n # never changes best-selection. Off = the legacy single-shot Researcher (richer digest still added).\n researcher_tools: bool = True\n # Cross-run introspection: give the agentic Researcher / pilot read-only tools to look at\n # SIBLING runs (same task_id, same run-root) — list them, read an experiment / its code, and\n # find analogous configs across runs — so a run can build on what neighbouring runs already\n # learned instead of rediscovering it. Advisory; never changes best-selection. Needs the run's\n # own dir wired through (no-op for unit-built roles). Off = the legacy single-run view only.\n cross_run_tools: bool = True\n # Read-only access to EVERY run on this machine ACROSS ALL TASKS (not just same-task siblings):\n # the Developer/Researcher get list_all_runs + read_run_code + read_run_experiment so they can read\n # the code + result of ANY past experiment anywhere and reuse an approach. Broader than\n # `cross_run_tools` (same-task only); the agent decides when a foreign run is relevant. Advisory;\n # never changes best-selection. Needs the run's own dir wired through (no-op for unit-built roles).\n all_runs_tools: bool = True\n # PART V §22 — read-only CROSS-RUN KNOWLEDGE tool for the reasoning roles (Researcher, Strategist,\n # deep-research, and a Developer-scoped variant). Adds `cross_run_prior_attempts` / `cross_run_claims`\n # / `cross_run_atlas` over the §21.20 read-models (concept overview + claim assessments + atlas), read\n # from `memory_dir` (lessons.jsonl + concept_capsules.jsonl). Read-only is a storage-authority\n # boundary, not a behavioral no-op: retrieved content can steer later proposals, and a tool-using\n # reasoning loop can consume additional model calls. The tools never mutate cross-run truth (facts\n # are engine-written, verdicts operator-ratified, §22.4) or directly select a winner. ON by explicit\n # experimental product choice, EngineOptions off; stores only exist once Part-IV features have run.\n cross_run_read_tools: bool = True\n # Agentic retrieval (ADR-16): if set, the LLM Researcher gets grep/kb_search/read\n # tools over this directory of markdown notes and chooses when to use them.\n knowledge_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"knowledge\"))\n # T4 real embeddings: model id for an OpenAI-compatible `/embeddings` endpoint used for\n # kb_search / case retrieval. Blank (default) = the dependency-free lexical `hash_embed` (today's\n # behavior). Set e.g. \"nomic-embed-text\" (Ollama) or \"text-embedding-3-small\" for SEMANTIC\n # retrieval; a misconfigured/offline endpoint degrades back to hash_embed at call time (never\n # crashes a run). `embed_base_url` blank = reuse `llm_base_url`; the shared `llm_api_key` is used.\n embed_model: str | None = None\n embed_base_url: str | None = None\n # Harmonic memory (Memora, ICML'26 — idea import). When ON, cross-run cases and knowledge notes are\n # indexed by a short ABSTRACTION + cue ANCHORS instead of their raw text, near-duplicate memories are\n # CONSOLIDATED into one entry under a matching abstraction, and `kb_search`/case retrieval EXPAND\n # through the top hits' anchors to surface related-but-not-similar memories. ON by default. Set\n # `memora=false` to restore the pre-Memora raw-text index. Never a source of truth — abstractions\n # live only in the derived, rebuildable retrieval index (never the event log or canonical cases.jsonl).\n memora: bool = True\n # Write abstractions with the wired chat model (richer than the deterministic lexical fallback). ON\n # by default; results are CACHED by content hash (see `memora_cache`) so a re-built index doesn't\n # re-call the model on unchanged notes/cases, and any endpoint failure degrades to lexical at call\n # time — so an offline box just gets lexical abstractions, never a crash. Set false to force lexical.\n memora_llm: bool = True\n # === Cross-run memory / Memora ========================================================\n memora_anchors: int = 6 # max cue anchors kept per memory\n # Cosine similarity at/above which a new case is CONSOLIDATED into an existing one (same evolving\n # topic) rather than stored as a separate entry. Only used when `memora` is on.\n # CLAUDE REVIEW: [EDGE-CASE] unbounded 0..1 cosine knob with no Field bounds. Consumers compare\n # `cosine(...) >= consolidate_threshold` (engine/memory.py, tools/knowledge_tools.py) and cosine is\n # capped at 1.0, so a typo'd value >1.0 validates cleanly and SILENTLY DISABLES consolidation forever\n # (a negative one consolidates everything). Its 0..1 sibling novelty_semantic_threshold carries\n # Field(ge=0.5, le=1.0); this should be bounded the same way (e.g. ge=0.0, le=1.0) to fail loud.\n memora_consolidate_threshold: float = 0.86\n # Where the LLM-abstraction cache lives (JSON). Blank (default) derives it from `memory_dir`\n # (`<memory_dir>/memora_cache.json`) or else `knowledge_dir` (`<knowledge_dir>/.memora_cache.json`);\n # with neither set the cache is in-memory only (per process). No-op unless `memora_llm` is on.\n memora_cache: str | None = None\n # E3 literature-grounded ideation (network-OPTIONAL): give the agentic Researcher an arXiv search\n # tool to ground ideas in real techniques. Off by default (egress is unreliable on some boxes);\n # fails gracefully if the network is blocked.\n literature_search: bool = False\n # Deep-Research stage (network-OPTIONAL): give the DeepResearcher a general web search/fetch\n # tool (DuckDuckGo) on top of arXiv to read across results + the web before steering the next\n # batch. Off by default (egress is unreliable on some boxes); fails gracefully when blocked.\n web_search: bool = False\n # Cadence for the Deep-Research stage: run it automatically every N created nodes. Zero disables\n # only the cadence; manual `deep_research` and Strategist `request_research` triggers still fire.\n # Default 3: deep research analyzes the accumulating results and steers the next batch (its\n # directions become hints + open hypotheses); paired with `concurrent_research` it overlaps the\n # think with the GPU-bound eval, so the cadence costs LLM tokens but no wall-clock on the search.\n deep_research_every: int = 3\n # Overlap a DUE deep-research \"think\" with the GPU-bound eval instead of running it in its own\n # serial step (the agent is otherwise idle while a node trains). research() computes from a state\n # snapshot; the joined background task records only allowlisted, order-tolerant\n # research/hint/hypothesis events as soon as the memo finishes. ON by default: the LLM is typically\n # remote (no GPU contention with eval), so overlapping hides latency behind long training.\n concurrent_research: bool = True\n # Concurrent-research REPEAT — don't idle a multi-DAY eval. The overlapped \"think\" fires ONCE per\n # window by default; ON here, it RE-RUNS on an adaptive time cadence for the WHOLE eval window\n # (a two-day training must not leave the reasoning agents idle after one memo). Self-paced: it\n # only records a memo whose CONTENT is new (identical re-runs are skipped, so the log/board don't\n # bloat) and backs off geometrically as the analysis converges, capped so it always re-checks.\n # The appended records never rewrite the current metric champion, but their hints/open hypotheses\n # deliberately steer later proposals and replay reconstructs that advice. The library default is\n # one-shot (== today, byte-identical); this product default turns the repetition on.\n concurrent_research_repeat: bool = True\n # Base cadence (seconds) between repeated concurrent-research passes. Research is EXPENSIVE\n # (multi-turn LLM + web/arXiv), so this is a FLOOR, not a ceiling: the effective cadence is\n # max(this, ~5% of the per-experiment time budget) — a two-day eval is re-researched about hourly,\n # a short eval not at all (the first tick outlasts it). Only matters when the repeat is on.\n concurrent_research_interval_s: float = Field(default=1800.0, gt=0)\n # Per-eval-window backstop on repeated-research LLM calls (0 = cadence-only, no cap). The adaptive\n # cadence + convergence backoff are the primary budget control; this bounds a pathological\n # never-converging window. Past it the loop stops calling the LLM (the health monitor still runs).\n concurrent_research_max_calls: int = Field(default=40, ge=0)\n # Concurrent hypothesis-board CONSOLIDATION during the eval window. Repeated research keeps ADDING\n # near-duplicate directions to the open board (each memo registers up to 5 as open hypotheses), so a\n # long eval's board bloats before the between-nodes merge runs. This dedups/merges it on the SAME\n # background loop so the board stays clean for the next proposal. In Card-driven mode the same merge\n # changes ownership/readiness, so it is deferred to the joined main-task cadence instead. Needs the\n # reflect client + `track_hypotheses`.\n # Only active while `concurrent_research` runs the overlap loop. Library default off (== today).\n concurrent_consolidate: bool = True\n # Cadence for the agent-authored run report: regenerate the conclusion-first narrative every N\n # created nodes (0 = off; it still regenerates on a manual `report_refresh` from the UI). The\n # deterministic report always renders from the node set regardless of this knob.\n report_every: int = 3\n # Agent Skills (I18, ADR-9): dir of SKILL.md the Researcher can list/load as tools.\n skills_dir: str | None = None\n # Prompt store (I18, ADR-8): dir of editable, hot-reloaded role prompt .md files.\n prompt_dir: str | None = None\n # API key as a reference, never serialized as a value (ADR-11). Local servers\n # ignore it; default to a placeholder.\n llm_api_key: SecretStr | None = None\n\n @model_validator(mode=\"before\")\n @classmethod\n def _reject_bool_for_numeric_fields(cls, data):\n \"\"\"A JSON boolean is a TYPE ERROR for a numeric setting, not the number 1.\n\n Pydantic runs in lax mode here (`model_config` sets no `strict`), and `isinstance(True, int)`\n is True — so `{\"max_nodes\": true}` from a UI/API type slip validated cleanly as a budget of\n ONE node, and `{\"eval_parallel\": true}` collapsed the width to one. The run then looked\n configured and simply did almost nothing, with no 422 and nothing in the snapshot to show why.\n Rejecting at the boundary covers every numeric field at once, including the many that carry no\n `Field(ge=...)` bound of their own to trip over.\n \"\"\"\n if not isinstance(data, dict):\n return data\n for key, val in data.items():\n if type(val) is bool and _is_numeric_annotation(\n getattr(cls.model_fields.get(key), \"annotation\", None)):\n raise ValueError(f\"{key} must be a number, not a boolean (got {val!r})\")\n return data\n\n @model_validator(mode=\"before\")\n @classmethod\n def _apply_profile(cls, data):\n \"\"\"Expand `profile` into a bundle of setting defaults BEFORE validation.\n\n pydantic-settings hands this validator the fully-merged input from ALL explicit sources\n (init kwargs + LOOPLAB_* env + .env), with fields the user left unset simply ABSENT (they\n fall to field defaults afterward). So a profile value is applied only when its key is not\n already present — i.e. any explicit file/CLI/env setting wins over the profile, and the\n profile wins over the bare field default. This makes `thorough` a true convenience preset,\n never an override of what the user asked for.\"\"\"\n if not isinstance(data, dict):\n return data\n prof = data.get(\"profile\", \"default\")\n # env may deliver the key as \"profile\" (env_prefix stripped) — normalize to a str\n prof = str(prof).strip().lower() if prof is not None else \"default\"\n overrides = PROFILES.get(prof)\n if overrides is None:\n raise ValueError(f\"unknown profile {prof!r}; known: {sorted(PROFILES)}\")\n for key, val in overrides.items():\n if key not in data: # explicit source (file/CLI/env) always wins\n data[key] = val\n return data\n\n @model_validator(mode=\"after\")\n def _check_trust_gate(self):\n if self.trust_gate not in (\"audit\", \"gate\", \"block\"):\n raise ValueError(\n f\"trust_gate must be audit|gate|block, got {self.trust_gate!r}\")\n if self.merge_mode not in (\"auto\", \"mean\", \"ensemble\"):\n raise ValueError(\n f\"merge_mode must be auto|mean|ensemble, got {self.merge_mode!r}\")\n # novelty_mode drives the dedup/novelty gate; an out-of-set value (e.g. a mis-cased\n # LOOPLAB_NOVELTY_MODE=LLM, or \"on\") used to fall through _apply_novelty_gate silently as a\n # NO-OP — turning the gate off with no diagnostic. Fail loudly at config time like trust_gate/\n # merge_mode do. (Other enum-ish string fields — seed_mode/eval_trust_mode/strategist_backend —\n # share the pattern; left unvalidated here only because their full value sets are less settled.)\n if self.novelty_mode not in (\"off\", \"algo\", \"llm\"):\n raise ValueError(\n f\"novelty_mode must be off|algo|llm, got {self.novelty_mode!r}\")\n # The remaining enum-ish string fields (arch-review §5 P3): a typo used to be accepted at\n # construction and only fail-safe/later-loud downstream (e.g. a mis-cased strategist_backend\n # silently ran the default). Fail loudly here like the fields above.\n # CLAUDE REVIEW: [READABILITY] the parenthetical in the novelty_mode comment above claims\n # seed_mode/eval_trust_mode/strategist_backend are \"left unvalidated here ... because their full\n # value sets are less settled\" — stale: all three ARE validated in this very block below. Update\n # that parenthetical so a load-bearing comment doesn't contradict the code 8 lines down.\n if self.strategist_backend not in (\"off\", \"rule\", \"llm\", \"agent\"):\n raise ValueError(\n f\"strategist_backend must be off|rule|llm|agent, got {self.strategist_backend!r}\")\n if self.eval_trust_mode not in (\"ratify_freeze\", \"autonomous\", \"ratify_freeze_drift\"):\n raise ValueError(\n \"eval_trust_mode must be ratify_freeze|autonomous|ratify_freeze_drift, \"\n f\"got {self.eval_trust_mode!r}\")\n if self.seed_mode not in (\"auto\", \"tracked\", \"all\"):\n raise ValueError(f\"seed_mode must be auto|tracked|all, got {self.seed_mode!r}\")\n if self.backend not in (\"toy\", \"llm\"):\n raise ValueError(f\"backend must be toy|llm, got {self.backend!r}\")\n # developer_backend is a CLOSED enum: \"default\" (in-house developer) plus the external\n # coding-agent keys in agents/cli_agent.py::PRESETS. The CLI --developer-backend flag guards it\n # (_DEV_BACKENDS), but a file/env/`--set` value reached here unchecked and adapters/tasks.py then\n # silently wired the DEFAULT developer for anything not in PRESETS. Validate against the SAME\n # authoritative registry the runtime consumer uses (imported lazily to keep config import-light),\n # so a typo fails loud instead of downgrading — mirroring the `backend` guard above.\n from looplab.agents.cli_agent import PRESETS as _DEV_PRESETS\n _dev_backends = (\"default\", *_DEV_PRESETS)\n if self.developer_backend not in _dev_backends:\n raise ValueError(\n f\"developer_backend must be {'|'.join(_dev_backends)}, \"\n f\"got {self.developer_backend!r}\")\n # `parse_structured` resolves an unknown name to the DEFAULT fallback order, so a typo'd\n # llm_parser is indistinguishable from asking for the default. Validate against the parser\n # registry itself (imported here, not at module scope, to keep config import-light).\n from looplab.core.parse import _ORDER as _PARSER_ORDER\n if self.llm_parser not in _PARSER_ORDER:\n raise ValueError(f\"llm_parser must be one of {'|'.join(sorted(_PARSER_ORDER))}, \"\n f\"got {self.llm_parser!r}\")\n self._check_llm_profiles()\n return self\n\n def _check_llm_profiles(self) -> None:\n \"\"\"Validate the connection-profile maps LOUDLY, at construction.\n\n Assignment is not validated anywhere in this project, and these two maps are the kind of\n thing a typo makes into a silent no-op: an unknown role name would simply never be read, and\n a reference to a missing profile would fall back to the shared model while the operator\n believed a second provider was in play. Both are errors here instead.\n\n The `api_key_env` rules exist for a specific reason. It must LOOK like a secret to\n `runtime/sandbox.py::SECRET_ENV`, because that is the filter that strips the operator's keys\n out of the environment handed to generated candidate code — a key parked in a variable named\n `MY_HANDLE` would be invisible to it and ride straight into the sandbox. This pattern is\n deliberately the STRICTER of the two (the sandbox has to recognize whatever already exists;\n this names a variable the operator is about to create), and it is duplicated below rather than\n imported because layering forbids `core` from importing `runtime`.\n `tests/test_secret_env_pattern.py` pins the two together — including the invariant that every\n name accepted here is one the sandbox strips. And a literal key inside a profile is refused\n outright: the profile map is written verbatim into config.snapshot.json and served over HTTP\n precisely because it is supposed to contain no secrets.\"\"\"\n from looplab.core.llm import LLM_ROLE_KEYS\n if not isinstance(self.llm_profiles, dict):\n raise ValueError(\"llm_profiles must be a map of profile name -> profile\")\n for name, entry in self.llm_profiles.items():\n if not isinstance(entry, dict):\n raise ValueError(f\"llm_profiles[{name!r}] must be an object\")\n # BEFORE the unknown-field check: none of these is a known field, so checking them\n # afterwards could never fire and someone who pasted a live key was told it was a typo.\n for banned in (\"api_key\", \"key\", \"token\", \"secret\"):\n if banned in entry:\n raise ValueError(\n f\"llm_profiles[{name!r}] must not contain {banned!r}: a profile stores \"\n \"api_key_env (the NAME of an environment variable), never a key value — \"\n \"the whole map is written to config.snapshot.json and served over HTTP\")\n unknown = set(entry) - _PROFILE_FIELDS\n if unknown:\n raise ValueError(\n f\"llm_profiles[{name!r}] has unknown field(s) {sorted(unknown)}; \"\n f\"known: {sorted(_PROFILE_FIELDS)}\")\n env = entry.get(\"api_key_env\")\n if env is not None and not _SECRET_ENV_NAME.match(str(env)):\n raise ValueError(\n f\"llm_profiles[{name!r}].api_key_env={env!r} is not a usable variable name: it \"\n \"must be UPPER_SNAKE and contain KEY/SECRET/TOKEN/PASSWORD/PASSWD/CREDENTIAL, so \"\n \"the sandbox's secret-name filter strips it from generated code's environment\")\n temp = entry.get(\"temperature\")\n if temp is not None and (isinstance(temp, bool) or not isinstance(temp, (int, float))):\n raise ValueError(f\"llm_profiles[{name!r}].temperature must be a number\")\n if not isinstance(self.role_profiles, dict):\n raise ValueError(\"role_profiles must be a map of role name -> profile name\")\n for role, profile in self.role_profiles.items():\n if role not in LLM_ROLE_KEYS:\n raise ValueError(\n f\"role_profiles has unknown role {role!r}; known: {sorted(LLM_ROLE_KEYS)}\")\n if profile not in self.llm_profiles:\n raise ValueError(\n f\"role_profiles[{role!r}] names profile {profile!r}, which is not in llm_profiles\")\n if self.llm_profile is not None and self.llm_profile not in self.llm_profiles:\n raise ValueError(\n f\"llm_profile={self.llm_profile!r} is not in llm_profiles\")\n\n def masked_snapshot(self) -> dict:\n # Snapshots are a JSON contract (they are written verbatim to config.snapshot.json), not a\n # Python-object dump. In particular tuple-valued schema fields such as\n # ``inline_repair_reasons`` must become JSON arrays before callers hash the runtime envelope;\n # otherwise the strict speculation-scope canonicalizer sees a different/non-JSON preimage\n # from the one that ``json.dumps`` eventually persists.\n d = self.model_dump(mode=\"json\")\n if self.llm_api_key is not None:\n d[\"llm_api_key\"] = \"***\"\n else:\n d[\"llm_api_key\"] = None\n # The snapshot's own FORMAT version — not a Settings field (it is never settable by env or\n # file, and it describes the document, not the run). `settings_from_snapshot` fails closed on\n # a version it does not understand; see CONFIG_SNAPSHOT_SCHEMA.\n d[CONFIG_SNAPSHOT_SCHEMA_KEY] = CONFIG_SNAPSHOT_SCHEMA\n return d\n\n\n# The config snapshot's document-format version, written by `masked_snapshot` and enforced by\n# `settings_from_snapshot`. Bump it when a snapshot written by this binary would be MISREAD by an\n# older one — i.e. when a new field changes paid, concurrency or selection behaviour and silently\n# defaulting it would resume the same event history under different semantics. `Settings` is\n# `extra=\"ignore\"`, so without this an older binary drops what it does not recognize and resumes\n# anyway, with no diagnostic; a version it does not know is now a loud refusal instead.\n# Version 1 is the first VERSIONED format. An absent key means a pre-versioning snapshot, which stays\n# readable through `LEGACY_CONFIG_SNAPSHOT_DEFAULTS` — that historical contract is unchanged.\nCONFIG_SNAPSHOT_SCHEMA = 1\nCONFIG_SNAPSHOT_SCHEMA_KEY = \"config_snapshot_schema\"\n\n\nclass ConfigSnapshotVersionError(ValueError):\n \"\"\"A config snapshot was written by a NEWER binary than the one reading it.\"\"\"\n\n\n# Pre-versioned snapshots are full Settings dumps, so absence is a reliable per-field version marker.\n# Keep their historical effective behavior when newer product defaults become active: re-entry must not\n# silently add paid calls, interventions, concurrency or a different selection policy to an old run.\nLEGACY_CONFIG_SNAPSHOT_DEFAULTS: dict[str, object] = {\n \"parallel_build\": 1,\n \"eval_parallel\": None,\n \"llm_parallel\": None,\n \"train_monitor\": False,\n \"train_monitor_interval_s\": 600.0,\n \"train_monitor_kill\": False,\n \"train_monitor_kill_confidence\": 0.8,\n \"asha_live\": False,\n \"asha_live_kill\": False,\n \"asha_live_quantile\": 0.5,\n \"asha_live_min_siblings\": 3,\n # Before this operator ceiling existed, the sandbox's 24-hour process limit was authoritative.\n \"max_eval_timeout\": 24 * 3600.0,\n \"watchdog_reflection\": False,\n \"card_driven_selection\": False,\n \"speculation_depth\": 0,\n \"speculation_gate_receipt\": None,\n \"concurrent_research_repeat\": False,\n \"concurrent_research_interval_s\": 1800.0,\n # Zero means unlimited; 40 was the first bounded default, and is latent while repeat is disabled.\n \"concurrent_research_max_calls\": 40,\n \"concurrent_consolidate\": False,\n # The map originally covered only the flags added in the days just before it was written\n # (the entries above are all 2026-07-19..07-21; the map itself landed 07-22). Everything below was\n # added 2026-06-24..07-17 — AFTER `config.snapshot.json` existed (it ships in the initial commit,\n # 2026-06-23) and BEFORE this map did — so a snapshot from that window carries none of these keys\n # and, without an entry here, resumed with today's paid product default ACTIVE. That is precisely\n # the \"silently add paid calls ... to an old run\" the paragraph above forbids: the resumed half of\n # the run would make predict-before-execute and cross-run LLM calls the original never made, and\n # admit candidates under a different novelty policy.\n # `setdefault` makes every one of these a no-op for a snapshot that DOES carry the key, so only\n # genuinely older runs are affected. Each value is the behaviour before the field existed: absent\n # feature = off, absent cadence = 0 (the same \"conservative library\" value the frozen\n # Settings-vs-EngineOptions divergence table records for these fields). Each was dated against the\n # tree before being listed — e.g. `report_every` (2026-06-25) is 0 because the run-report writer\n # itself did not exist before that commit, so an older run wrote no report to preserve.\n \"foresight\": False,\n \"foresight_agentic\": False,\n \"foresight_verify\": False,\n \"concept_pivot\": False,\n \"graded_novelty\": False,\n \"cross_run_concepts\": False,\n \"cross_run_advisory\": False,\n \"cross_run_structured_claims\": False,\n \"cross_run_curation\": False,\n \"cross_run_read_tools\": False,\n \"concept_run_base\": False,\n \"fingerprint_universal\": False,\n \"memora_llm\": False,\n \"comparative_lessons\": False,\n \"lessons_every\": 0,\n \"lessons_refresh_every\": 0,\n \"unified_agent\": False,\n \"deep_research_every\": 0,\n \"report_every\": 0,\n \"failure_reflection\": False,\n \"reflection_priors\": False,\n \"agent_drives_actions\": False,\n \"concurrent_research\": False,\n \"deep_repair\": False,\n # CLAUDE REVIEW: [QUALITY] possible gap — `merge_mode` is absent. options.py's docstring names it as a\n # product-vs-library divergence in the SAME breath as report_every (\"merge_mode='auto', report_every=3\"),\n # and report_every IS in this map. Settings defaults \"auto\", which resolves to \"ensemble\" for a\n # code-generating developer (orchestrator.__init__ L715) — a PAID code-recombination merge producing a\n # DIFFERENT merged solution than the frozen legacy default \"mean\" (EngineOptions/orchestrator._DEFAULTS).\n # So a pre-field snapshot resumed on an LLM backend silently gains ensemble merges it never did — the\n # exact \"add paid calls / a different selection policy to an old run\" this map guards against, with a\n # pointable before-value (\"mean\"). Add `\"merge_mode\": \"mean\"` here IF merge_mode postdates 2026-06-23\n # (unverifiable in this squashed history — confirm against the real commit before acting).\n # WHAT THIS MAP IS NOT. It is a hand-maintained list of FEATURE switches whose before-the-field\n # value is unambiguous, not a complete partition of `Settings`. Two classes stay out on purpose,\n # because for them a wrong entry is worse than a missing one — it would silently REMOVE behaviour\n # the original run really had:\n # * knobs that are latent while their parent feature is off — `foresight_panel`,\n # `foresight_verify_samples`, `memora_anchors`, `holdout_top_k`, `asha_eta`: the parent above\n # already pins them out of play, and their own before-the-field value is not recoverable;\n # * magnitudes and depths for behaviour that ALREADY existed — `debug_depth`,\n # `context_budget_chars`, `developer_plan_max_steps`, `agent_emit_after`: guessing 0/off there\n # disables working machinery rather than preserving history.\n # A new field belongs here only when it (a) postdates 2026-06-23, (b) defaults to adding paid\n # calls / interventions / concurrency / a different selection policy, and (c) has a historical\n # value you can point at a commit for. When (c) fails, leave it out and say so here.\n}\n\n\ndef migrate_config_snapshot(data: dict) -> dict:\n \"\"\"Return an effective copy of one legacy/current snapshot without rewriting evidence bytes.\"\"\"\n if not isinstance(data, dict):\n raise TypeError(\"config snapshot must be a JSON object\")\n migrated = dict(data)\n for key, value in LEGACY_CONFIG_SNAPSHOT_DEFAULTS.items():\n migrated.setdefault(key, value)\n return migrated\n\n\ndef config_snapshot_schema(data: dict) -> int:\n \"\"\"The snapshot's declared format version. 0 = pre-versioning (the key did not exist yet).\n\n A malformed value is treated as UNREADABLE rather than as 0: a snapshot whose own version marker\n is corrupt cannot be assumed to be the oldest format.\"\"\"\n raw = data.get(CONFIG_SNAPSHOT_SCHEMA_KEY, 0) if isinstance(data, dict) else 0\n if isinstance(raw, bool) or not isinstance(raw, int) or raw < 0:\n raise ConfigSnapshotVersionError(\n f\"config snapshot has a malformed {CONFIG_SNAPSHOT_SCHEMA_KEY}: {raw!r}\")\n return raw\n\n\ndef settings_from_snapshot(data: dict) -> Settings:\n \"\"\"Build re-entry Settings from a masked snapshot under its historical missing-field contract.\n\n Fails closed on a snapshot written by a NEWER binary. `Settings` is `extra=\"ignore\"`, so such a\n snapshot would otherwise load with every unrecognized control silently dropped — the run would\n resume on the same event history under different paid/concurrency/selection semantics, with\n nothing to show why. Refusing is the only honest answer: this binary cannot know what it is\n dropping. Older and pre-versioning snapshots keep loading exactly as before.\"\"\"\n found = config_snapshot_schema(data)\n if found > CONFIG_SNAPSHOT_SCHEMA:\n raise ConfigSnapshotVersionError(\n f\"this run's config.snapshot.json declares format v{found}, but this build understands \"\n f\"at most v{CONFIG_SNAPSHOT_SCHEMA}. It was written by a newer LoopLab; resuming here \"\n \"would silently drop settings this build does not know. Upgrade LoopLab to resume it.\")\n migrated = migrate_config_snapshot(data)\n migrated.pop(\"llm_api_key\", None)\n migrated.pop(CONFIG_SNAPSHOT_SCHEMA_KEY, None) # a document marker, never a Settings field\n return Settings(**migrated)\n"}, "890c84cfe81360de24433f935ccf31f25838bcc7": {"t": 1785768734.413131, "n": 119809, "h": "ce0c835356a96eb3", "c": "\"\"\"Engine config: the schema (what settings exist). (I0, ADR-11)\n\nOne pydantic-settings class declaring every engine knob, its default, and its docs. The loader —\nhow a run file / CLI / env becomes a `Settings` — is `looplab.core.appconfig`, which also owns\nthe config-precedence order. A resolved, secret-masked snapshot is written next to each run for\nreproducibility. (No real secrets in P0, but the masking discipline is in place.)\n\"\"\"\nfrom __future__ import annotations\n\nimport re\nimport types\nimport typing\nfrom pathlib import Path\n\nfrom pydantic import Field, SecretStr, field_validator, model_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n# Single source of the LLM first-byte (response-headers) default — see core/llm.py.\nfrom looplab.core.llm import DEFAULT_HEADER_TIMEOUT_S\n\n# Default home for cross-run memory + the knowledge base (shared across every run). Both are ON by\n# default now (a user asked for it) so a fresh install accumulates + consults knowledge with no setup;\n# set the env var to \"\" to disable, or to a path to relocate. `~/.looplab/` keeps them out of any one\n# project + shared across projects.\n_LL_HOME = Path.home() / \".looplab\"\n\n# What one `llm_profiles` entry may contain. Deliberately closed: an unrecognized field is a typo the\n# operator would otherwise never see fail, and the map is a public artifact (config.snapshot.json,\n# HTTP, LOOPLAB_*), so it must never accrete free-form keys.\n_PROFILE_FIELDS = frozenset({\"model\", \"base_url\", \"temperature\", \"api_key_env\"})\n# A variable name that `runtime/sandbox.py::SECRET_ENV` will recognize as holding a secret and strip\n# from generated code's environment. Duplicated here, not imported: layering forbids `core` from\n# importing `runtime`. `tests/test_secret_env_pattern.py` holds the two in agreement.\n_SECRET_ENV_NAME = re.compile(\n r\"\\A(?=[A-Z][A-Z0-9_]{0,63}\\Z)(?=.*(KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL)).*\\Z\")\n\n\ndef _is_numeric_annotation(ann) -> bool:\n \"\"\"True when a field's annotation admits int/float but NOT bool.\n\n Used by `Settings._reject_bool_for_numeric_fields`: pydantic resolves annotations at class build,\n so `model_fields[...].annotation` is a real type object here even under\n `from __future__ import annotations`. Optionals are numeric when every non-None member is\n (`int | None` yes, `int | str` no), so a boolean can never slip in through a union arm.\"\"\"\n if ann is bool:\n return False # a bool field legitimately takes a bool\n if ann is int or ann is float:\n return True\n args = [a for a in typing.get_args(ann) if a is not type(None)]\n origin = typing.get_origin(ann)\n if args and origin in (typing.Union, types.UnionType):\n return all(_is_numeric_annotation(a) for a in args)\n return False\n\n# Autonomous roles that may be granted per-setting write access (see Settings.agent_control).\n# strategist = A7 meta-controller (run-wide); boss = run-chat operator-proxy (run-wide);\n# researcher = per-experiment proposer (per-node sizing, e.g. eval timeout for a heavy model).\nAGENT_ROLES = (\"strategist\", \"boss\", \"researcher\")\n\n# A foresight score is computed for every acted-on proposal. Keep this lower than the generic\n# verifier's emergency ceiling so a typo cannot multiply paid synchronous calls across a run.\nMAX_FORESIGHT_VERIFY_SAMPLES = 8\n\n\n# Layer-2 parallelism renamed two public settings without removing the durable legacy fields. Keep\n# the equivalence in one place: config-source precedence, governance migration, and live strategy\n# application must all agree which names describe the same authority/value.\nPARALLELISM_ALIASES: dict[str, str] = {\n \"max_parallel\": \"eval_parallel\",\n \"parallel_build\": \"llm_parallel\",\n}\n\n\ndef parallelism_aliases(setting: str) -> tuple[str, ...]:\n \"\"\"Return every spelling in ``setting``'s alias family, canonical name first.\"\"\"\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if setting in (legacy, canonical):\n return canonical, legacy\n return (setting,)\n\n\ndef canonicalize_parallelism_source(\n values: dict, *, promote_build_to_llm_parallel: bool = False,\n) -> dict:\n \"\"\"Promote legacy parallel keys *within one config-precedence source*.\n\n Applying this only after file/CLI/env were flattened would let a lower-priority canonical env\n value shadow a higher-priority legacy file value (or a file canonical value shadow a CLI legacy\n override), so callers with sub-layers normalize each layer independently.\n\n ``max_parallel``/``eval_parallel`` are true aliases (pure eval width, no extra semantics) and are\n always promoted. ``parallel_build``/``llm_parallel`` are NOT: a positive ``llm_parallel`` also flips\n on the finite shared LLM broker (``orchestrator._startup_llm_total``), while legacy ``parallel_build``\n means \"legacy build width, unbounded overlap, no shared total\". So promoting a legacy-only\n ``parallel_build`` layer (e.g. just ``LOOPLAB_PARALLEL_BUILD=1`` in env) would silently serialize\n every provider call — contradicting llm_broker.py's docstring, orchestrator's \"legacy-only preserves\n unbounded overlap\" note, the broker-neutral live-control path, and the configuration.md ``llm_parallel``\n row (\"falls back ... without enabling a shared total\"). Config/startup loads therefore keep broker\n opt-in tied to an EXPLICITLY-spelled canonical ``llm_parallel``; the orchestrator's own width fallback\n (``llm_parallel if not None else parallel_build``) still carries the legacy build width. Only the\n Strategist live-control path (``canonicalize_strategy_parallelism``) opts into\n ``promote_build_to_llm_parallel``, where ``parallel_build`` is a deliberate full alias of the\n canonical knob (see strategy.py ``_apply_strategy``).\n \"\"\"\n if not isinstance(values, dict):\n return values\n out = dict(values)\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if canonical == \"llm_parallel\" and not promote_build_to_llm_parallel:\n continue\n # None is the durable legacy mode for llm_parallel, not a missing value. A normal snapshot\n # contains llm_parallel:null plus parallel_build:1; promoting null to 1 would unexpectedly enable\n # the global one-call broker. Promote aliases only when the canonical key is absent, and pin\n # snapshot round-trip parity. Canonicalize inside each precedence layer, never across the\n # flattened result, so the alias migration cannot invert CLI > file > env precedence.\n if canonical not in out and out.get(legacy) is not None:\n out[canonical] = out[legacy]\n return out\n\n\ndef flatten_parallelism_layers(layers) -> dict:\n \"\"\"Flatten same-tier precedence layers (LOWEST priority first) and resolve the legacy pair.\n\n PRECEDENCE REPAIR for the non-promoted `parallel_build`/`llm_parallel` pair. Declining to promote\n in `canonicalize_parallelism_source` is right (a legacy-only layer must not flip on the shared\n broker), but it let a LOWER-priority canonical value survive a HIGHER-priority legacy override:\n file `llm_parallel: 2` + CLI `-s parallel_build=8` flattened to BOTH keys, and the orchestrator\n prefers `llm_parallel`, so the operator's explicit CLI width was silently discarded. When a higher\n layer names the legacy width over a lower layer's canonical one, mask with the LEGACY SENTINEL\n (`llm_parallel=None` is the durable \"legacy mode — build width from parallel_build, no shared\n total\"), so the higher layer wins with the semantics it asked for.\n\n The mask lives HERE, not in the per-layer canonicalizer, because only a flatten knows the layer\n ORDER. Stamping it per layer meant any layer naming `parallel_build` emitted `llm_parallel=None`\n even when NO layer in the tier set the canonical key — and since pydantic-settings ranks init\n kwargs above env, that sentinel then silently overrode a `LOOPLAB_LLM_PARALLEL` the operator had\n exported as a deliberate provider rate/cost ceiling. Precedence is per FIELD: a tier that never\n spelled `llm_parallel` has not set it, and it must fall through to env/.env/defaults — which is\n exactly what `build_settings`' docstring promises (\"the merged dict wins only where it actually\n sets a value\").\n \"\"\"\n merged: dict = {}\n legacy_at = canonical_at = -1\n for index, layer in enumerate(layers):\n if not isinstance(layer, dict):\n continue\n merged.update(layer)\n if layer.get(\"parallel_build\") is not None:\n legacy_at = index\n if \"llm_parallel\" in layer:\n canonical_at = index\n # Only when this tier DID set the canonical key, at a strictly lower layer than the legacy one.\n if canonical_at >= 0 and legacy_at > canonical_at:\n merged[\"llm_parallel\"] = None\n return merged\n\n# Settings whose semantics are committed by ``run_started`` and restored from the folded event log\n# on every re-entry. Changing one only in ``config.snapshot.json`` would mix incompatible evidence\n# (a different holdout split) or make the live producer disagree with replay's selection policy.\n# The HTTP config editor and the engine both consume this one contract; keep deliberately mutable\n# controls such as ``trust_gate`` out (that field has its own durable change event).\nRUN_START_PINNED_FIELDS = frozenset({\n \"card_driven_selection\",\n \"speculation_depth\",\n \"holdout_fraction\",\n \"holdout_select\",\n \"select_verifier\",\n \"select_verifier_samples\",\n \"verifier_ci_tie\",\n})\n\n\ndef run_start_pinned_settings(state) -> dict:\n \"\"\"Return the effective Settings names committed by a folded run-start record.\n\n Pre-pin legacy logs have no recorded holdout fraction, so their snapshot remains authoritative\n for both holdout knobs. Verifier fields, however, have always been re-pinned from the legacy-safe\n fold defaults whenever a valid ``run_started`` identity exists.\n \"\"\"\n if not getattr(state, \"run_id\", \"\"):\n return {}\n values = {\n # Layer 3 changes which queue owns macro-action selection. Once a run starts, replay and\n # resume must keep the same owner even if the snapshot or ambient default changes later.\n \"card_driven_selection\": bool(getattr(state, \"card_driven_selection\", False)),\n # Layer 5 overlaps Card production with evaluation only when this pinned treatment is\n # positive. Old logs omit it and therefore retain the exact serial build/eval spine.\n \"speculation_depth\": int(getattr(state, \"speculation_depth\", 0)),\n \"select_verifier\": bool(getattr(state, \"select_verifier_tiebreak\", False)),\n \"select_verifier_samples\": int(getattr(state, \"select_verifier_samples\", 3)),\n \"verifier_ci_tie\": bool(getattr(state, \"verifier_ci_tie\", False)),\n }\n holdout_fraction = getattr(state, \"holdout_fraction\", None)\n if holdout_fraction is not None:\n values.update({\n \"holdout_fraction\": holdout_fraction,\n \"holdout_select\": bool(getattr(state, \"holdout_select\", False)),\n })\n if not values.keys() <= RUN_START_PINNED_FIELDS:\n raise RuntimeError(\"folded run-start pinned settings contract drifted\")\n return values\n\n# --- Run profiles (config-first presets) -------------------------------------------------------\n# A `profile` is a NAMED BUNDLE of setting defaults — nothing more. It only fills fields the user\n# did NOT set explicitly (via file / --set / LOOPLAB_* env / init-kwargs), so any explicit knob\n# always wins (see `_apply_profile`). Most engine intelligence lives behind individual flags. The\n# PART IV/V cross-run + concept-graph machinery (concept_pivot, graded_novelty, cross_run_concepts /\n# _advisory / _structured_claims / _curation / _read_tools, fingerprint_universal, foresight_verify)\n# now ships ON in the product `Settings` default. That is an explicit experimental product choice,\n# not a claim that a frozen A/B or workload-wide cost/quality gate has cleared: the bundle can steer\n# prompts and candidate generation, change graded-novelty admission / pre-execution choices, and add\n# paid LLM work when its client and memory prerequisites are present. The bare-library `EngineOptions`\n# default stays lean (see tests/test_options_divergence.py — product side is the aggressive one) so a\n# toy `Engine(...)` in a test doesn't fire cross-run LLM work unasked. The trust/confirm/ablate QUALITY\n# bundle still ships OFF and is turned on by `thorough`. Every value here is reachable by hand — the\n# profile is a convenience, never a hidden mode.\n# keep this rationale behavioral and evidence-based; do not relabel read-only storage\n# access or proposal-only governance as a no-op for agent behavior, cost, or proposal admission.\n#\n# `thorough` deliberately touches only QUALITY/TRUST machinery, not spend: it does NOT raise\n# max_nodes / eval_parallel (those are cost knobs the user owns). It enables multi-seed confirmation\n# (demote seed-lucky leaders), the reward-hack / leakage / critic monitors AND flips them from\n# advisory to *gating* (`trust_gate=\"gate\"` — a\n# flagged win can no longer be selected as best), ablation-driven refinement, and the prompt cues\n# (complexity / budget / failure-reflection / watchdog-reflection). Agentic novelty and the experimental\n# Part IV/V bundle are already product defaults, not profile changes. Effect is large; marginal cost is a\n# few extra evals.\nPROFILES: dict[str, dict] = {\n \"default\": {}, # explicit no-op alias for \"ship-as-is\" (== omitting profile)\n \"fast\": {}, # today's lean defaults, named for symmetry with `thorough`\n \"thorough\": {\n \"confirm_top_k\": 3,\n \"confirm_seeds\": 3,\n # novelty_gate intentionally NOT enabled here: duplicate-detection is the agentic Researcher's\n # job (it reads past experiments + find_analogous_across_runs and DECIDES), not an algorithmic\n # param-distance / embedding auto-reject that mis-fired (it nudged a good fresh idea onto a\n # twice-dead lineage — live node 61). Search stays as tools; the LLM judges novelty.\n \"operator_bandit\": True, # P4: adaptive operator mix over folded yields (GreedyTree)\n \"reward_hack_detect\": True,\n \"code_leakage_detect\": True,\n \"critic_check\": True,\n \"trust_gate\": \"gate\", # detectors stop gating a flagged node from WINNING (not just audit)\n \"ablate_every\": 3,\n \"complexity_cue\": True,\n \"budget_aware\": True,\n \"failure_reflection\": True,\n \"watchdog_reflection\": True, # feed recent live-watchdog (train-monitor/ASHA) flags to proposals\n \"reflection_priors\": True, # no-op unless memory_dir is set (cross-run priors)\n },\n}\n\n\n# The shipped agent-governance matrix (who may change which setting at runtime). Defined as a module\n# constant so the Engine's direct-construction default matches this shipped default exactly (the\n# EngineOptions \"Engine() == shipped defaults\" invariant) instead of diverging to an all-locked map.\nDEFAULT_AGENT_CONTROL: dict[str, list[str]] = {\n \"timeout\": [\"researcher\", \"strategist\"],\n # Layer-2 canonical names are the presented/default governance surface. `_agent_may` falls back\n # to an old snapshot's legacy grant only when its canonical counterpart is absent, so removing\n # duplicate defaults preserves resume authority while an explicit canonical revocation still wins.\n \"eval_parallel\": [\"strategist\"],\n \"llm_parallel\": [\"strategist\"],\n # Strategy-only pseudo-setting: the closed per-lane allocation inside the canonical LLM total.\n # It is not a launch Settings field; operators pin it through `set_strategy` and the Strategist\n # may reallocate it only while this grant is present.\n \"llm_lane_limits\": [\"strategist\"],\n \"max_nodes\": [\"strategist\", \"boss\"],\n \"max_eval_seconds\": [\"strategist\", \"boss\"],\n \"policy\": [\"strategist\"],\n # The policy NAME and its PARAMS are gated independently in `_apply_strategy`; grant both by default\n # so the Strategist that may switch to (say) MCTS may also apply the `c`/`eta` it decided — else its\n # tuned params are silently dropped and the recorded `active_strategy` diverges from the live engine.\n \"policy_params\": [\"strategist\"],\n \"n_seeds\": [\"strategist\"],\n \"ablate_every\": [\"strategist\"],\n \"merge_mode\": [\"strategist\"],\n \"complexity_cue\": [\"strategist\"],\n \"ablate_code_blocks\": [\"strategist\"],\n \"prefer_sweep\": [\"strategist\"],\n \"novelty_stance\": [\"strategist\"],\n \"developer\": [\"strategist\"],\n \"fidelity\": [\"strategist\", \"researcher\"],\n}\n\n\ndef default_agent_control() -> dict[str, list[str]]:\n \"\"\"A FRESH deep-ish copy of the shipped governance matrix (each role list copied so a caller can\n mutate its own map without touching the module constant). The SINGLE constructor of the default,\n called by both `Settings.agent_control`'s default_factory AND the Engine's direct-construction\n default — so \"Engine() == shipped defaults\" can't drift if the copy depth ever needs to change\n (e.g. nested values requiring copy.deepcopy), instead of two verbatim copy expressions.\"\"\"\n return {k: list(v) for k, v in DEFAULT_AGENT_CONTROL.items()}\n\n\nclass Settings(BaseSettings):\n \"\"\"The engine settings schema (every knob a run accepts).\n\n Timeout family — six distinct knobs, each owned by a different subsystem:\n - `timeout`: per-eval wall-clock budget for ONE experiment's evaluation (engine/eval).\n - `max_eval_timeout`: hard ceiling for a governed per-node Researcher timeout override.\n - `llm_timeout`: LLM request idle timeout — inter-token stall limit in stream mode\n (core.llm OpenAICompatibleClient).\n - `llm_header_timeout`: LLM first-byte (response-headers) window for stream attempts (core.llm).\n - `agent_time_budget_s`: wall-clock ceiling across a tool-using agent loop's turns (agents).\n - `dep_install_timeout`: per-package budget for auto-installing a missing dep (env self-prep).\n\n Config-source precedence is owned by `looplab.core.appconfig` (the loader) — see its module\n docstring for the one canonical order.\n \"\"\"\n # Config precedence: see appconfig.py — the loader owns the full order. The `.env` is read\n # so `looplab run`/`looplab ui` pick up LOOPLAB_LLM_BASE_URL etc. without exporting them by hand\n # (keys MUST carry the LOOPLAB_ prefix, same as the env vars). utf-8-sig tolerates a BOM from\n # Windows editors. The test suite disables this (tests/conftest.py) so a dev's real .env in the\n # repo root can't leak into assertions.\n model_config = SettingsConfigDict(\n env_prefix=\"LOOPLAB_\",\n env_file=\".env\",\n env_file_encoding=\"utf-8-sig\",\n extra=\"ignore\",\n allow_inf_nan=False,\n )\n\n @classmethod\n def settings_customise_sources(\n cls, settings_cls, init_settings, env_settings, dotenv_settings,\n file_secret_settings):\n \"\"\"Preserve source precedence across canonical/legacy parallelism names.\n\n Pydantic ranks init > env > dotenv correctly, but it ranks field names rather than semantic\n alias families. Promoting a legacy value inside each source before the merge makes an old\n explicit snapshot/file value beat a lower-priority canonical env value. ``appconfig`` also\n promotes its file/typed/--set sub-layers before combining them into the init source.\n \"\"\"\n def _with_parallel_aliases(source):\n return lambda: canonicalize_parallelism_source(source())\n\n return (\n _with_parallel_aliases(init_settings),\n _with_parallel_aliases(env_settings),\n _with_parallel_aliases(dotenv_settings),\n _with_parallel_aliases(file_secret_settings),\n )\n\n # Run profile (config-first preset, see PROFILES above). \"default\"/\"fast\" = today's lean\n # defaults; \"thorough\" turns the built quality/trust machinery ON in one word. A profile only\n # fills fields the user did NOT set — every knob it touches stays individually overridable.\n profile: str = \"default\"\n\n # Bounds (review C/⚪): a non-positive node/seed budget or timeout is never valid. Upper bounds are\n # equally load-bearing — the UI /start path writes these straight into the engine's env, and an\n # unbounded `eval_parallel` (or legacy `max_parallel`; e.g. 100000 from a crafted preflight) would\n # fan out that many sandboxes → resource exhaustion. Reject at config time, not mid-run. Ceilings\n # are generous; tests top out near 50.\n # Pydantic's lax mode coerces a JSON boolean into any numeric field (`true` -> 1), so a UI/API type\n # error used to silently collapse a run budget to ONE node instead of returning 422. Rejected for\n # EVERY numeric field by `_reject_bool_for_numeric_fields` below, not just the ones bounded here.\n n_seeds: int = Field(default=3, ge=1, le=1024)\n max_nodes: int = Field(default=8, ge=1, le=1_000_000)\n # legacy evaluation-width input. `eval_parallel` below is canonical and wins when set;\n # keep this field for old env/config/snapshots and direct Engine call sites. A width > 1 admits\n # concurrent evals, whose resource reservations may be CPU-only or span one/more GPUs — there is no\n # one-node/one-GPU promise. `max_parallel=0` is launch-time AUTO (detected GPU count, at least 1).\n max_parallel: int = Field(default=1, ge=0, le=1024)\n # legacy build-width input. `llm_parallel` below is canonical and also controls the\n # shared provider-call total when positive. A width > 1 uses fresh pooled researcher/developer pairs;\n # it says nothing about the later eval's CPU/GPU reservation. Startup `0` derives build fan-out from\n # resolved eval width; a live Strategist/operator zero settles to 1. Without role_factory it clamps to 1.\n parallel_build: int = Field(default=1, ge=0, le=64)\n # Hypothesis-card Kanban re-architecture (docs/23, Layer 2). Two INDEPENDENT concurrency axes,\n # decoupled from each other so cost (LLM work) can be tuned separately from throughput (GPU evals).\n # These are the CANONICAL names going forward; `max_parallel`/`parallel_build` above are kept as thin\n # back-compat aliases (env vars / snapshots / ~100 Engine(...) call sites depend on the exact names,\n # so they cannot be renamed — CLAUDE.md). Resolution (orchestrator.__init__): a NEW name, when set,\n # WINS over its legacy alias; None => fall back to the legacy field => byte-identical to today.\n # `eval_parallel`: concurrent EVALS (the GPU/experiment consumer). None -> max_parallel; 0 = AUTO\n # (one experiment per detected GPU). This is the in-Run eval-concurrency ceiling; the resource\n # scheduler bin-packs declared footprints separately. Sibling local GPU-owning Runs serialize\n # through the conservative host-pool lease; external execution needs its own admission boundary.\n # `llm_parallel`: total concurrent LLM provider calls + BUILD fan-out. None -> legacy\n # parallel_build semantics without a shared total; 0 = startup AUTO (= resolved eval_parallel\n # for build fan-out, historical research overlap unbounded). A positive canonical value enables\n # the named-lane broker the Strategist can reallocate at cadence.\n eval_parallel: int | None = Field(default=None, ge=0, le=1024)\n llm_parallel: int | None = Field(default=None, ge=0, le=64)\n # Training-log monitor (I-series watchdog family): a per-eval background observer that tails the live\n # training log while a (often multi-hour) declared stage runs in a worker thread. ON in the product\n # surface (Settings). Its alert event is fold-ignored and cannot directly change lifecycle/champion;\n # with watchdog_reflection on, that alert is nevertheless advice in a later Researcher\n # prompt and can change future proposals. It only fires on the command-eval path with an LLM client;\n # bare-library EngineOptions stays OFF. See test_options_divergence.\n # `_interval_s` is the BASE tick cadence (the effective one adapts to the per-experiment budget).\n train_monitor: bool = True\n train_monitor_interval_s: float = Field(default=600.0, gt=0)\n # Phase 3 — INTERVENTION (opt-in, separate from observation): let the monitor tree-kill a training the\n # LLM judges 'broken' (diverged / silent CPU fallback / not learning) EARLY, instead of burning the\n # whole budget. Off by default — the observer only WATCHES unless this is on. A kill only fires on a\n # 'broken' verdict (a plateau is 'watch', never killed) with confidence >= the threshold; the node then\n # fails normally (reason='monitor_broken'), so replay reconstructs it from that one terminal event.\n train_monitor_kill: bool = False\n train_monitor_kill_confidence: float = Field(default=0.8, ge=0.0, le=1.0)\n # ASHA live-curve watchdog (sibling of the training monitor): reads the latest INTERMEDIATE value of\n # the objective metric off the live log (reusing the eval's OWN metric reader). Finished-endpoint rank\n # remains advisory. An opt-in KILL additionally requires an operator-declared metric.resource_key and\n # enough sibling observations at exactly that resource; absent comparable evidence cannot stop a run.\n # Records a fold-ignored `asha_rank` diagnostic + trace span; the library default is OFF (off == today).\n asha_live: bool = True\n # Opt-in INTERVENTION (separate from the advisory signal): let the watchdog tree-kill a node whose\n # intermediate metric stays below the SAME-RESOURCE bar past a short grace window. Off by default —\n # it only surfaces endpoint rank unless this is on. A kill fails normally (asha_underperforming).\n asha_live_kill: bool = False\n # The bar sits at `asha_live_quantile` along a WORST->BEST ordering of the finished siblings' finals:\n # 0.5 = the median (a node worse than the median peer flags); a SMALLER value is more conservative —\n # it lowers the bar toward the WORST finished peer, so only a node worse than nearly all peers flags\n # (0.0 = worse than the worst). `min_siblings` is the floor of finished peers required before it ranks\n # at all, so it never acts on too little evidence.\n asha_live_quantile: float = Field(default=0.5, ge=0.0, le=1.0)\n asha_live_min_siblings: int = Field(default=3, ge=1)\n timeout: float = Field(default=30.0, gt=0)\n # Hard ceiling for the Researcher's governed per-node `Idea.eval_timeout`. One hour preserves the\n # existing heavy/neural-net authoring example while staying well below the sandbox's defensive\n # 24-hour subprocess ceiling. This is operator-owned run config: agents may request less or more,\n # but the accepted action is always clamped here after the `agent_control.timeout` permission gate.\n max_eval_timeout: float = Field(default=3600.0, gt=0, le=24 * 3600.0)\n # Intra-node sweep: a sweep node runs a whole grid in one process, so it gets this multiple of\n # `timeout` as its wall-clock budget (solution.py path; RepoTasks use their per-profile timeout).\n sweep_timeout_mult: float = Field(default=8.0, ge=1)\n # Eval STALL watchdog CAP (seconds): a stage that is completely SILENT on stdout/stderr for this\n # long — while still alive and BELOW its wall-clock deadline — is tree-killed early with a STALLED\n # marker (a hung dataloader/deadlock dies in minutes instead of burning a multi-hour timeout). The\n # per-stage window is `min(this, the stage's own timeout)`, so a short scorer's deadline fires first.\n # `0` DISABLES the watchdog entirely (only the hard deadline applies) — for a legitimately quiet\n # non-Python stage (block-buffered stdout, a script that logs only to its own file). Threaded into\n # the eval and surfaced to the Developer so its code can emit periodic progress to stay alive.\n eval_stall_timeout_s: float = Field(default=1800.0, ge=0)\n # Sandbox tier (ADR-13): \"trusted_local\" (subprocess, no Docker) for the CLI;\n # \"untrusted\" (Docker --network none, shared-kernel runtime) for hosted/multi-tenant UI;\n # \"hostile\" (untrusted + a true-isolation OCI runtime, gVisor `runsc` by default / Kata) for\n # running untrusted code. See runtime/sandbox.py::make_sandbox for the tier selection.\n trust_mode: str = \"trusted_local\"\n # Image for the untrusted command-eval tier (RepoTask, Phase 4): the framework's deps\n # should be baked in (the container runs --network none, so a pip setup can't fetch).\n docker_image: str = \"python:3.12-slim\"\n # Resource caps for the untrusted/hostile Docker tier (passed to `docker run --memory/--cpus`).\n # The default 4g bound protects other tenants from an OOM, but a model-training MLE-bench eval\n # routinely needs more — raise it (e.g. \"16g\") for such runs. \"\" disables the cap (unbounded).\n # Ignored by the trusted_local (subprocess) tier.\n sandbox_memory: str = \"4g\"\n sandbox_cpus: str = \"\"\n # Best-effort host-OOM guard for the TRUSTED-LOCAL (subprocess) tier. When set (e.g. \"8g\"), each\n # eval child gets an RLIMIT_AS address-space cap so a runaway trainer hits MemoryError instead of\n # OOM-killing the whole host (and the engine). POSIX only; a no-op on Windows. Default \"\" = OFF:\n # RLIMIT_AS bounds VIRTUAL memory, and CUDA/torch reserve tens of GB of virtual without using it,\n # so a default cap would spuriously kill GPU evals — leave it off for those and use the Docker\n # tier's real --memory cgroup bound (sandbox_memory) instead. Ideal for CPU/numpy/sklearn runs.\n sandbox_memory_local: str = \"\"\n # Best-effort disk-fill guard for the TRUSTED-LOCAL tier (P1-5): an RLIMIT_FSIZE cap on the size of\n # any single file an eval child writes (e.g. \"2g\"), so a runaway that writes an unbounded file gets\n # SIGXFSZ instead of filling the host disk. POSIX only. Default \"\" = OFF (large model checkpoints\n # need big files); set it for tasks that write only small artifacts.\n sandbox_fsize_local: str = \"\"\n # Search policy (ADR-2): \"greedy\" | \"evolutionary\" | \"mcts\" | \"asha\".\n # CLAUDE REVIEW: [DOCS-MISMATCH] this enum list omits \"bohb\", which IS a registered policy\n # (search/policy.py::_REGISTRY aliases it to the ASHA factory) and IS advertised by\n # appconfig.render_template (\"greedy | evolutionary | mcts | asha | bohb\") and the `bohb` template\n # kind. The schema comment (the documented value set) undersells the valid values. (policy itself is\n # unvalidated at config time, but make_policy raises on an unknown name, so a typo fails loud-late\n # rather than silently — unlike developer_backend above.)\n policy: str = \"greedy\"\n # Ablation-driven refinement (I7): every N improves, ablate the best to find the\n # highest-impact parameter and refine it. 0 = off. (GreedyTree only.)\n ablate_every: int = 0\n # A0a (MLE-STAR): when ablating, treat each generated pipeline *code block* (not just a\n # numeric param) as an ablation unit — neutralize a block, measure the metric delta, refine\n # the highest-impact block. Off => the classic numeric-param ablation. Config-first knob.\n ablate_code_blocks: bool = False\n # A0b: real merge/ensembling. \"mean\" = legacy mean-param merge; \"ensemble\" = the Developer\n # writes a code-recombination ensemble over the two parents' solutions (verified: agent-proposed\n # ensembling 37.9%->43.9%). \"auto\" (default) = ensemble whenever the Developer actually\n # generates code (LLM/agent backends; declared via `is_code_generating`), mean for the\n # templated/toy developers where a code ensemble is meaningless — the evidence says code\n # recombination is the strongest single merge (removing it costs ~9 pp), so it is the default\n # wherever it can work. Falls back to mean when the Developer can't ensemble.\n merge_mode: str = \"auto\"\n # A0d (AIRA): inject a dynamic complexity hint into the draft/improve prompt keyed on the\n # node's child count (few children -> keep minimal; many -> escalate to ensembling/HPO).\n complexity_cue: bool = False\n # I1 (CAAFE-style) LLM feature-engineering: instruct the proposer/developer to add\n # semantically-meaningful engineered features (ratios, interactions, aggregations) as code. The\n # existing cross-validation eval + best-selection is the CV GATE — a feature set that doesn't\n # improve CV is never selected (feature engineering is non-universal, so this gate is mandatory).\n # Tabular tasks (those exposing column data). Off by default.\n feature_engineering: bool = False\n # A5: surface the REMAINING eval-compute budget into the proposal prompt so the Researcher can\n # reason over how much compute is left (explore broad while flush, exploit/cheapen when low).\n # No-op unless a max_eval_seconds budget is set.\n budget_aware: bool = False\n # A4 (LATS-style): feed a summary of the most recent FAILED branches (operator + error reason)\n # back into the proposal prompt so the proposer reflects on and avoids repeating them. ON by\n # default: it is SELECTIVE by construction (injects only when recent failures exist — the\n # evidenced form of reflection; reflecting at every step shows no gains, arXiv:2506.12928).\n failure_reflection: bool = True\n # Feed the LIVE-WATCHDOG observations (train-monitor health verdicts + ASHA intermediate-rank flags)\n # of recent experiments back into the proposal prompt, so the Researcher avoids re-proposing a config\n # whose TRAINING was already observed to be weak — even when the watchdog kills are off (default) and\n # the node ran to completion (those diagnostics are fold-ignored, so failure_reflection never sees\n # them). ON by default and SELECTIVE like failure_reflection (injects only when a recent flag exists).\n watchdog_reflection: bool = True\n # C3 deep test-driven repair: hand the Developer the failure taxonomy + a structured \"reproduce\n # then fix\" directive on debug, not just the raw stderr tail. ON by default (product surface); the\n # conservative library default in EngineOptions stays off, per that module's contract.\n deep_repair: bool = True\n # === Inline crash repair ==============================================================\n # Hybrid in-node crash repair: when an LLM-generated node CRASHES at runtime (mechanical errors\n # — bad import, removed kwarg, typo), the agent triages it and may repair the code IN PLACE within\n # the same eval (cheap; does NOT consume max_nodes and does NOT add a node to the search tree).\n # Semantic failures still flow to a new idea/debug node via the policy. ON by default; set False\n # to restore the prior behavior (every crash waits for a budgeted inter-node debug node).\n inline_repair: bool = True\n # Max in-place repair attempts per node before it fails normally. Default 0 = UNLIMITED: the same\n # agent keeps fixing + re-evaluating IN THE SAME node until it produces a clean metric — no new\n # node just to retry. Runaway is prevented not by a count but by the anti-stuck guard\n # (`inline_repair_stuck_repeat`): when the SAME error signature repeats with no progress, or the\n # agent's crash-triage says \"abandon\", the node fails. Set a positive N to cap the retries instead.\n inline_repair_attempts: int = Field(default=0, ge=0)\n # Anti-stuck: abandon in-node repair when the SAME error recurs this many times in a row (no\n # progress). Keeps \"unlimited\" repair from looping forever on an unfixable error.\n inline_repair_stuck_repeat: int = Field(default=4, ge=2)\n # Which failure reasons (_failure_reason: crash|timeout|oom|setup|no_metric|drift) are eligible for\n # inline repair. Default: mechanical crashes, timeouts AND OOM-kills — a timeout/OOM means the code\n # was too slow / too memory-hungry for the budget (or the pod's cgroup limit), not that the idea is\n # wrong, so the agent repairs it by reducing compute/memory (fewer estimators/epochs/folds/seeds, a\n # lighter model, a smaller batch, subsampling) instead of letting the node die unrecovered. Env\n # override expects a JSON array.\n inline_repair_reasons: tuple[str, ...] = (\"crash\", \"timeout\", \"oom\")\n # Cost ceiling for in-node repair of a MULTI-STAGE eval: the max number of FULL pipeline re-runs\n # (i.e. re-trains from the first stage) the inline-repair loop may do before abandoning to the\n # inter-node debug operator. A late-stage failure (a broken `score`/eval script that didn't touch\n # `train`) REUSES the completed train checkpoint and re-runs only from the failed stage — that is\n # CHEAP and NOT counted here; only a repair that changes an EARLIER stage's code (train.py/loss.py/…)\n # forces a full re-train, which each costs the whole training time. Without this bound, a repair that\n # keeps changing training code could burn many full trains (the anti-stuck guard is error-signature\n # based, not cost based). 0 = unlimited (legacy behavior). Single-command evals ignore it.\n inline_repair_retrain_cap: int = Field(default=2, ge=0)\n # Environment self-prep: when a solution crashes purely because a KNOWN library isn't installed\n # (ModuleNotFoundError), the engine pip-installs it into the eval interpreter and re-runs — so a\n # torch/XGBoost/CatBoost idea (e.g. a GRU model) actually runs on a fresh box instead of being\n # thrown away as `idea_rejected`. Trusted_local tier only (the untrusted/hostile Docker tiers run\n # --network none). A typo'd or local helper module is NOT installed (that's a code bug to repair).\n auto_install_deps: bool = True\n # Per-package wall-clock budget for an auto-install (seconds). Generous: large wheels like torch\n # can take minutes to download/build.\n dep_install_timeout: float = Field(default=900.0, gt=0)\n # --- Agent governance: who may change a setting at runtime (per-setting allow-list of roles) ---\n # Roles: \"strategist\" (A7 meta-controller; run-wide), \"boss\" (run-chat operator-proxy; run-wide),\n # \"researcher\" (per-experiment proposer; PER-NODE — e.g. set a longer eval timeout for a neural-net\n # idea). A setting ABSENT from this map is LOCKED — no agent may change it, only a human via the\n # snapshot/UI. The sole conditional exception is Strategy-only `card_scoring`: Card mode grants\n # it implicitly without changing the default flag-off snapshot, while an explicit [] revokes it.\n # The gate is ENFORCED at every AGENT seam via `_agent_may` — the strategist's whole\n # control surface (policy/operators/novelty_stance/prefer_sweep/developer/fidelity/timeout/\n # eval_parallel/llm_parallel) is checked in `_apply_strategy`, so removing a role from a knob locks it,\n # not just greys out the UI pill. The boss/max_* grants document boss-relevant knobs, but a\n # `budget_extend` is a HUMAN control intent (the boss action-builder can only emit `add_nodes`), so\n # it is applied as-is — the human always wins via the UI/snapshot. Defaults are conservative: resource/\n # search-shape knobs the agents already reason about; provider infra (llm_model/base_url/api_key,\n # docker_image) stays locked. The UI shows S/B/R pills per setting. `timeout` granted to the\n # researcher IS the \"auto\" per-node mode — the researcher sizes each experiment's wall-clock\n # (Idea.eval_timeout); the config `timeout` is the fallback for nodes it doesn't size. Env override\n # expects a JSON object. (`fidelity`/`novelty_stance`/`prefer_sweep`/`developer` are governance keys\n # for the strategist's per-node dials — not 1:1 Settings fields, but gated the same way.) The default\n # lives in the module-level DEFAULT_AGENT_CONTROL so the Engine's direct-construction default matches.\n agent_control: dict[str, list[str]] = Field(default_factory=default_agent_control)\n # C1 (Agentless localization): for repo tasks, rank the source files most relevant to the most\n # recent failure (traceback + identifiers) and surface them in the proposal/repair prompt so the\n # Developer edits the right place. Off by default; repo tasks only.\n localize_faults: bool = False\n # A2 surrogate-guided proposal (BO-lite): fit a k-NN surrogate over (params->metric) and propose\n # by acquisition instead of random/hill-climb. Numeric-bounds tasks only; bootstraps via the\n # base Researcher. Off by default. `surrogate_explore` = UCB-style exploration weight.\n surrogate_proposer: bool = False\n surrogate_explore: float = 0.1\n # E2 researcher panel: generate K candidate ideas per proposal and keep the one ranked best by a\n # cheap empirical surrogate over the (params->metric) history (NOT an LLM-judge). 1 = off.\n researcher_panel: int = 1\n # A1 ASHA / successive-halving: reduction factor (keep top 1/eta per rung) and rung budget.\n asha_eta: int = Field(default=3, ge=2)\n # Rung-0 width for ASHA/BOHB (the wide base of cheap drafts). 0 = use n_seeds (default,\n # preserving prior behavior); set >0 to seed a wider/narrower base independent of n_seeds.\n asha_rung_nodes: int = Field(default=0, ge=0)\n # A6 proxy/predictive scoring: cheaply rank a candidate's potential from early-stage signals\n # and skip a full eval for the doomed bottom fraction. 0.0 = off (no candidate skipped).\n proxy_scoring: bool = False\n proxy_kill_fraction: float = Field(default=0.0, ge=0.0, le=0.9)\n # === Novelty / dedup ==================================================================\n # E1 novelty/dedup gate: reject near-duplicate proposals (normalized param-space distance below\n # `novelty_epsilon`) by deterministically nudging them off the duplicate — stops the search\n # wasting evals re-trying the same idea. Audit event `novelty_rejected`. Off by default.\n # Novelty/dedup MODE selector: how a fresh proposal is checked against already-tried experiments.\n # \"off\" — no explicit gate; the agentic Researcher's own read-the-history judgment is trusted.\n # \"algo\" — the deterministic gate: numeric param-distance (`novelty_epsilon`) + optional embedding\n # similarity (`novelty_semantic`). Cheap, no LLM call, but can't explain itself.\n # \"llm\" — an LLM adjudicates (reads the real experiments via tools) whether the idea is a\n # near-duplicate and, if so, asks the Researcher once more for something different. One\n # extra LLM call per proposal — highest quality, follows the \"let the LLM decide\" line.\n novelty_mode: str = \"llm\"\n # Legacy sub-toggles, honored by the \"algo\" mode (and back-compat: novelty_gate=True forces \"algo\"):\n novelty_gate: bool = False\n novelty_epsilon: float = 0.05\n # T5 semantic novelty (active whenever the deterministic gate runs — novelty_mode=algo,\n # novelty_gate=true which is the legacy alias for algo, OR the Strategist novelty stance is\n # \"explore\"; a no-op only under novelty_mode=llm/off with a non-explore stance): reject a proposal\n # whose idea TEXT embeds within\n # `novelty_semantic_threshold` cosine of an existing node's. OFF BY DEFAULT: novelty is the agentic\n # Researcher's call, made by READING the actual prior experiments (read_experiment /\n # find_analogous_across_runs / list_experiments), NOT an embedding-similarity auto-reject that can't\n # explain itself and mis-fires on paraphrases. Semantic/param search stays available as TOOLS in the\n # Researcher's context; the LLM decides whether an idea is a true duplicate.\n novelty_semantic: bool = False\n novelty_semantic_threshold: float = Field(default=0.92, ge=0.5, le=1.0)\n # T10: debug-lineage depth bound for every policy — how many error-feedback repairs a failing\n # lineage gets before it is abandoned. The old default (1) abandoned lineages after ONE repair;\n # multi-turn/deeper debugging is a verified lever (AIRA2 ReAct debug +5.5 percentile points).\n debug_depth: int = Field(default=2, ge=1)\n # P4 operator bandit (GreedyTree): replace the fixed merge/ablate cadences with a\n # deterministic UCB over per-operator yield (Δmetric per eval-second, folded from the run).\n # Off by default (the cadences are well-tested and the bandit has no direct published\n # ablation); `thorough` turns it on.\n operator_bandit: bool = False\n # P1 hypothesis ledger: ask the Researcher to state the one-line hypothesis each experiment tests,\n # register deep-research directions as hypotheses, and track them to a verdict on the board. ON by\n # default. The board is shown to the Researcher and can be foresight-ordered, so it can\n # steer later proposals even though it never directly re-ranks already-evaluated nodes. Set False\n # to drop the prompt nudge + registration.\n track_hypotheses: bool = True\n # E4/M2/M3 reflection-memory -> priors (gradient-free cross-run meta-learning): at run end distill\n # the winner into `<memory_dir>/meta_notes.jsonl` AND structured lessons (incl. NEGATIVE results:\n # tested/abandoned hypotheses + failure themes) into `lessons.jsonl` with a task fingerprint; at run\n # start, inject exact-task notes + fingerprint-matched lessons from SIMILAR tasks into the proposal\n # prompt. ON by default — but a NO-OP until `memory_dir` is set (that's where cross-run memory lives).\n reflection_priors: bool = True\n # M6 comparative lessons (MARS \"comparative reflective memory\", doc 13 §7 item 2): distill\n # credit-assigned lessons from PAIRS of solutions — which SPECIFIC change made a child beat\n # (or regress from) its parent, and what fixed a failure — on top of the one-shot run-end\n # reflection. At most one extra LLM call per distillation (all pairs batched); offline a\n # deterministic param-diff credit stands in. Gated on reflection_priors + memory_dir like the\n # rest of cross-run memory — and since memory_dir has a default (~/.looplab/memory), this is\n # ACTIVE out of the box; clear memory_dir to disable cross-run memory wholesale.\n comparative_lessons: bool = True\n # M6 mid-run cadences (doc 13 §7 item 5 — the AgentRxiv live-share pattern): every N created\n # nodes, WRITE comparative lessons to the shared cross-run store during the run (lessons_every\n # — one batched LLM call per firing) and RE-READ the store so lessons distilled by CONCURRENT\n # runs reach this run's proposals (lessons_refresh_every; file re-read, no LLM call, skipped\n # when the store is unchanged). ON by default (every 4 nodes) and, like comparative_lessons\n # above, live out of the box because memory_dir defaults on; 0 = off — run-end write /\n # run-start read only, the pre-M6 behavior.\n lessons_every: int = Field(default=4, ge=0)\n lessons_refresh_every: int = Field(default=4, ge=0)\n # B3 output redaction: mask credentials (known key shapes + high-entropy tokens) in the\n # stdout/stderr tail before it is persisted to the event log / spans / UI — a leaked secret in a\n # print()/traceback must not land in the durable log. Off by default; recommend on for untrusted.\n redact_output: bool = False\n # B5 reward-hacking detector: a host-side monitor that flags suspicious wins (grader/answer-key\n # access, runtime writes to frozen files, suspiciously-perfect metrics) as a `reward_hack_suspected`\n # audit event in the Trust panel. Off by default. Whether a flag CHANGES selection is governed by\n # `trust_gate` below (default: audit-only, never changes selection).\n reward_hack_detect: bool = False\n # Trust enforcement (T2): what a reward-hack / data-leakage flag actually DOES to selection.\n # \"audit\" (default) = surface it in the Trust panel, never change selection (today's behavior);\n # \"gate\" = a flagged node can no longer be selected as BEST **and isn't bred/confirmed from**\n # (breed_excluded — see replay._apply_trust_gate / models.breedable_nodes), but stays\n # FEASIBLE so it still counts for diversity/audit — closes the \"a hacked/leaky node can\n # win OR seed its lineage\" hole;\n # \"block\" = additionally mark it fully INFEASIBLE (removed from feasible_nodes entirely).\n # Deliberately gates only high-precision CHEATING/LEAKAGE signals. Broad `critic:*` findings stay\n # advisory; `critic:hardcoded_metric` is the one narrow high-precision critic exception\n # and gates because it requires a literal metric with no computed assignment anywhere.\n trust_gate: str = \"audit\"\n # I3 data-centric: static code-leakage scan of each evaluated solution (fit-before-split,\n # fit-on-test) surfaced into the Trust panel alongside reward-hack flags. Off by default.\n code_leakage_detect: bool = False\n # 4.4 sandbox instrumentation (needs reward_hack_detect): after each eval, flag a RUNTIME write\n # to a protected/frozen file (an answer key or grader tampered mid-run) — behavioral evidence a\n # static code scan misses. ON by default; only fires when the reward-hack detector is on.\n workdir_audit: bool = True\n # D8 decoupled research Verifier: check every Deep-Research memo's CLAIMS against their cited\n # evidence (node ids / urls) before recording — synthesis is the documented weak link (Kosmos\n # 57.9% accurate). Deterministic layer always; an LLM rubric pass when the DeepResearcher has a\n # client. The verdict cannot change this run's node/champion, but finalize uses aligned supported\n # verdicts as the evidence gate for persisted D8 cross-run claims; unavailable/misaligned stays\n # unverified. ON by default (no-op with no memo/claims).\n research_verify: bool = True\n # C4 independent critic: an execution-free critic of each solution (stub / hardcoded-metric /\n # params-ignored; on host-graded tasks the metric checks become a submission-output check)\n # surfaced in the Trust panel. Broad findings are advisory; `critic:hardcoded_metric` can gate under\n # trust_gate=gate/block. Off by default.\n critic_check: bool = False\n # A7 Strategist (NEW, user-requested): optional LLM/rule meta-controller that picks the search\n # policy/allocator + operator mix + fidelity (+ Developer backend) per situation. Config-first:\n # every choice the Strategist makes is also a direct knob. Defaults to \"agent\" so the agent adapts\n # the search policy/operators/fidelity per situation; set \"off\" for static config-driven search.\n # \"agent\" = a tool-using Strategist that READS the run/data/sibling-runs/KB/memory (B1-guarded)\n # before deciding, instead of the single-shot \"llm\" call over aggregate stats.\n strategist_backend: str = \"agent\" # \"off\" | \"rule\" | \"llm\" | \"agent\" — default AGENTIC: the\n # Strategist READS the run/data/sibling-runs/KB/memory with tools before deciding, not a single-shot\n # call over aggregate stats. \"llm\" = the old non-agentic single-shot; \"rule\"/\"off\" = no LLM.\n strategist_every: int = Field(default=3, ge=1) # consult cadence (created nodes)\n # PART V (F1): concept CLASSIFIER re-tag + consolidation cadence, DECOUPLED from strategist_every. The\n # LLM concept map is heavier and slower-moving than a strategy consult, so it refreshes on its own\n # (sparser) interval. Researcher-authored idea.concepts still fold immediately at node_created — this\n # only paces the classifier-evidence + consolidation refresh (and the concept-coverage pivot snapshot).\n concept_retag_every: int = Field(default=30, ge=1)\n # === Confirmation & holdout ===========================================================\n # Multi-seed confirmation (I12, ADR-15): confirm the top-k under N seeds before\n # finishing. 0 disables (default). Only meaningful when eval has variance.\n confirm_top_k: int = 0\n confirm_seeds: int = 0\n # D1 seed-holdout discipline: the FIRST seed the confirm phase uses. Search evals run with the\n # implicit seed 0 (LOOPLAB_EVAL_SEED unset -> \"0\"), so a base of 1 keeps every confirm split\n # DISJOINT from anything the search optimized against — the confirm metric is then a\n # generalization signal, not a re-measurement (AIRA: selecting on a signal the search saw\n # overfits by 9-13 pp). Set 0 to restore the legacy overlapping seeds 0..N-1.\n confirm_seed_base: int = Field(default=1, ge=0)\n # D1 holdout-gated promotion (B6, Arbor-style): for host-graded tasks, reserve this fraction of\n # the held-out labels as a FINAL holdout partition the search never sees — every search/confirm\n # eval is scored on the remaining rows only, and at finish the val-top-k are re-scored on the\n # holdout partition (free: the predictions already exist). 0.0 = off (legacy full-label scoring).\n holdout_fraction: float = Field(default=0.25, ge=0.0, le=0.9)\n # Champion selection prefers the HOLDOUT metric among the nodes that have one (the val-top-k).\n # Recorded in run_started so replay applies the same rule; old logs fold to False (legacy pick).\n # The per-node val-holdout `generalization_gap` folds into the Trust panel either way.\n holdout_select: bool = True\n # How many val-leaders get a holdout evaluation at finish (host-graded tasks).\n holdout_top_k: int = Field(default=3, ge=1)\n # R1-c / Part IV — calibrated §12-verifier tie-break in best-selection. When a fresh node's robust\n # metric EXACTLY TIES an already-eligible node, the engine runs the §12 verifier (grounded on each\n # node's realized result, `selection_criteria`) and records a soundness score per node. The tie-break\n # then applies across the WHOLE selection path: the mean pick breaks a robust_metric tie by the score,\n # and — when `holdout_select` is on — the holdout-slot ranking prefers the sounder node so it isn't\n # denied a holdout eval, and the final holdout override breaks a holdout_metric tie by it too. Strictly\n # a TIE-BREAK — it can NEVER move a node ahead of a strictly-better metric (§21.7 advisory-never-\n # overrides). Best-effort: verification is LAZY (only fires on an actual tie) and bounded per cadence,\n # so a large/late tie stays on deterministic metric+id order unless one atomic treatment scores every\n # member. Recorded in run_started so\n # replay applies the same rule; old logs fold to False (legacy pick). Opt-in (default off); needs a\n # reachable LLM client. Enable only after validating calibration offline with `verifier.calibrate`.\n # See trust/verifier.py + core/fitness.py.\n select_verifier: bool = False\n # R1-d (§21.19): widen the verifier tie-break from EXACT-metric to a conservative STATISTICAL tie.\n # The band is capped by both the metric leader's SE and the pooled SE-of-difference, so challenger\n # variance cannot widen it and a significant difference is never a tie (§21.7). Off -> exact only.\n verifier_ci_tie: bool = False\n # Verifier sample count for `select_verifier` (the §12 repeated-sampling expectation). 3 tames the\n # single-shot variance; 1 = single-shot (cheaper, noisier).\n select_verifier_samples: int = Field(default=3, ge=1, le=32)\n # Budget (I13): hard wall-clock ceiling; the run aborts cleanly when exceeded.\n max_seconds: float | None = None\n # Eval-compute budget (#2): hard ceiling on cumulative time spent INSIDE evals (training\n # runs), separate from wall-clock. Survives resume (summed from the event log). The real\n # guard against a silent multi-hour sweep when an eval is a minutes-hours training run.\n max_eval_seconds: float | None = None\n # Cross-run memory (I19, ADR-10): if set, the best result of each run is stored as\n # a case here, and the cases become retrievable knowledge for future runs.\n memory_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"memory\"))\n # HITL (I21, ADR-11): pause for human approval of the final best before finishing.\n require_approval: bool = False\n # Diversity archive (I22): niche bucket width in parameter space.\n archive_resolution: float = 1.0\n # Coverage read-model (narrowing signal): at the strategist cadence, compute a breadth summary\n # (themes / param-niches / theme entropy / dominant-theme fraction) from the folded run, record\n # it as a `coverage_snapshot` audit event, and surface it into the Strategist's decision context.\n # Deterministic, cheap, and purely additive context — no search-behavior change on its own. On by\n # default (like the rest of the always-on situational context). See search/coverage.py.\n coverage_context: bool = True\n # PART IV Phase 2a live steering (§21.11/§21.13). When on, the `concept_retag_every` cadence (NOT\n # `strategist_every` — the producer gates on `_should_consult_concepts`, which uses the seed boundary\n # + `concept_retag_every`, default 30) records a\n # concept-graph coverage + uncovered-region snapshot (deterministic heuristic tagger over the\n # task-type skeleton) and, on an `explore` stance, the Researcher's novelty hint names the exact\n # uncovered regions (\"0 coverage in {negatives/external-mining, distillation} — go there\") instead\n # of the vague \"broaden\". ON by default in the product Settings (ce4a379), EngineOptions off: it\n # only enriches an already-mode-gated explore hint, never forces exploration, and no-ops for a task\n # with no curated concept skeleton. The snapshot is auditable, but its prompt cue can change future\n # candidate generation; it does not directly rewrite best-metric selection. See search/concept_graph.py.\n # DESIGN NOTE (2026-07-17 critique): now ON unconditionally, but a toy / non-ML task yields EMPTY concept\n # tags while still paying the per-node LLM concept cost (~10 calls/node with the full Part IV/V bundle).\n # Consider AUTO-QUIESCING: if the heuristic tagger returns empty for the first N nodes (no concept signal),\n # skip the LLM concept work for the run — keeps it on for ML tasks, free on toy/degenerate ones.\n concept_pivot: bool = True\n # PART IV Phase 2b — D3 graded novelty into the LIVE novelty gate (§21.4/§21.13). When on and the\n # task has a curated concept skeleton, the novelty gate first grades a proposal over the concept\n # graph: a level-4 \"same direction, DIFFERENT implementation\" or a level-5 \"re-opens a\n # wrongly-abandoned FAILED direction\" proposal is ALLOWED through unchanged — the flat LLM/semantic\n # dedup gate can't tell \"this DCL tweak\" from \"the whole DCL branch\" and would wrongly reject a\n # legitimate variant or never re-open a sound-but-killed direction (the node_63 archetype). Levels\n # 1/2/3 (identical / near-dup / prior-run) still fall through to the existing gate. Deterministic\n # (heuristic tagger, no LLM), audit event `novelty_graded`, replay-safe. ON by default in the product\n # Settings (ce4a379), EngineOptions off; no-ops for a task with no skeleton. See search/graded_novelty.py.\n graded_novelty: bool = True\n # PART IV Phase 2b — D7 capability-expansion forced-jump DIRECTIVE (§21.8/§21.13, issue #7). When on\n # and the concept-graph cadence detects action-space LOCK-IN (the search has stayed inside one D5\n # branch for a long consecutive streak) on an `explore` stance, the Researcher's novelty hint\n # ESCALATES from \"broaden\" to \"expand the ACTION SPACE — build the missing infra (external mining,\n # a new data pipeline, a different eval), do NOT swap another {locked} lever.\" The same\n # gate also stamps the proposal's authoritative operator as `expand`; that node competes normally\n # under SearchFitness and its separate operator yield is measured. It does not force a winner.\n # Reads the 2a concept-coverage snapshot, so it needs `concept_pivot` to record one. OPT-IN\n # (default off). See engine/proposal_cues.py + search/lock_in.py.\n capability_expansion: bool = False\n # PART IV cross-run Step 0 (§21.20.12/§21.20.13). Universal task-fingerprint tokenization: drop the\n # ASCII-only `[a-z0-9]` allowlist on goal keywords so a non-Latin goal (Russian, CJK, …) is NOT\n # silently dropped from its cross-run fingerprint (today such a goal keys only on kind/dir/metric and\n # never reaches SIMILAR-task priors/lessons/cases). Uses `[^\\W_]+`/`.casefold()` — same splitting as\n # before, any script. ON by default in the product Settings (ce4a379); the bare-library EngineOptions\n # default stays off, so a run pinned to it won't silently re-key a portfolio mid-flight. See engine/memory.py.\n fingerprint_universal: bool = True\n # PART IV cross-run Step 2 (§21.20). Fill graded-novelty's `prior_concepts` from the cross-run\n # ConceptCapsuleStore: at run end write a per-run concept capsule (the shipped `node_concepts` tags +\n # best outcome, keyed by task_fingerprint) to `memory_dir`; when a later SIMILAR run proposes an idea\n # whose concept was tried before, SURFACE the prior outcome as a `cross_run_prior` audit event (never\n # reject — D3 level 3). Audit-only, off the selection path; ON by default in the product Settings\n # (ce4a379), EngineOptions off. Needs a `memory_dir` to share capsules.\n # See engine/memory.py (ConceptCapsuleStore) + engine/novelty.py.\n # PREREQUISITES (this flag is NOT standalone): the WRITE needs per-run concept tags\n # (`node_concepts`, produced by `concept_pivot`/F1 tagging) — no tags => no capsule; the SURFACE path\n # runs inside the graded-novelty precheck, so it also needs `graded_novelty` on. With only this flag +\n # `memory_dir` it persists/surfaces nothing. Enable `concept_pivot` + `graded_novelty` alongside it.\n # A non-Latin (Russian/CJK) portfolio should ALSO set `fingerprint_universal`, else the capsule's goal\n # tokens are dropped and its fingerprint over-matches on kind/dir/metric alone (the novelty direction\n # gate narrows this, but goal-word similarity is still lost).\n cross_run_concepts: bool = True\n # PART V (B): run-base + node-DELTA concept authoring. When on, once the first evaluated node has\n # authored concepts the engine seeds RunState.run_base_concepts from them (a one-shot EV_RUN_CONCEPTS,\n # idempotent because it only fires while the base is empty), and the Researcher's proposal hint tells it\n # to author only `concepts_added`/`concepts_removed` vs the run base + its parent — minimal per-node\n # annotations that inherit down the DAG (a swap propagates). Off -> every node authors its full concept\n # set as before. Product-on; the bare EngineOptions mirror defaults off (test_options_divergence).\n concept_run_base: bool = True\n # PART IV cross-run Step 5 advisory (§21.20.5). Fold the bounded cross-run CONTEXT PACK — evidence-\n # grounded claims with BOTH support and counter-evidence (Step 4) + a portfolio-coverage line (Step 3) —\n # into the Researcher's proposal prompt, exactly like the E4 cross-run prior note. Advisory ONLY: it is\n # prompt-grounding: it can change the Researcher's later candidates, but does not directly rewrite\n # best-metric selection (§21.7). Reads `memory_dir` (lessons.jsonl + concept_capsules.jsonl); \"\" when\n # empty. ON by explicit experimental product choice; no frozen A/B validation is claimed. The\n # bare-library EngineOptions default stays off (engine/options.py, frozen in\n # test_options_divergence). See engine/proposal_cues.py.\n cross_run_advisory: bool = True\n # PART IV cross-run §21.20.13 (full CR of the lean fuzzy claim merge). Switch the claim read-model to the\n # SCOPE+POLARITY-safe STRUCTURED claim key (engine/claim_key.py): claims from different tasks never\n # merge, opposite polarity (\"X helps\" vs \"X never helps\") is surfaced as a CONTRADICTION instead of being\n # collapsed, paraphrase/inflection variants group by exact structured key (no transitive over-merge), and\n # operator governance is scope-precise (a decision in task A cannot reach a same-worded claim in task B).\n # Affects the `cross_run_advisory` context pack only; ON by default in the product Settings (ce4a379);\n # the bare-library EngineOptions default stays off (engine/options.py). See engine/claims.py.\n cross_run_structured_claims: bool = True\n # PART IV cross-run §22.4 (AGENTIC taxonomy steward). At finalize, when an LLM client is available, let\n # the concept steward (engine/concept_steward.py) review the freshly-updated portfolio concept graph and\n # PROPOSE a curation — merge duplicate slugs / split conflated ones / purge noise. Proposals are LOGGED to\n # `concept_curation_log.jsonl` for operator ratification (via the serve/CLI governance surface); the LLM\n # never mutates fold state. The calls run synchronously during finalize, so they add latency and may add\n # paid inference; a caught steward failure does not prevent terminal completion or trigger a retry.\n # ON by explicit experimental product choice when `memory_dir` + an LLM backend are present; the\n # bare-library EngineOptions default stays off (engine/options.py). See engine/lessons.py.\n cross_run_curation: bool = True\n # Deprecated compatibility flag. Steward output is untrusted and finalize is proposal-only regardless\n # of this value; retained so old snapshots still validate and logs can disclose that auto was requested.\n cross_run_curation_auto: bool = False\n # Role backend (ADR-7/14): \"toy\" (offline optimizer) | \"llm\" (live model). Validated in\n # `_check_trust_gate` alongside the other enum-ish strings — every consumer tests `== \"llm\"`\n # exactly (cli/__init__.py, adapters/tasks.py), so an untyped `--set`/file/env typo (or a mis-cased\n # \"LLM\") used to fall through to the OFFLINE toy roles and quietly produce a run that never called\n # the model. Left as `str`, not `Literal`, so old snapshots still validate through the same path.\n backend: str = \"toy\"\n # Developer backend (ADR-7): \"default\" (templated/LLM from the task) or an external\n # CLI coding agent: \"opencode\" | \"aider\" | \"goose\" | \"continue\".\n # CLAUDE REVIEW: [LOGIC] developer_backend is a CLOSED enum (default + cli_agent.PRESETS keys) but is\n # NOT validated in _check_trust_gate like backend/seed_mode/eval_trust_mode/strategist_backend. Only\n # the CLI --developer-backend flag guards it (_choice/_DEV_BACKENDS); a file `settings:` block,\n # LOOPLAB_DEVELOPER_BACKEND env, or `--set developer_backend=Aider` reaches here unchecked. The\n # consumer (adapters/tasks.py: `settings.developer_backend not in PRESETS`) then silently wires the\n # DEFAULT in-house developer — the exact silent-downgrade no-op the `backend` typo guard was added to\n # close (test_an_unknown_backend_is_rejected_instead_of_downgrading_to_toy), with no diagnostic.\n developer_backend: str = \"default\"\n # C2 best-of-N: generate N candidate implementations per node and keep the best by an\n # execution-free reward (static validity + metric-print). 1 = off. In-house LLM developer only\n # (not external agents — ADR-7 cost rule). The top SWE-bench reliability lever for weak models.\n best_of_n: int = 1\n # D10 list-wise best-of-N selection: when the top static-scorers TIE, break the tie with a\n # comparative LLM selection over the candidates presented together (+~3 pts vs pointwise on\n # GAIA, arXiv:2506.12928). The static score stays the primary filter — the LLM is a weak\n # comparative prior, never the sole oracle. ON by default (no-op when best_of_n==1).\n best_of_n_listwise: bool = True\n # === Foresight / predict-before-execute ===============================================\n # FOREAGENT predict-before-execute (arXiv:2601.05930): use the LLM as an IMPLICIT WORLD MODEL to\n # predict — WITHOUT executing — which candidate / hypothesis will score best, primed with a\n # Verified Data Analysis Report (data profile / brief) + the accumulated experiment memory. Master\n # switch, ON by default; a no-op wherever there is nothing to rank (best_of_n==1 and\n # foresight_panel==1) or no LLM client. See `looplab/search/foresight.py`.\n foresight: bool = True\n # Hypothesis foresight breadth: generate K candidate ideas per proposal and keep the one PREDICTED\n # best pre-execution by the world model — it ranks the STRUCTURAL / text ideas the numeric k-NN\n # surrogate (researcher_panel) is blind to, primed with the data profile + memory (the synergy).\n # >1 enables it (LLM backend only; needs `foresight` on); 2 = on by default at modest cost, 1 = off.\n foresight_panel: int = 2\n # AGENTIC foresight: run the ranking (hypothesis-board prioritization + K-idea pick) as a TOOL-USING\n # loop that can pull actual experiment results / data facts before deciding, instead of a one-shot\n # prediction from a pre-baked report. ON by default (needs foresight + a client; falls back to the\n # one-shot predictor on any hiccup). A few extra LLM calls per proposal; set False for the cheaper\n # single-call ranker.\n foresight_agentic: bool = True\n # §1 foresight confidence gate: the minimum predicted confidence at which a predict-before-execute\n # pick is ACTED ON. Below it the ranker abstains — the K-idea panel falls back to the first\n # proposal, best-of-N falls through to the D10 tie-break — instead of committing a low-confidence\n # choice. 0.0 (default) = OFF (act on every pick, the historical behavior); raise toward ~0.5 to\n # make the world model defer when it isn't sure. Pairs with the foresight track record (§1) the\n # predictor is now primed with. Range 0.0-1.0.\n # BOUNDED like its confidence/fraction siblings (train_monitor_kill_confidence,\n # asha_live_quantile). Unbounded, a typo'd 5.0 validated silently into a gate no score can ever\n # clear, so every foresight pick abstained forever with nothing in the snapshot to say why.\n foresight_min_confidence: float = Field(default=0.0, ge=0.0, le=1.0)\n # PART IV Phase 2c — replace the world model's SELF-REPORTED confidence (measured Pearson≈0 with the\n # realized outcome, §21.12) with a CALIBRATED §12-verifier score. When on and a client is available,\n # after the K-idea ranker picks the predicted-best candidate the §12 verifier scores it (grounded +\n # repeated + criteria-decomposed: `foresight_criteria` — likely to improve the objective, and\n # sound/feasible) and THAT calibrated score becomes the confidence the `foresight_min_confidence` gate\n # and the telemetry use. Degrades to the self-reported confidence without a client or on any verifier\n # error (the telemetry records which source was used). ON by default in the product Settings (ce4a379),\n # EngineOptions off; a few extra LLM calls per acted-on proposal. See looplab/trust/verifier.py + search/foresight.py.\n # REVISIT (cost/quality, not yet resolved): a live classification run measured ~10 LLM calls & ~3.8 min/node\n # on deepseek-v4-flash with the full Part IV/V default bundle; the calibrated verifier is part of that per-node\n # tax. Kept ON deliberately — it raises decision quality (self-reported foresight confidence has ≈0 Pearson\n # with realized outcome), and lowering quality to save calls is the wrong trade. TODO: run a frozen quality/cost\n # A/B (verifier-on vs -off) to quantify the recall@objective gain per extra call before considering a default flip.\n foresight_verify: bool = True\n # Verifier sample count for `foresight_verify` (the §12 repeated-sampling expectation on a no-logprob\n # backend). 3 tames the single-shot variance §21.12 measured; 1 = single-shot (cheaper, noisier).\n # RESUME-critical: this value is persisted in config.snapshot.json, so a hard `le` schema bound would\n # make an old run started with a larger LOOPLAB_FORESIGHT_VERIFY_SAMPLES un-resumable (Settings(**snapshot)\n # would raise on `looplab resume` / finalize-recovery / serve replay). CLAMP into [1, MAX] via a validator\n # instead of rejecting — the runtime cap is preserved without breaking the snapshots/env-compat rule.\n foresight_verify_samples: int = 3\n\n @field_validator(\"foresight_verify_samples\")\n @classmethod\n def _clamp_foresight_verify_samples(cls, v: int) -> int:\n # Coerce a persisted/env value into [1, MAX] rather than reject (see field note above).\n try:\n v = int(v)\n except (TypeError, ValueError):\n return 3\n return max(1, min(v, MAX_FORESIGHT_VERIFY_SAMPLES))\n\n # T7 LLM response cache: serve an identical DETERMINISTIC (temperature 0) request from an\n # in-process content-addressed cache instead of re-hitting the model — cuts cost on\n # retry/panel/verify flows. NEVER caches sampling calls (temp>0: best-of-N / panel / novelty\n # re-propose must vary). Off by default (most role calls run at temperature>0 anyway).\n llm_cache: bool = False\n agent_cmd: str | None = None # override the agent's launcher (path / wrapper)\n # External-agent validation (ADR-7): wrap a CLI-agent Developer with a validator that\n # audits each output (no-op/syntax/crash/timeout), retries with feedback, and falls\n # back to the in-house LLM Developer. Off -> use the raw agent output unchecked.\n validate_agent: bool = True\n agent_max_retries: int = 1 # re-prompts of the agent on an invalid result\n # Patch-gated multi-file agent (ADR-7 Rule 3): run the CLI agent in a git worktree and\n # accept only edits whose paths match `agent_surface` (reject-not-strip out-of-surface\n # touches). Lets the agent create helper modules, not just solution.py. Degrades to\n # whole-file readback if git is unavailable.\n agent_patch_gate: bool = True\n agent_surface: list[str] = [\"*.py\"] # edit-surface allow-list (globs)\n # Trust policy for an agent-authored eval/metric adapter (RepoTask onboarding, Phase 3):\n # \"ratify_freeze\" waits for human approval, \"autonomous\" accepts the proposed spec without that\n # gate, and \"ratify_freeze_drift\" also enforces the configured independent drift cross-check.\n eval_trust_mode: str = \"ratify_freeze\"\n # RepoTask node seeding policy (run-wide fallback; a task/editable `seed_mode` overrides):\n # \"auto\" (default) copies git-tracked files when the editable is a git repo (so a tree bloated\n # with untracked artifacts isn't deep-copied per node), else a full copy; \"tracked\" forces\n # code-only; \"all\" forces a full recursive copy (legacy). Genesis authors per-task from the\n # user's words; this is the global default when unspecified.\n seed_mode: str = \"auto\"\n llm_model: str = \"qwen3:8b\"\n # === LLM / transport ==================================================================\n llm_base_url: str = \"http://localhost:11434/v1\" # Ollama OpenAI-compatible endpoint\n llm_temperature: float = 0.6\n # H3 per-role model presets (5090 recipe): optionally run the Researcher and Developer on\n # DIFFERENT models/endpoints (e.g. Developer=Qwen3-Coder-30B for code, a fast model for breadth).\n # Blank = use the shared llm_model/llm_base_url. Generalizes the role-backend swap to per-role.\n researcher_model: str | None = None\n developer_model: str | None = None\n researcher_base_url: str | None = None\n developer_base_url: str | None = None\n # Per-role sampling temperature (§4.1): the Researcher wants breadth (higher temp = more diverse\n # ideas), the Developer wants determinism (lower temp = fewer syntax slips), the Strategist a\n # steady hand. Each overrides the shared `llm_temperature` for that role only; None = use the\n # shared value (byte-identical to before). Flat + nullable, mirroring the per-role model fields.\n researcher_temperature: float | None = None\n developer_temperature: float | None = None\n strategist_temperature: float | None = None\n # The Strategist had per-role TEMPERATURE but no model/endpoint of its own, so in split mode it\n # always ran on the shared llm_model however the other roles were pointed. These close that gap\n # with the same names/shape as the pair above; blank = its profile, else the shared value.\n strategist_model: str | None = None\n strategist_base_url: str | None = None\n # === Connection profiles (multi-provider) =============================================\n # A \"profile\" is a NAMED connection: {model, base_url, temperature, api_key_env, provider}. Roles\n # point at one by name through `role_profiles`; `llm_profile` is the default for every role that\n # doesn't. All three are EMPTY by default and the resolver short-circuits to the shared\n # llm_model/llm_base_url/llm_api_key path when they are — an operator running one model never\n # needs to know the word \"profile\".\n #\n # A profile stores `api_key_env`, the NAME of an environment variable, never the key itself. That\n # is ADR-11's \"a secret is a reference, not a value\" turned from a convention into a data type:\n # the whole profile map is then safe to write into config.snapshot.json, hand back over HTTP and\n # render into LOOPLAB_* — there is nothing in it to mask. It is also what makes per-role\n # CREDENTIALS expressible at all: two roles on one provider can carry different keys, which a\n # key-per-endpoint map could not say.\n llm_profiles: dict[str, dict] = Field(default_factory=dict)\n llm_profile: str | None = None\n role_profiles: dict[str, str] = Field(default_factory=dict)\n # Unified self-driving agent: one LLM identity that plays Researcher + Developer (+ Strategist)\n # across pipeline stages, choosing its own model/toolset per stage. ON by default — the agent\n # drives the loop (incl. crash triage: repair/abandon/reject_idea) and, via\n # `agent_drives_actions`, picks the next macro action within a pure legal-action gate. Set both\n # to False for the legacy byte-identical split-role behavior. (No-op unless backend=\"llm\".)\n unified_agent: bool = True\n agent_drives_actions: bool = True\n # Layer 3 Card queue ownership. False preserves the existing policy/unified-pilot action path.\n # True is pinned in run_started because changing the selector on resume would mix two search\n # treatments in one run. When both this and agent_drives_actions are true, Card selection wins.\n card_driven_selection: bool = False\n # Layer 5 bounded speculative Card buffer. Zero is a hard OFF switch and preserves the\n # historical alternating build/eval spine; positive values are pinned by run_started so a\n # resume cannot silently mix search treatments after a snapshot/default edit.\n speculation_depth: int = Field(default=0, ge=0, le=64)\n # Local evidence receipt produced by ``looplab speculation-gate``. This is deliberately absent\n # from the curated Settings UI: positive-depth execution is a calibrated rollout decision, not a\n # casual tuning knob. The Engine validates the receipt against the current scorer,\n # implementation, GPU inventory and raw paired-run evidence before it enables speculation.\n speculation_gate_receipt: str | None = Field(default=None, max_length=4096)\n # Per-stage model/endpoint overrides for the unified agent. Recognized keys (see\n # tasks.build_unified_agent): `propose` (researcher), `implement`/`repair` (developer),\n # `strategy`, `pilot`. Unlisted keys are ignored. Empty = the per-role researcher_*/developer_*\n # models, then the shared llm_model. Explicit map wins. No-op unless `unified_agent` is true.\n agent_stage_models: dict = {}\n agent_stage_base_urls: dict = {}\n llm_parser: str = \"tool_call\"\n # Reasoning/thinking toggle sent IN the request (providers differ — see `llm.reasoning_body`):\n # Qwen3 on vLLM/SGLang -> chat_template_kwargs.enable_thinking (bool)\n # OpenAI/Ollama-v1/DeepSeek -> reasoning_effort (low|medium|high|none)\n # `llm_reasoning`: \"\" = send nothing (server default — current behavior); off = actively disable;\n # on = enable at default depth; low|medium|high = enable at that effort.\n # Defaults to \"high\" so the agent reasons before proposing/repairing/triaging (better code, fewer\n # crashes); set \"\" to send nothing (server default) or \"off\" to disable. The model's chain-of-thought\n # is CAPTURED either way (reasoning_content field or inline <think>). NOTE: to get a SEPARATE\n # reasoning_content field from SGLang/vLLM, the server also needs `--reasoning-parser qwen3`.\n llm_reasoning: str = \"high\"\n llm_reasoning_style: str = \"auto\" # auto | qwen | effort | none — how to shape the request param\n llm_reasoning_extra: dict = {} # raw fields merged into the body (escape hatch, e.g. Anthropic thinking)\n # H1: drive structured calls with the endpoint's constrained decoding (vLLM/SGLang guided_json +\n # response_format json_schema) so weak models can't emit invalid JSON. Off for Ollama (default).\n llm_guided_json: bool = False\n # Stream every LLM request (SSE) and reassemble it, so the request `timeout` is an INTER-TOKEN\n # idle timeout rather than a whole-request deadline: a long-but-alive generation is never cut off\n # (tokens keep resetting the timer), while a genuinely STALLED endpoint (no token for `timeout` s)\n # is detected and retried on a fresh connection — the fix for silent multi-minute hangs. Set False\n # to use one blocking read (old per-op timeout semantics) if an endpoint streams badly.\n llm_stream: bool = True\n # LLM IDLE timeout (seconds). Stream mode: the INTER-TOKEN stall limit — a steady generation is\n # never cut off, only a silent endpoint. Non-stream: bounds the whole read. Raise for endpoints\n # with a long prefill on huge prompts; lower to fail fast on a flaky shared endpoint.\n llm_timeout: float = Field(default=180.0, gt=0)\n # First-byte (response-headers) window for STREAM attempts, seconds: an SSE response sends its\n # headers on admission, so no-headers ≈ a black-holed request — fail over to a fresh connection\n # fast instead of waiting the full idle timeout. Clamped to llm_timeout. Non-stream attempts are\n # NOT bounded by this (their headers arrive only after the whole generation). The default mirrors\n # `DEFAULT_HEADER_TIMEOUT_S` in looplab/core/llm.py is the single source (imported above).\n llm_header_timeout: float = Field(default=DEFAULT_HEADER_TIMEOUT_S, gt=0)\n # Honour HTTP(S)_PROXY / NO_PROXY / SSL_CERT_FILE env vars for LLM calls. Default False: the\n # transport connects DIRECTLY (many internal/OLAP endpoints are reachable only by bypassing an\n # ambient proxy — a picked-up proxy yields \"connection refused\"). Set true when the endpoint is\n # reachable ONLY via a corporate proxy or needs a custom CA bundle from the environment.\n llm_trust_env: bool = False\n # H4/C2: compact the growing agentic tool-call history once it exceeds this many chars (auto_summary\n # LLM-summarizes the stale middle; else middle-truncate). Sized to the model's real context window so\n # files read earlier STAY in context instead of being compacted away and re-read: ~1,000,000 chars ≈\n # 250k tokens (≈4 chars/token), matching the configured deepseek-v4-flash (≥200k tokens confirmed).\n # The old 120k-char (~30k-token) default was ~8× too aggressive and drove a read-thrash loop. 0 = off.\n context_budget_chars: int = 1_000_000\n # M5: the Researcher's always-on experiments digest budget (chars). 0 = AUTO — scales with the\n # run size (60 chars/node, bounded [1200, 6000]) instead of one flat cap for an 8-node toy run\n # and a 200-node benchmark run alike. Set a positive value to pin it.\n digest_char_cap: int = Field(default=0, ge=0)\n # D11 compression model slot (open_deep_research's four-slot pattern): a dedicated CHEAP model\n # for agent-history summarization (C2 auto-summary), so compression doesn't pay the main\n # model's price. Blank = use each loop's own model (legacy). `compressor_base_url` blank =\n # the shared llm_base_url.\n compressor_model: str | None = None\n compressor_base_url: str | None = None\n # === Agentic tool-loop ================================================================\n # Agentic tool-loop limits — apply to EVERY tool-using agent (the LLM Researcher, the unified\n # agent's pilot + crash-triage, Deep-Research, the run-chat Boss, the genesis repo-scout, and the\n # cross-run report synthesizer). The loop lets the model call read-only tools across turns before\n # emitting its structured result. Both default to UNLIMITED so the agent takes as many turns / as\n # long as it needs — it is never cut off mid-reasoning; set a positive value here (or per-run, or\n # in the UI settings) to bound latency/cost. These used to be hardcoded per call site (e.g. the\n # boss at max_turns=3 / 45s), which silently dropped a slow reasoning model to a no-op reply.\n agent_max_turns: int = 0 # max tool turns before the emit is forced (0 = unlimited)\n agent_time_budget_s: float = 0.0 # wall-clock ceiling across the loop's turns (0 = no cap)\n # G · Soft convergence SAFETY NET (not a research cap). Two thresholds on the agent's tool-turn count:\n # at `agent_emit_after` the loop NUDGES the agent once (\"you've investigated enough — emit now\"); at\n # `agent_emit_force` it FORCES the emit from what was gathered. Set GENEROUSLY — a focused researcher\n # may legitimately read many files/experiments before it can propose a grounded idea; these only catch\n # a genuine runaway that keeps issuing DIFFERENT calls forever (which stuck-detection, keyed on\n # repeats, misses). NB: the old 499-read runaway (node 63) was 79% REDUNDANT re-reads caused by a\n # broken read_file — fixing read_file (pagination) is the real cure; this is just insurance. 0 = off.\n agent_emit_after: int = 300\n agent_emit_force: int = 500\n # B1 · No-progress / stuck detection — the safety net that makes \"unlimited turns\" safe. The\n # turn/time ceilings above are only BACKSTOPS; this is what actually stops a runaway loop, on the\n # cheapest signal: when the model repeats the SAME tool call (or ping-pongs between two, or keeps\n # hitting the SAME error) with no progress, the loop forces the final emit and finishes. ON by\n # default; reading DIFFERENT files or running ONE long command never trips it. (OpenHands-style.)\n agent_stuck_detection: bool = True\n agent_stuck_repeat: int = 4 # identical calls in a row that count as \"stuck\" (>=2)\n agent_stuck_alternate: int = 4 # ping-pong cycles between two calls that count as \"stuck\" (>=2)\n # C1 · Self-plan (TodoWrite-style): expose an `update_plan` tool so a long-running agent keeps its\n # OWN working TODO and re-surfaces it every `agent_plan_reinject_every` turns — keeps the goal in\n # view across a long loop so the agent \"writes its own context\" (Claude Code style). ON by default.\n agent_self_plan: bool = True\n agent_plan_reinject_every: int = 5\n # C2 · Auto-summary (ON by default): when the tool-loop history grows long, LLM-summarize the\n # stale middle instead of only middle-truncating it. The trigger is `context_budget_chars` if set,\n # else a built-in high-water mark (context_budget.DEFAULT_SUMMARY_CHARS ≈ 120k chars) so short\n # loops are untouched. Falls back to truncation on any summarizer error; one extra model call per\n # over-budget turn.\n agent_auto_summary: bool = True\n # C4 · Repo-developer plan decomposition. A big feature attempted in ONE developer session made a\n # non-converging reasoning model loop indefinitely — it kept writing + exploring without ever\n # emitting `done` (10k+ LLM calls / hours; agentic stuck-detection can't catch VARIED exploration,\n # only literal repeats). Fix: the developer first proposes an ordered plan of ATOMIC steps; a\n # multi-step plan is executed step-by-step, each in a FRESH bounded session with only that step's\n # scope + the files accumulated so far, syntax-validated per write. Every session stays\n # small-context and convergent. ON by default for repo tasks (a 1-step plan == the old single pass).\n developer_plan_decompose: bool = True\n developer_plan_min_steps: int = 2 # a proposed plan with >= this many steps runs step-by-step\n developer_plan_max_steps: int = 8 # cap on plan length (a runaway planner can't spawn 100 steps)\n # Hard per-session backstop for the repo developer — the ONE write-heavy agent that can run away\n # even with stuck-detection on (varied writes/reads never trip the repeat/ping-pong signal). Bounds\n # the plan phase, EACH step, and the single-session fallback. Unlike the global agent_* limits\n # (0 = unlimited, meant for read-only agents), the developer always gets a FINITE ceiling so a\n # model that never emits `done` fails cleanly with the code it wrote so far, instead of looping.\n developer_session_max_turns: int = 500\n developer_session_time_budget_s: float = 1200.0 # 20 min wall-clock per developer session\n # Phase-handoff summaries. Each LLM phase in a node build (Researcher·propose → Developer·stages →\n # plan → implement) ends with ONE extra LLM call that distills its transcript — the repo structure\n # it mapped, files/data confirmed, decisions made — into a brief injected into the NEXT phase (even\n # across the role boundary). The next phase TRUSTS that instead of re-reading the same files, which\n # cuts the biggest source of tool-call thrash (stages/plan/implement each re-exploring the repo).\n # One summary call per phase in exchange for many fewer read calls downstream. ON by default.\n phase_handoff_summary: bool = True\n # Observability (ADR-17): capture each LLM call's full prompt + completion into the active\n # span (spans.jsonl) so the UI can show exactly what the model read and wrote per node.\n # Diagnostics only — `replay.fold` never reads spans. Default on for local single-user; set\n # LOOPLAB_TRACE_LLM_IO=0 to suppress (e.g. to keep spans.jsonl small or avoid storing prompts).\n trace_llm_io: bool = True\n # Run-introspection tools (ON by default): make the LLM Researcher (and DeepResearcher) a\n # tool-using agent that can read its OWN experiments (list/read/find-analogous/themes/code) and\n # the task data (schema/profile/asset) mid-loop, instead of seeing only best+parent. Advisory —\n # never changes best-selection. Off = the legacy single-shot Researcher (richer digest still added).\n researcher_tools: bool = True\n # Cross-run introspection: give the agentic Researcher / pilot read-only tools to look at\n # SIBLING runs (same task_id, same run-root) — list them, read an experiment / its code, and\n # find analogous configs across runs — so a run can build on what neighbouring runs already\n # learned instead of rediscovering it. Advisory; never changes best-selection. Needs the run's\n # own dir wired through (no-op for unit-built roles). Off = the legacy single-run view only.\n cross_run_tools: bool = True\n # Read-only access to EVERY run on this machine ACROSS ALL TASKS (not just same-task siblings):\n # the Developer/Researcher get list_all_runs + read_run_code + read_run_experiment so they can read\n # the code + result of ANY past experiment anywhere and reuse an approach. Broader than\n # `cross_run_tools` (same-task only); the agent decides when a foreign run is relevant. Advisory;\n # never changes best-selection. Needs the run's own dir wired through (no-op for unit-built roles).\n all_runs_tools: bool = True\n # PART V §22 — read-only CROSS-RUN KNOWLEDGE tool for the reasoning roles (Researcher, Strategist,\n # deep-research, and a Developer-scoped variant). Adds `cross_run_prior_attempts` / `cross_run_claims`\n # / `cross_run_atlas` over the §21.20 read-models (concept overview + claim assessments + atlas), read\n # from `memory_dir` (lessons.jsonl + concept_capsules.jsonl). Read-only is a storage-authority\n # boundary, not a behavioral no-op: retrieved content can steer later proposals, and a tool-using\n # reasoning loop can consume additional model calls. The tools never mutate cross-run truth (facts\n # are engine-written, verdicts operator-ratified, §22.4) or directly select a winner. ON by explicit\n # experimental product choice, EngineOptions off; stores only exist once Part-IV features have run.\n cross_run_read_tools: bool = True\n # Agentic retrieval (ADR-16): if set, the LLM Researcher gets grep/kb_search/read\n # tools over this directory of markdown notes and chooses when to use them.\n knowledge_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"knowledge\"))\n # T4 real embeddings: model id for an OpenAI-compatible `/embeddings` endpoint used for\n # kb_search / case retrieval. Blank (default) = the dependency-free lexical `hash_embed` (today's\n # behavior). Set e.g. \"nomic-embed-text\" (Ollama) or \"text-embedding-3-small\" for SEMANTIC\n # retrieval; a misconfigured/offline endpoint degrades back to hash_embed at call time (never\n # crashes a run). `embed_base_url` blank = reuse `llm_base_url`; the shared `llm_api_key` is used.\n embed_model: str | None = None\n embed_base_url: str | None = None\n # Harmonic memory (Memora, ICML'26 — idea import). When ON, cross-run cases and knowledge notes are\n # indexed by a short ABSTRACTION + cue ANCHORS instead of their raw text, near-duplicate memories are\n # CONSOLIDATED into one entry under a matching abstraction, and `kb_search`/case retrieval EXPAND\n # through the top hits' anchors to surface related-but-not-similar memories. ON by default. Set\n # `memora=false` to restore the pre-Memora raw-text index. Never a source of truth — abstractions\n # live only in the derived, rebuildable retrieval index (never the event log or canonical cases.jsonl).\n memora: bool = True\n # Write abstractions with the wired chat model (richer than the deterministic lexical fallback). ON\n # by default; results are CACHED by content hash (see `memora_cache`) so a re-built index doesn't\n # re-call the model on unchanged notes/cases, and any endpoint failure degrades to lexical at call\n # time — so an offline box just gets lexical abstractions, never a crash. Set false to force lexical.\n memora_llm: bool = True\n # === Cross-run memory / Memora ========================================================\n memora_anchors: int = 6 # max cue anchors kept per memory\n # Cosine similarity at/above which a new case is CONSOLIDATED into an existing one (same evolving\n # topic) rather than stored as a separate entry. Only used when `memora` is on.\n # CLAUDE REVIEW: [EDGE-CASE] unbounded 0..1 cosine knob with no Field bounds. Consumers compare\n # `cosine(...) >= consolidate_threshold` (engine/memory.py, tools/knowledge_tools.py) and cosine is\n # capped at 1.0, so a typo'd value >1.0 validates cleanly and SILENTLY DISABLES consolidation forever\n # (a negative one consolidates everything). Its 0..1 sibling novelty_semantic_threshold carries\n # Field(ge=0.5, le=1.0); this should be bounded the same way (e.g. ge=0.0, le=1.0) to fail loud.\n memora_consolidate_threshold: float = 0.86\n # Where the LLM-abstraction cache lives (JSON). Blank (default) derives it from `memory_dir`\n # (`<memory_dir>/memora_cache.json`) or else `knowledge_dir` (`<knowledge_dir>/.memora_cache.json`);\n # with neither set the cache is in-memory only (per process). No-op unless `memora_llm` is on.\n memora_cache: str | None = None\n # E3 literature-grounded ideation (network-OPTIONAL): give the agentic Researcher an arXiv search\n # tool to ground ideas in real techniques. Off by default (egress is unreliable on some boxes);\n # fails gracefully if the network is blocked.\n literature_search: bool = False\n # Deep-Research stage (network-OPTIONAL): give the DeepResearcher a general web search/fetch\n # tool (DuckDuckGo) on top of arXiv to read across results + the web before steering the next\n # batch. Off by default (egress is unreliable on some boxes); fails gracefully when blocked.\n web_search: bool = False\n # Cadence for the Deep-Research stage: run it automatically every N created nodes. Zero disables\n # only the cadence; manual `deep_research` and Strategist `request_research` triggers still fire.\n # Default 3: deep research analyzes the accumulating results and steers the next batch (its\n # directions become hints + open hypotheses); paired with `concurrent_research` it overlaps the\n # think with the GPU-bound eval, so the cadence costs LLM tokens but no wall-clock on the search.\n deep_research_every: int = 3\n # Overlap a DUE deep-research \"think\" with the GPU-bound eval instead of running it in its own\n # serial step (the agent is otherwise idle while a node trains). research() computes from a state\n # snapshot; the joined background task records only allowlisted, order-tolerant\n # research/hint/hypothesis events as soon as the memo finishes. ON by default: the LLM is typically\n # remote (no GPU contention with eval), so overlapping hides latency behind long training.\n concurrent_research: bool = True\n # Concurrent-research REPEAT — don't idle a multi-DAY eval. The overlapped \"think\" fires ONCE per\n # window by default; ON here, it RE-RUNS on an adaptive time cadence for the WHOLE eval window\n # (a two-day training must not leave the reasoning agents idle after one memo). Self-paced: it\n # only records a memo whose CONTENT is new (identical re-runs are skipped, so the log/board don't\n # bloat) and backs off geometrically as the analysis converges, capped so it always re-checks.\n # The appended records never rewrite the current metric champion, but their hints/open hypotheses\n # deliberately steer later proposals and replay reconstructs that advice. The library default is\n # one-shot (== today, byte-identical); this product default turns the repetition on.\n concurrent_research_repeat: bool = True\n # Base cadence (seconds) between repeated concurrent-research passes. Research is EXPENSIVE\n # (multi-turn LLM + web/arXiv), so this is a FLOOR, not a ceiling: the effective cadence is\n # max(this, ~5% of the per-experiment time budget) — a two-day eval is re-researched about hourly,\n # a short eval not at all (the first tick outlasts it). Only matters when the repeat is on.\n concurrent_research_interval_s: float = Field(default=1800.0, gt=0)\n # Per-eval-window backstop on repeated-research LLM calls (0 = cadence-only, no cap). The adaptive\n # cadence + convergence backoff are the primary budget control; this bounds a pathological\n # never-converging window. Past it the loop stops calling the LLM (the health monitor still runs).\n concurrent_research_max_calls: int = Field(default=40, ge=0)\n # Concurrent hypothesis-board CONSOLIDATION during the eval window. Repeated research keeps ADDING\n # near-duplicate directions to the open board (each memo registers up to 5 as open hypotheses), so a\n # long eval's board bloats before the between-nodes merge runs. This dedups/merges it on the SAME\n # background loop so the board stays clean for the next proposal. In Card-driven mode the same merge\n # changes ownership/readiness, so it is deferred to the joined main-task cadence instead. Needs the\n # reflect client + `track_hypotheses`.\n # Only active while `concurrent_research` runs the overlap loop. Library default off (== today).\n concurrent_consolidate: bool = True\n # Cadence for the agent-authored run report: regenerate the conclusion-first narrative every N\n # created nodes (0 = off; it still regenerates on a manual `report_refresh` from the UI). The\n # deterministic report always renders from the node set regardless of this knob.\n report_every: int = 3\n # Agent Skills (I18, ADR-9): dir of SKILL.md the Researcher can list/load as tools.\n skills_dir: str | None = None\n # Prompt store (I18, ADR-8): dir of editable, hot-reloaded role prompt .md files.\n prompt_dir: str | None = None\n # API key as a reference, never serialized as a value (ADR-11). Local servers\n # ignore it; default to a placeholder.\n llm_api_key: SecretStr | None = None\n\n @model_validator(mode=\"before\")\n @classmethod\n def _reject_bool_for_numeric_fields(cls, data):\n \"\"\"A JSON boolean is a TYPE ERROR for a numeric setting, not the number 1.\n\n Pydantic runs in lax mode here (`model_config` sets no `strict`), and `isinstance(True, int)`\n is True — so `{\"max_nodes\": true}` from a UI/API type slip validated cleanly as a budget of\n ONE node, and `{\"eval_parallel\": true}` collapsed the width to one. The run then looked\n configured and simply did almost nothing, with no 422 and nothing in the snapshot to show why.\n Rejecting at the boundary covers every numeric field at once, including the many that carry no\n `Field(ge=...)` bound of their own to trip over.\n \"\"\"\n if not isinstance(data, dict):\n return data\n for key, val in data.items():\n if type(val) is bool and _is_numeric_annotation(\n getattr(cls.model_fields.get(key), \"annotation\", None)):\n raise ValueError(f\"{key} must be a number, not a boolean (got {val!r})\")\n return data\n\n @model_validator(mode=\"before\")\n @classmethod\n def _apply_profile(cls, data):\n \"\"\"Expand `profile` into a bundle of setting defaults BEFORE validation.\n\n pydantic-settings hands this validator the fully-merged input from ALL explicit sources\n (init kwargs + LOOPLAB_* env + .env), with fields the user left unset simply ABSENT (they\n fall to field defaults afterward). So a profile value is applied only when its key is not\n already present — i.e. any explicit file/CLI/env setting wins over the profile, and the\n profile wins over the bare field default. This makes `thorough` a true convenience preset,\n never an override of what the user asked for.\"\"\"\n if not isinstance(data, dict):\n return data\n prof = data.get(\"profile\", \"default\")\n # env may deliver the key as \"profile\" (env_prefix stripped) — normalize to a str\n prof = str(prof).strip().lower() if prof is not None else \"default\"\n overrides = PROFILES.get(prof)\n if overrides is None:\n raise ValueError(f\"unknown profile {prof!r}; known: {sorted(PROFILES)}\")\n for key, val in overrides.items():\n if key not in data: # explicit source (file/CLI/env) always wins\n data[key] = val\n return data\n\n @model_validator(mode=\"after\")\n def _check_trust_gate(self):\n if self.trust_gate not in (\"audit\", \"gate\", \"block\"):\n raise ValueError(\n f\"trust_gate must be audit|gate|block, got {self.trust_gate!r}\")\n if self.merge_mode not in (\"auto\", \"mean\", \"ensemble\"):\n raise ValueError(\n f\"merge_mode must be auto|mean|ensemble, got {self.merge_mode!r}\")\n # novelty_mode drives the dedup/novelty gate; an out-of-set value (e.g. a mis-cased\n # LOOPLAB_NOVELTY_MODE=LLM, or \"on\") used to fall through _apply_novelty_gate silently as a\n # NO-OP — turning the gate off with no diagnostic. Fail loudly at config time like trust_gate/\n # merge_mode do. (Other enum-ish string fields — seed_mode/eval_trust_mode/strategist_backend —\n # share the pattern; left unvalidated here only because their full value sets are less settled.)\n if self.novelty_mode not in (\"off\", \"algo\", \"llm\"):\n raise ValueError(\n f\"novelty_mode must be off|algo|llm, got {self.novelty_mode!r}\")\n # The remaining enum-ish string fields (arch-review §5 P3): a typo used to be accepted at\n # construction and only fail-safe/later-loud downstream (e.g. a mis-cased strategist_backend\n # silently ran the default). Fail loudly here like the fields above.\n # CLAUDE REVIEW: [READABILITY] the parenthetical in the novelty_mode comment above claims\n # seed_mode/eval_trust_mode/strategist_backend are \"left unvalidated here ... because their full\n # value sets are less settled\" — stale: all three ARE validated in this very block below. Update\n # that parenthetical so a load-bearing comment doesn't contradict the code 8 lines down.\n if self.strategist_backend not in (\"off\", \"rule\", \"llm\", \"agent\"):\n raise ValueError(\n f\"strategist_backend must be off|rule|llm|agent, got {self.strategist_backend!r}\")\n if self.eval_trust_mode not in (\"ratify_freeze\", \"autonomous\", \"ratify_freeze_drift\"):\n raise ValueError(\n \"eval_trust_mode must be ratify_freeze|autonomous|ratify_freeze_drift, \"\n f\"got {self.eval_trust_mode!r}\")\n if self.seed_mode not in (\"auto\", \"tracked\", \"all\"):\n raise ValueError(f\"seed_mode must be auto|tracked|all, got {self.seed_mode!r}\")\n if self.backend not in (\"toy\", \"llm\"):\n raise ValueError(f\"backend must be toy|llm, got {self.backend!r}\")\n # `parse_structured` resolves an unknown name to the DEFAULT fallback order, so a typo'd\n # llm_parser is indistinguishable from asking for the default. Validate against the parser\n # registry itself (imported here, not at module scope, to keep config import-light).\n from looplab.core.parse import _ORDER as _PARSER_ORDER\n if self.llm_parser not in _PARSER_ORDER:\n raise ValueError(f\"llm_parser must be one of {'|'.join(sorted(_PARSER_ORDER))}, \"\n f\"got {self.llm_parser!r}\")\n self._check_llm_profiles()\n return self\n\n def _check_llm_profiles(self) -> None:\n \"\"\"Validate the connection-profile maps LOUDLY, at construction.\n\n Assignment is not validated anywhere in this project, and these two maps are the kind of\n thing a typo makes into a silent no-op: an unknown role name would simply never be read, and\n a reference to a missing profile would fall back to the shared model while the operator\n believed a second provider was in play. Both are errors here instead.\n\n The `api_key_env` rules exist for a specific reason. It must LOOK like a secret to\n `runtime/sandbox.py::SECRET_ENV`, because that is the filter that strips the operator's keys\n out of the environment handed to generated candidate code — a key parked in a variable named\n `MY_HANDLE` would be invisible to it and ride straight into the sandbox. This pattern is\n deliberately the STRICTER of the two (the sandbox has to recognize whatever already exists;\n this names a variable the operator is about to create), and it is duplicated below rather than\n imported because layering forbids `core` from importing `runtime`.\n `tests/test_secret_env_pattern.py` pins the two together — including the invariant that every\n name accepted here is one the sandbox strips. And a literal key inside a profile is refused\n outright: the profile map is written verbatim into config.snapshot.json and served over HTTP\n precisely because it is supposed to contain no secrets.\"\"\"\n from looplab.core.llm import LLM_ROLE_KEYS\n if not isinstance(self.llm_profiles, dict):\n raise ValueError(\"llm_profiles must be a map of profile name -> profile\")\n for name, entry in self.llm_profiles.items():\n if not isinstance(entry, dict):\n raise ValueError(f\"llm_profiles[{name!r}] must be an object\")\n # BEFORE the unknown-field check: none of these is a known field, so checking them\n # afterwards could never fire and someone who pasted a live key was told it was a typo.\n for banned in (\"api_key\", \"key\", \"token\", \"secret\"):\n if banned in entry:\n raise ValueError(\n f\"llm_profiles[{name!r}] must not contain {banned!r}: a profile stores \"\n \"api_key_env (the NAME of an environment variable), never a key value — \"\n \"the whole map is written to config.snapshot.json and served over HTTP\")\n unknown = set(entry) - _PROFILE_FIELDS\n if unknown:\n raise ValueError(\n f\"llm_profiles[{name!r}] has unknown field(s) {sorted(unknown)}; \"\n f\"known: {sorted(_PROFILE_FIELDS)}\")\n env = entry.get(\"api_key_env\")\n if env is not None and not _SECRET_ENV_NAME.match(str(env)):\n raise ValueError(\n f\"llm_profiles[{name!r}].api_key_env={env!r} is not a usable variable name: it \"\n \"must be UPPER_SNAKE and contain KEY/SECRET/TOKEN/PASSWORD/PASSWD/CREDENTIAL, so \"\n \"the sandbox's secret-name filter strips it from generated code's environment\")\n temp = entry.get(\"temperature\")\n if temp is not None and (isinstance(temp, bool) or not isinstance(temp, (int, float))):\n raise ValueError(f\"llm_profiles[{name!r}].temperature must be a number\")\n if not isinstance(self.role_profiles, dict):\n raise ValueError(\"role_profiles must be a map of role name -> profile name\")\n for role, profile in self.role_profiles.items():\n if role not in LLM_ROLE_KEYS:\n raise ValueError(\n f\"role_profiles has unknown role {role!r}; known: {sorted(LLM_ROLE_KEYS)}\")\n if profile not in self.llm_profiles:\n raise ValueError(\n f\"role_profiles[{role!r}] names profile {profile!r}, which is not in llm_profiles\")\n if self.llm_profile is not None and self.llm_profile not in self.llm_profiles:\n raise ValueError(\n f\"llm_profile={self.llm_profile!r} is not in llm_profiles\")\n\n def masked_snapshot(self) -> dict:\n # Snapshots are a JSON contract (they are written verbatim to config.snapshot.json), not a\n # Python-object dump. In particular tuple-valued schema fields such as\n # ``inline_repair_reasons`` must become JSON arrays before callers hash the runtime envelope;\n # otherwise the strict speculation-scope canonicalizer sees a different/non-JSON preimage\n # from the one that ``json.dumps`` eventually persists.\n d = self.model_dump(mode=\"json\")\n if self.llm_api_key is not None:\n d[\"llm_api_key\"] = \"***\"\n else:\n d[\"llm_api_key\"] = None\n # The snapshot's own FORMAT version — not a Settings field (it is never settable by env or\n # file, and it describes the document, not the run). `settings_from_snapshot` fails closed on\n # a version it does not understand; see CONFIG_SNAPSHOT_SCHEMA.\n d[CONFIG_SNAPSHOT_SCHEMA_KEY] = CONFIG_SNAPSHOT_SCHEMA\n return d\n\n\n# The config snapshot's document-format version, written by `masked_snapshot` and enforced by\n# `settings_from_snapshot`. Bump it when a snapshot written by this binary would be MISREAD by an\n# older one — i.e. when a new field changes paid, concurrency or selection behaviour and silently\n# defaulting it would resume the same event history under different semantics. `Settings` is\n# `extra=\"ignore\"`, so without this an older binary drops what it does not recognize and resumes\n# anyway, with no diagnostic; a version it does not know is now a loud refusal instead.\n# Version 1 is the first VERSIONED format. An absent key means a pre-versioning snapshot, which stays\n# readable through `LEGACY_CONFIG_SNAPSHOT_DEFAULTS` — that historical contract is unchanged.\nCONFIG_SNAPSHOT_SCHEMA = 1\nCONFIG_SNAPSHOT_SCHEMA_KEY = \"config_snapshot_schema\"\n\n\nclass ConfigSnapshotVersionError(ValueError):\n \"\"\"A config snapshot was written by a NEWER binary than the one reading it.\"\"\"\n\n\n# Pre-versioned snapshots are full Settings dumps, so absence is a reliable per-field version marker.\n# Keep their historical effective behavior when newer product defaults become active: re-entry must not\n# silently add paid calls, interventions, concurrency or a different selection policy to an old run.\nLEGACY_CONFIG_SNAPSHOT_DEFAULTS: dict[str, object] = {\n \"parallel_build\": 1,\n \"eval_parallel\": None,\n \"llm_parallel\": None,\n \"train_monitor\": False,\n \"train_monitor_interval_s\": 600.0,\n \"train_monitor_kill\": False,\n \"train_monitor_kill_confidence\": 0.8,\n \"asha_live\": False,\n \"asha_live_kill\": False,\n \"asha_live_quantile\": 0.5,\n \"asha_live_min_siblings\": 3,\n # Before this operator ceiling existed, the sandbox's 24-hour process limit was authoritative.\n \"max_eval_timeout\": 24 * 3600.0,\n \"watchdog_reflection\": False,\n \"card_driven_selection\": False,\n \"speculation_depth\": 0,\n \"speculation_gate_receipt\": None,\n \"concurrent_research_repeat\": False,\n \"concurrent_research_interval_s\": 1800.0,\n # Zero means unlimited; 40 was the first bounded default, and is latent while repeat is disabled.\n \"concurrent_research_max_calls\": 40,\n \"concurrent_consolidate\": False,\n # The map originally covered only the flags added in the days just before it was written\n # (the entries above are all 2026-07-19..07-21; the map itself landed 07-22). Everything below was\n # added 2026-06-24..07-17 — AFTER `config.snapshot.json` existed (it ships in the initial commit,\n # 2026-06-23) and BEFORE this map did — so a snapshot from that window carries none of these keys\n # and, without an entry here, resumed with today's paid product default ACTIVE. That is precisely\n # the \"silently add paid calls ... to an old run\" the paragraph above forbids: the resumed half of\n # the run would make predict-before-execute and cross-run LLM calls the original never made, and\n # admit candidates under a different novelty policy.\n # `setdefault` makes every one of these a no-op for a snapshot that DOES carry the key, so only\n # genuinely older runs are affected. Each value is the behaviour before the field existed: absent\n # feature = off, absent cadence = 0 (the same \"conservative library\" value the frozen\n # Settings-vs-EngineOptions divergence table records for these fields). Each was dated against the\n # tree before being listed — e.g. `report_every` (2026-06-25) is 0 because the run-report writer\n # itself did not exist before that commit, so an older run wrote no report to preserve.\n \"foresight\": False,\n \"foresight_agentic\": False,\n \"foresight_verify\": False,\n \"concept_pivot\": False,\n \"graded_novelty\": False,\n \"cross_run_concepts\": False,\n \"cross_run_advisory\": False,\n \"cross_run_structured_claims\": False,\n \"cross_run_curation\": False,\n \"cross_run_read_tools\": False,\n \"concept_run_base\": False,\n \"fingerprint_universal\": False,\n \"memora_llm\": False,\n \"comparative_lessons\": False,\n \"lessons_every\": 0,\n \"lessons_refresh_every\": 0,\n \"unified_agent\": False,\n \"deep_research_every\": 0,\n \"report_every\": 0,\n \"failure_reflection\": False,\n \"reflection_priors\": False,\n \"agent_drives_actions\": False,\n \"concurrent_research\": False,\n \"deep_repair\": False,\n # CLAUDE REVIEW: [QUALITY] possible gap — `merge_mode` is absent. options.py's docstring names it as a\n # product-vs-library divergence in the SAME breath as report_every (\"merge_mode='auto', report_every=3\"),\n # and report_every IS in this map. Settings defaults \"auto\", which resolves to \"ensemble\" for a\n # code-generating developer (orchestrator.__init__ L715) — a PAID code-recombination merge producing a\n # DIFFERENT merged solution than the frozen legacy default \"mean\" (EngineOptions/orchestrator._DEFAULTS).\n # So a pre-field snapshot resumed on an LLM backend silently gains ensemble merges it never did — the\n # exact \"add paid calls / a different selection policy to an old run\" this map guards against, with a\n # pointable before-value (\"mean\"). Add `\"merge_mode\": \"mean\"` here IF merge_mode postdates 2026-06-23\n # (unverifiable in this squashed history — confirm against the real commit before acting).\n # WHAT THIS MAP IS NOT. It is a hand-maintained list of FEATURE switches whose before-the-field\n # value is unambiguous, not a complete partition of `Settings`. Two classes stay out on purpose,\n # because for them a wrong entry is worse than a missing one — it would silently REMOVE behaviour\n # the original run really had:\n # * knobs that are latent while their parent feature is off — `foresight_panel`,\n # `foresight_verify_samples`, `memora_anchors`, `holdout_top_k`, `asha_eta`: the parent above\n # already pins them out of play, and their own before-the-field value is not recoverable;\n # * magnitudes and depths for behaviour that ALREADY existed — `debug_depth`,\n # `context_budget_chars`, `developer_plan_max_steps`, `agent_emit_after`: guessing 0/off there\n # disables working machinery rather than preserving history.\n # A new field belongs here only when it (a) postdates 2026-06-23, (b) defaults to adding paid\n # calls / interventions / concurrency / a different selection policy, and (c) has a historical\n # value you can point at a commit for. When (c) fails, leave it out and say so here.\n}\n\n\ndef migrate_config_snapshot(data: dict) -> dict:\n \"\"\"Return an effective copy of one legacy/current snapshot without rewriting evidence bytes.\"\"\"\n if not isinstance(data, dict):\n raise TypeError(\"config snapshot must be a JSON object\")\n migrated = dict(data)\n for key, value in LEGACY_CONFIG_SNAPSHOT_DEFAULTS.items():\n migrated.setdefault(key, value)\n return migrated\n\n\ndef config_snapshot_schema(data: dict) -> int:\n \"\"\"The snapshot's declared format version. 0 = pre-versioning (the key did not exist yet).\n\n A malformed value is treated as UNREADABLE rather than as 0: a snapshot whose own version marker\n is corrupt cannot be assumed to be the oldest format.\"\"\"\n raw = data.get(CONFIG_SNAPSHOT_SCHEMA_KEY, 0) if isinstance(data, dict) else 0\n if isinstance(raw, bool) or not isinstance(raw, int) or raw < 0:\n raise ConfigSnapshotVersionError(\n f\"config snapshot has a malformed {CONFIG_SNAPSHOT_SCHEMA_KEY}: {raw!r}\")\n return raw\n\n\ndef settings_from_snapshot(data: dict) -> Settings:\n \"\"\"Build re-entry Settings from a masked snapshot under its historical missing-field contract.\n\n Fails closed on a snapshot written by a NEWER binary. `Settings` is `extra=\"ignore\"`, so such a\n snapshot would otherwise load with every unrecognized control silently dropped — the run would\n resume on the same event history under different paid/concurrency/selection semantics, with\n nothing to show why. Refusing is the only honest answer: this binary cannot know what it is\n dropping. Older and pre-versioning snapshots keep loading exactly as before.\"\"\"\n found = config_snapshot_schema(data)\n if found > CONFIG_SNAPSHOT_SCHEMA:\n raise ConfigSnapshotVersionError(\n f\"this run's config.snapshot.json declares format v{found}, but this build understands \"\n f\"at most v{CONFIG_SNAPSHOT_SCHEMA}. It was written by a newer LoopLab; resuming here \"\n \"would silently drop settings this build does not know. Upgrade LoopLab to resume it.\")\n migrated = migrate_config_snapshot(data)\n migrated.pop(\"llm_api_key\", None)\n migrated.pop(CONFIG_SNAPSHOT_SCHEMA_KEY, None) # a document marker, never a Settings field\n return Settings(**migrated)\n"}, "25bd6df117a09aa74b94420275ba85bb9ecbb1ae": {"t": 1785768736.3624148, "n": 116668, "h": "29da7f361f6c20c2", "c": "\"\"\"Engine config: the schema (what settings exist). (I0, ADR-11)\n\nOne pydantic-settings class declaring every engine knob, its default, and its docs. The loader —\nhow a run file / CLI / env becomes a `Settings` — is `looplab.core.appconfig`, which also owns\nthe config-precedence order. A resolved, secret-masked snapshot is written next to each run for\nreproducibility. (No real secrets in P0, but the masking discipline is in place.)\n\"\"\"\nfrom __future__ import annotations\n\nimport re\nimport types\nimport typing\nfrom pathlib import Path\n\nfrom pydantic import Field, SecretStr, field_validator, model_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n# Single source of the LLM first-byte (response-headers) default — see core/llm.py.\nfrom looplab.core.llm import DEFAULT_HEADER_TIMEOUT_S\n\n# Default home for cross-run memory + the knowledge base (shared across every run). Both are ON by\n# default now (a user asked for it) so a fresh install accumulates + consults knowledge with no setup;\n# set the env var to \"\" to disable, or to a path to relocate. `~/.looplab/` keeps them out of any one\n# project + shared across projects.\n_LL_HOME = Path.home() / \".looplab\"\n\n# What one `llm_profiles` entry may contain. Deliberately closed: an unrecognized field is a typo the\n# operator would otherwise never see fail, and the map is a public artifact (config.snapshot.json,\n# HTTP, LOOPLAB_*), so it must never accrete free-form keys.\n_PROFILE_FIELDS = frozenset({\"model\", \"base_url\", \"temperature\", \"api_key_env\"})\n# A variable name that `runtime/sandbox.py::SECRET_ENV` will recognize as holding a secret and strip\n# from generated code's environment. Duplicated here, not imported: layering forbids `core` from\n# importing `runtime`. `tests/test_secret_env_pattern.py` holds the two in agreement.\n_SECRET_ENV_NAME = re.compile(\n r\"\\A(?=[A-Z][A-Z0-9_]{0,63}\\Z)(?=.*(KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL)).*\\Z\")\n\n\ndef _is_numeric_annotation(ann) -> bool:\n \"\"\"True when a field's annotation admits int/float but NOT bool.\n\n Used by `Settings._reject_bool_for_numeric_fields`: pydantic resolves annotations at class build,\n so `model_fields[...].annotation` is a real type object here even under\n `from __future__ import annotations`. Optionals are numeric when every non-None member is\n (`int | None` yes, `int | str` no), so a boolean can never slip in through a union arm.\"\"\"\n if ann is bool:\n return False # a bool field legitimately takes a bool\n if ann is int or ann is float:\n return True\n args = [a for a in typing.get_args(ann) if a is not type(None)]\n origin = typing.get_origin(ann)\n if args and origin in (typing.Union, types.UnionType):\n return all(_is_numeric_annotation(a) for a in args)\n return False\n\n# Autonomous roles that may be granted per-setting write access (see Settings.agent_control).\n# strategist = A7 meta-controller (run-wide); boss = run-chat operator-proxy (run-wide);\n# researcher = per-experiment proposer (per-node sizing, e.g. eval timeout for a heavy model).\nAGENT_ROLES = (\"strategist\", \"boss\", \"researcher\")\n\n# A foresight score is computed for every acted-on proposal. Keep this lower than the generic\n# verifier's emergency ceiling so a typo cannot multiply paid synchronous calls across a run.\nMAX_FORESIGHT_VERIFY_SAMPLES = 8\n\n\n# Layer-2 parallelism renamed two public settings without removing the durable legacy fields. Keep\n# the equivalence in one place: config-source precedence, governance migration, and live strategy\n# application must all agree which names describe the same authority/value.\nPARALLELISM_ALIASES: dict[str, str] = {\n \"max_parallel\": \"eval_parallel\",\n \"parallel_build\": \"llm_parallel\",\n}\n\n\ndef parallelism_aliases(setting: str) -> tuple[str, ...]:\n \"\"\"Return every spelling in ``setting``'s alias family, canonical name first.\"\"\"\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if setting in (legacy, canonical):\n return canonical, legacy\n return (setting,)\n\n\ndef canonicalize_parallelism_source(\n values: dict, *, promote_build_to_llm_parallel: bool = False,\n) -> dict:\n \"\"\"Promote legacy parallel keys *within one config-precedence source*.\n\n Applying this only after file/CLI/env were flattened would let a lower-priority canonical env\n value shadow a higher-priority legacy file value (or a file canonical value shadow a CLI legacy\n override), so callers with sub-layers normalize each layer independently.\n\n ``max_parallel``/``eval_parallel`` are true aliases (pure eval width, no extra semantics) and are\n always promoted. ``parallel_build``/``llm_parallel`` are NOT: a positive ``llm_parallel`` also flips\n on the finite shared LLM broker (``orchestrator._startup_llm_total``), while legacy ``parallel_build``\n means \"legacy build width, unbounded overlap, no shared total\". So promoting a legacy-only\n ``parallel_build`` layer (e.g. just ``LOOPLAB_PARALLEL_BUILD=1`` in env) would silently serialize\n every provider call — contradicting llm_broker.py's docstring, orchestrator's \"legacy-only preserves\n unbounded overlap\" note, the broker-neutral live-control path, and the configuration.md ``llm_parallel``\n row (\"falls back ... without enabling a shared total\"). Config/startup loads therefore keep broker\n opt-in tied to an EXPLICITLY-spelled canonical ``llm_parallel``; the orchestrator's own width fallback\n (``llm_parallel if not None else parallel_build``) still carries the legacy build width. Only the\n Strategist live-control path (``canonicalize_strategy_parallelism``) opts into\n ``promote_build_to_llm_parallel``, where ``parallel_build`` is a deliberate full alias of the\n canonical knob (see strategy.py ``_apply_strategy``).\n \"\"\"\n if not isinstance(values, dict):\n return values\n out = dict(values)\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if canonical == \"llm_parallel\" and not promote_build_to_llm_parallel:\n continue\n # None is the durable legacy mode for llm_parallel, not a missing value. A normal snapshot\n # contains llm_parallel:null plus parallel_build:1; promoting null to 1 would unexpectedly enable\n # the global one-call broker. Promote aliases only when the canonical key is absent, and pin\n # snapshot round-trip parity. Canonicalize inside each precedence layer, never across the\n # flattened result, so the alias migration cannot invert CLI > file > env precedence.\n if canonical not in out and out.get(legacy) is not None:\n out[canonical] = out[legacy]\n return out\n\n\ndef flatten_parallelism_layers(layers) -> dict:\n \"\"\"Flatten same-tier precedence layers (LOWEST priority first) and resolve the legacy pair.\n\n PRECEDENCE REPAIR for the non-promoted `parallel_build`/`llm_parallel` pair. Declining to promote\n in `canonicalize_parallelism_source` is right (a legacy-only layer must not flip on the shared\n broker), but it let a LOWER-priority canonical value survive a HIGHER-priority legacy override:\n file `llm_parallel: 2` + CLI `-s parallel_build=8` flattened to BOTH keys, and the orchestrator\n prefers `llm_parallel`, so the operator's explicit CLI width was silently discarded. When a higher\n layer names the legacy width over a lower layer's canonical one, mask with the LEGACY SENTINEL\n (`llm_parallel=None` is the durable \"legacy mode — build width from parallel_build, no shared\n total\"), so the higher layer wins with the semantics it asked for.\n\n The mask lives HERE, not in the per-layer canonicalizer, because only a flatten knows the layer\n ORDER. Stamping it per layer meant any layer naming `parallel_build` emitted `llm_parallel=None`\n even when NO layer in the tier set the canonical key — and since pydantic-settings ranks init\n kwargs above env, that sentinel then silently overrode a `LOOPLAB_LLM_PARALLEL` the operator had\n exported as a deliberate provider rate/cost ceiling. Precedence is per FIELD: a tier that never\n spelled `llm_parallel` has not set it, and it must fall through to env/.env/defaults — which is\n exactly what `build_settings`' docstring promises (\"the merged dict wins only where it actually\n sets a value\").\n \"\"\"\n merged: dict = {}\n legacy_at = canonical_at = -1\n for index, layer in enumerate(layers):\n if not isinstance(layer, dict):\n continue\n merged.update(layer)\n if layer.get(\"parallel_build\") is not None:\n legacy_at = index\n if \"llm_parallel\" in layer:\n canonical_at = index\n # Only when this tier DID set the canonical key, at a strictly lower layer than the legacy one.\n if canonical_at >= 0 and legacy_at > canonical_at:\n merged[\"llm_parallel\"] = None\n return merged\n\n# Settings whose semantics are committed by ``run_started`` and restored from the folded event log\n# on every re-entry. Changing one only in ``config.snapshot.json`` would mix incompatible evidence\n# (a different holdout split) or make the live producer disagree with replay's selection policy.\n# The HTTP config editor and the engine both consume this one contract; keep deliberately mutable\n# controls such as ``trust_gate`` out (that field has its own durable change event).\nRUN_START_PINNED_FIELDS = frozenset({\n \"card_driven_selection\",\n \"speculation_depth\",\n \"holdout_fraction\",\n \"holdout_select\",\n \"select_verifier\",\n \"select_verifier_samples\",\n \"verifier_ci_tie\",\n})\n\n\ndef run_start_pinned_settings(state) -> dict:\n \"\"\"Return the effective Settings names committed by a folded run-start record.\n\n Pre-pin legacy logs have no recorded holdout fraction, so their snapshot remains authoritative\n for both holdout knobs. Verifier fields, however, have always been re-pinned from the legacy-safe\n fold defaults whenever a valid ``run_started`` identity exists.\n \"\"\"\n if not getattr(state, \"run_id\", \"\"):\n return {}\n values = {\n # Layer 3 changes which queue owns macro-action selection. Once a run starts, replay and\n # resume must keep the same owner even if the snapshot or ambient default changes later.\n \"card_driven_selection\": bool(getattr(state, \"card_driven_selection\", False)),\n # Layer 5 overlaps Card production with evaluation only when this pinned treatment is\n # positive. Old logs omit it and therefore retain the exact serial build/eval spine.\n \"speculation_depth\": int(getattr(state, \"speculation_depth\", 0)),\n \"select_verifier\": bool(getattr(state, \"select_verifier_tiebreak\", False)),\n \"select_verifier_samples\": int(getattr(state, \"select_verifier_samples\", 3)),\n \"verifier_ci_tie\": bool(getattr(state, \"verifier_ci_tie\", False)),\n }\n holdout_fraction = getattr(state, \"holdout_fraction\", None)\n if holdout_fraction is not None:\n values.update({\n \"holdout_fraction\": holdout_fraction,\n \"holdout_select\": bool(getattr(state, \"holdout_select\", False)),\n })\n if not values.keys() <= RUN_START_PINNED_FIELDS:\n raise RuntimeError(\"folded run-start pinned settings contract drifted\")\n return values\n\n# --- Run profiles (config-first presets) -------------------------------------------------------\n# A `profile` is a NAMED BUNDLE of setting defaults — nothing more. It only fills fields the user\n# did NOT set explicitly (via file / --set / LOOPLAB_* env / init-kwargs), so any explicit knob\n# always wins (see `_apply_profile`). Most engine intelligence lives behind individual flags. The\n# PART IV/V cross-run + concept-graph machinery (concept_pivot, graded_novelty, cross_run_concepts /\n# _advisory / _structured_claims / _curation / _read_tools, fingerprint_universal, foresight_verify)\n# now ships ON in the product `Settings` default. That is an explicit experimental product choice,\n# not a claim that a frozen A/B or workload-wide cost/quality gate has cleared: the bundle can steer\n# prompts and candidate generation, change graded-novelty admission / pre-execution choices, and add\n# paid LLM work when its client and memory prerequisites are present. The bare-library `EngineOptions`\n# default stays lean (see tests/test_options_divergence.py — product side is the aggressive one) so a\n# toy `Engine(...)` in a test doesn't fire cross-run LLM work unasked. The trust/confirm/ablate QUALITY\n# bundle still ships OFF and is turned on by `thorough`. Every value here is reachable by hand — the\n# profile is a convenience, never a hidden mode.\n# keep this rationale behavioral and evidence-based; do not relabel read-only storage\n# access or proposal-only governance as a no-op for agent behavior, cost, or proposal admission.\n#\n# `thorough` deliberately touches only QUALITY/TRUST machinery, not spend: it does NOT raise\n# max_nodes / eval_parallel (those are cost knobs the user owns). It enables multi-seed confirmation\n# (demote seed-lucky leaders), the reward-hack / leakage / critic monitors AND flips them from\n# advisory to *gating* (`trust_gate=\"gate\"` — a\n# flagged win can no longer be selected as best), ablation-driven refinement, and the prompt cues\n# (complexity / budget / failure-reflection / watchdog-reflection). Agentic novelty and the experimental\n# Part IV/V bundle are already product defaults, not profile changes. Effect is large; marginal cost is a\n# few extra evals.\nPROFILES: dict[str, dict] = {\n \"default\": {}, # explicit no-op alias for \"ship-as-is\" (== omitting profile)\n \"fast\": {}, # today's lean defaults, named for symmetry with `thorough`\n \"thorough\": {\n \"confirm_top_k\": 3,\n \"confirm_seeds\": 3,\n # novelty_gate intentionally NOT enabled here: duplicate-detection is the agentic Researcher's\n # job (it reads past experiments + find_analogous_across_runs and DECIDES), not an algorithmic\n # param-distance / embedding auto-reject that mis-fired (it nudged a good fresh idea onto a\n # twice-dead lineage — live node 61). Search stays as tools; the LLM judges novelty.\n \"operator_bandit\": True, # P4: adaptive operator mix over folded yields (GreedyTree)\n \"reward_hack_detect\": True,\n \"code_leakage_detect\": True,\n \"critic_check\": True,\n \"trust_gate\": \"gate\", # detectors stop gating a flagged node from WINNING (not just audit)\n \"ablate_every\": 3,\n \"complexity_cue\": True,\n \"budget_aware\": True,\n \"failure_reflection\": True,\n \"watchdog_reflection\": True, # feed recent live-watchdog (train-monitor/ASHA) flags to proposals\n \"reflection_priors\": True, # no-op unless memory_dir is set (cross-run priors)\n },\n}\n\n\n# The shipped agent-governance matrix (who may change which setting at runtime). Defined as a module\n# constant so the Engine's direct-construction default matches this shipped default exactly (the\n# EngineOptions \"Engine() == shipped defaults\" invariant) instead of diverging to an all-locked map.\nDEFAULT_AGENT_CONTROL: dict[str, list[str]] = {\n \"timeout\": [\"researcher\", \"strategist\"],\n # Layer-2 canonical names are the presented/default governance surface. `_agent_may` falls back\n # to an old snapshot's legacy grant only when its canonical counterpart is absent, so removing\n # duplicate defaults preserves resume authority while an explicit canonical revocation still wins.\n \"eval_parallel\": [\"strategist\"],\n \"llm_parallel\": [\"strategist\"],\n # Strategy-only pseudo-setting: the closed per-lane allocation inside the canonical LLM total.\n # It is not a launch Settings field; operators pin it through `set_strategy` and the Strategist\n # may reallocate it only while this grant is present.\n \"llm_lane_limits\": [\"strategist\"],\n \"max_nodes\": [\"strategist\", \"boss\"],\n \"max_eval_seconds\": [\"strategist\", \"boss\"],\n \"policy\": [\"strategist\"],\n # The policy NAME and its PARAMS are gated independently in `_apply_strategy`; grant both by default\n # so the Strategist that may switch to (say) MCTS may also apply the `c`/`eta` it decided — else its\n # tuned params are silently dropped and the recorded `active_strategy` diverges from the live engine.\n \"policy_params\": [\"strategist\"],\n \"n_seeds\": [\"strategist\"],\n \"ablate_every\": [\"strategist\"],\n \"merge_mode\": [\"strategist\"],\n \"complexity_cue\": [\"strategist\"],\n \"ablate_code_blocks\": [\"strategist\"],\n \"prefer_sweep\": [\"strategist\"],\n \"novelty_stance\": [\"strategist\"],\n \"developer\": [\"strategist\"],\n \"fidelity\": [\"strategist\", \"researcher\"],\n}\n\n\ndef default_agent_control() -> dict[str, list[str]]:\n \"\"\"A FRESH deep-ish copy of the shipped governance matrix (each role list copied so a caller can\n mutate its own map without touching the module constant). The SINGLE constructor of the default,\n called by both `Settings.agent_control`'s default_factory AND the Engine's direct-construction\n default — so \"Engine() == shipped defaults\" can't drift if the copy depth ever needs to change\n (e.g. nested values requiring copy.deepcopy), instead of two verbatim copy expressions.\"\"\"\n return {k: list(v) for k, v in DEFAULT_AGENT_CONTROL.items()}\n\n\nclass Settings(BaseSettings):\n \"\"\"The engine settings schema (every knob a run accepts).\n\n Timeout family — six distinct knobs, each owned by a different subsystem:\n - `timeout`: per-eval wall-clock budget for ONE experiment's evaluation (engine/eval).\n - `max_eval_timeout`: hard ceiling for a governed per-node Researcher timeout override.\n - `llm_timeout`: LLM request idle timeout — inter-token stall limit in stream mode\n (core.llm OpenAICompatibleClient).\n - `llm_header_timeout`: LLM first-byte (response-headers) window for stream attempts (core.llm).\n - `agent_time_budget_s`: wall-clock ceiling across a tool-using agent loop's turns (agents).\n - `dep_install_timeout`: per-package budget for auto-installing a missing dep (env self-prep).\n\n Config-source precedence is owned by `looplab.core.appconfig` (the loader) — see its module\n docstring for the one canonical order.\n \"\"\"\n # Config precedence: see appconfig.py — the loader owns the full order. The `.env` is read\n # so `looplab run`/`looplab ui` pick up LOOPLAB_LLM_BASE_URL etc. without exporting them by hand\n # (keys MUST carry the LOOPLAB_ prefix, same as the env vars). utf-8-sig tolerates a BOM from\n # Windows editors. The test suite disables this (tests/conftest.py) so a dev's real .env in the\n # repo root can't leak into assertions.\n model_config = SettingsConfigDict(\n env_prefix=\"LOOPLAB_\",\n env_file=\".env\",\n env_file_encoding=\"utf-8-sig\",\n extra=\"ignore\",\n allow_inf_nan=False,\n )\n\n @classmethod\n def settings_customise_sources(\n cls, settings_cls, init_settings, env_settings, dotenv_settings,\n file_secret_settings):\n \"\"\"Preserve source precedence across canonical/legacy parallelism names.\n\n Pydantic ranks init > env > dotenv correctly, but it ranks field names rather than semantic\n alias families. Promoting a legacy value inside each source before the merge makes an old\n explicit snapshot/file value beat a lower-priority canonical env value. ``appconfig`` also\n promotes its file/typed/--set sub-layers before combining them into the init source.\n \"\"\"\n def _with_parallel_aliases(source):\n return lambda: canonicalize_parallelism_source(source())\n\n return (\n _with_parallel_aliases(init_settings),\n _with_parallel_aliases(env_settings),\n _with_parallel_aliases(dotenv_settings),\n _with_parallel_aliases(file_secret_settings),\n )\n\n # Run profile (config-first preset, see PROFILES above). \"default\"/\"fast\" = today's lean\n # defaults; \"thorough\" turns the built quality/trust machinery ON in one word. A profile only\n # fills fields the user did NOT set — every knob it touches stays individually overridable.\n profile: str = \"default\"\n\n # Bounds (review C/⚪): a non-positive node/seed budget or timeout is never valid. Upper bounds are\n # equally load-bearing — the UI /start path writes these straight into the engine's env, and an\n # unbounded `eval_parallel` (or legacy `max_parallel`; e.g. 100000 from a crafted preflight) would\n # fan out that many sandboxes → resource exhaustion. Reject at config time, not mid-run. Ceilings\n # are generous; tests top out near 50.\n # Pydantic's lax mode coerces a JSON boolean into any numeric field (`true` -> 1), so a UI/API type\n # error used to silently collapse a run budget to ONE node instead of returning 422. Rejected for\n # EVERY numeric field by `_reject_bool_for_numeric_fields` below, not just the ones bounded here.\n n_seeds: int = Field(default=3, ge=1, le=1024)\n max_nodes: int = Field(default=8, ge=1, le=1_000_000)\n # legacy evaluation-width input. `eval_parallel` below is canonical and wins when set;\n # keep this field for old env/config/snapshots and direct Engine call sites. A width > 1 admits\n # concurrent evals, whose resource reservations may be CPU-only or span one/more GPUs — there is no\n # one-node/one-GPU promise. `max_parallel=0` is launch-time AUTO (detected GPU count, at least 1).\n max_parallel: int = Field(default=1, ge=0, le=1024)\n # legacy build-width input. `llm_parallel` below is canonical and also controls the\n # shared provider-call total when positive. A width > 1 uses fresh pooled researcher/developer pairs;\n # it says nothing about the later eval's CPU/GPU reservation. Startup `0` derives build fan-out from\n # resolved eval width; a live Strategist/operator zero settles to 1. Without role_factory it clamps to 1.\n parallel_build: int = Field(default=1, ge=0, le=64)\n # Hypothesis-card Kanban re-architecture (docs/23, Layer 2). Two INDEPENDENT concurrency axes,\n # decoupled from each other so cost (LLM work) can be tuned separately from throughput (GPU evals).\n # These are the CANONICAL names going forward; `max_parallel`/`parallel_build` above are kept as thin\n # back-compat aliases (env vars / snapshots / ~100 Engine(...) call sites depend on the exact names,\n # so they cannot be renamed — CLAUDE.md). Resolution (orchestrator.__init__): a NEW name, when set,\n # WINS over its legacy alias; None => fall back to the legacy field => byte-identical to today.\n # `eval_parallel`: concurrent EVALS (the GPU/experiment consumer). None -> max_parallel; 0 = AUTO\n # (one experiment per detected GPU). This is the in-Run eval-concurrency ceiling; the resource\n # scheduler bin-packs declared footprints separately. Sibling local GPU-owning Runs serialize\n # through the conservative host-pool lease; external execution needs its own admission boundary.\n # `llm_parallel`: total concurrent LLM provider calls + BUILD fan-out. None -> legacy\n # parallel_build semantics without a shared total; 0 = startup AUTO (= resolved eval_parallel\n # for build fan-out, historical research overlap unbounded). A positive canonical value enables\n # the named-lane broker the Strategist can reallocate at cadence.\n eval_parallel: int | None = Field(default=None, ge=0, le=1024)\n llm_parallel: int | None = Field(default=None, ge=0, le=64)\n # Training-log monitor (I-series watchdog family): a per-eval background observer that tails the live\n # training log while a (often multi-hour) declared stage runs in a worker thread. ON in the product\n # surface (Settings). Its alert event is fold-ignored and cannot directly change lifecycle/champion;\n # with watchdog_reflection on, that alert is nevertheless advice in a later Researcher\n # prompt and can change future proposals. It only fires on the command-eval path with an LLM client;\n # bare-library EngineOptions stays OFF. See test_options_divergence.\n # `_interval_s` is the BASE tick cadence (the effective one adapts to the per-experiment budget).\n train_monitor: bool = True\n train_monitor_interval_s: float = Field(default=600.0, gt=0)\n # Phase 3 — INTERVENTION (opt-in, separate from observation): let the monitor tree-kill a training the\n # LLM judges 'broken' (diverged / silent CPU fallback / not learning) EARLY, instead of burning the\n # whole budget. Off by default — the observer only WATCHES unless this is on. A kill only fires on a\n # 'broken' verdict (a plateau is 'watch', never killed) with confidence >= the threshold; the node then\n # fails normally (reason='monitor_broken'), so replay reconstructs it from that one terminal event.\n train_monitor_kill: bool = False\n train_monitor_kill_confidence: float = Field(default=0.8, ge=0.0, le=1.0)\n # ASHA live-curve watchdog (sibling of the training monitor): reads the latest INTERMEDIATE value of\n # the objective metric off the live log (reusing the eval's OWN metric reader). Finished-endpoint rank\n # remains advisory. An opt-in KILL additionally requires an operator-declared metric.resource_key and\n # enough sibling observations at exactly that resource; absent comparable evidence cannot stop a run.\n # Records a fold-ignored `asha_rank` diagnostic + trace span; the library default is OFF (off == today).\n asha_live: bool = True\n # Opt-in INTERVENTION (separate from the advisory signal): let the watchdog tree-kill a node whose\n # intermediate metric stays below the SAME-RESOURCE bar past a short grace window. Off by default —\n # it only surfaces endpoint rank unless this is on. A kill fails normally (asha_underperforming).\n asha_live_kill: bool = False\n # The bar sits at `asha_live_quantile` along a WORST->BEST ordering of the finished siblings' finals:\n # 0.5 = the median (a node worse than the median peer flags); a SMALLER value is more conservative —\n # it lowers the bar toward the WORST finished peer, so only a node worse than nearly all peers flags\n # (0.0 = worse than the worst). `min_siblings` is the floor of finished peers required before it ranks\n # at all, so it never acts on too little evidence.\n asha_live_quantile: float = Field(default=0.5, ge=0.0, le=1.0)\n asha_live_min_siblings: int = Field(default=3, ge=1)\n timeout: float = Field(default=30.0, gt=0)\n # Hard ceiling for the Researcher's governed per-node `Idea.eval_timeout`. One hour preserves the\n # existing heavy/neural-net authoring example while staying well below the sandbox's defensive\n # 24-hour subprocess ceiling. This is operator-owned run config: agents may request less or more,\n # but the accepted action is always clamped here after the `agent_control.timeout` permission gate.\n max_eval_timeout: float = Field(default=3600.0, gt=0, le=24 * 3600.0)\n # Intra-node sweep: a sweep node runs a whole grid in one process, so it gets this multiple of\n # `timeout` as its wall-clock budget (solution.py path; RepoTasks use their per-profile timeout).\n sweep_timeout_mult: float = Field(default=8.0, ge=1)\n # Eval STALL watchdog CAP (seconds): a stage that is completely SILENT on stdout/stderr for this\n # long — while still alive and BELOW its wall-clock deadline — is tree-killed early with a STALLED\n # marker (a hung dataloader/deadlock dies in minutes instead of burning a multi-hour timeout). The\n # per-stage window is `min(this, the stage's own timeout)`, so a short scorer's deadline fires first.\n # `0` DISABLES the watchdog entirely (only the hard deadline applies) — for a legitimately quiet\n # non-Python stage (block-buffered stdout, a script that logs only to its own file). Threaded into\n # the eval and surfaced to the Developer so its code can emit periodic progress to stay alive.\n eval_stall_timeout_s: float = Field(default=1800.0, ge=0)\n # Sandbox tier (ADR-13): \"trusted_local\" (subprocess, no Docker) for the CLI;\n # \"untrusted\" (Docker --network none, shared-kernel runtime) for hosted/multi-tenant UI;\n # \"hostile\" (untrusted + a true-isolation OCI runtime, gVisor `runsc` by default / Kata) for\n # running untrusted code. See runtime/sandbox.py::make_sandbox for the tier selection.\n trust_mode: str = \"trusted_local\"\n # Image for the untrusted command-eval tier (RepoTask, Phase 4): the framework's deps\n # should be baked in (the container runs --network none, so a pip setup can't fetch).\n docker_image: str = \"python:3.12-slim\"\n # Resource caps for the untrusted/hostile Docker tier (passed to `docker run --memory/--cpus`).\n # The default 4g bound protects other tenants from an OOM, but a model-training MLE-bench eval\n # routinely needs more — raise it (e.g. \"16g\") for such runs. \"\" disables the cap (unbounded).\n # Ignored by the trusted_local (subprocess) tier.\n sandbox_memory: str = \"4g\"\n sandbox_cpus: str = \"\"\n # Best-effort host-OOM guard for the TRUSTED-LOCAL (subprocess) tier. When set (e.g. \"8g\"), each\n # eval child gets an RLIMIT_AS address-space cap so a runaway trainer hits MemoryError instead of\n # OOM-killing the whole host (and the engine). POSIX only; a no-op on Windows. Default \"\" = OFF:\n # RLIMIT_AS bounds VIRTUAL memory, and CUDA/torch reserve tens of GB of virtual without using it,\n # so a default cap would spuriously kill GPU evals — leave it off for those and use the Docker\n # tier's real --memory cgroup bound (sandbox_memory) instead. Ideal for CPU/numpy/sklearn runs.\n sandbox_memory_local: str = \"\"\n # Best-effort disk-fill guard for the TRUSTED-LOCAL tier (P1-5): an RLIMIT_FSIZE cap on the size of\n # any single file an eval child writes (e.g. \"2g\"), so a runaway that writes an unbounded file gets\n # SIGXFSZ instead of filling the host disk. POSIX only. Default \"\" = OFF (large model checkpoints\n # need big files); set it for tasks that write only small artifacts.\n sandbox_fsize_local: str = \"\"\n # Search policy (ADR-2): \"greedy\" | \"evolutionary\" | \"mcts\" | \"asha\".\n policy: str = \"greedy\"\n # Ablation-driven refinement (I7): every N improves, ablate the best to find the\n # highest-impact parameter and refine it. 0 = off. (GreedyTree only.)\n ablate_every: int = 0\n # A0a (MLE-STAR): when ablating, treat each generated pipeline *code block* (not just a\n # numeric param) as an ablation unit — neutralize a block, measure the metric delta, refine\n # the highest-impact block. Off => the classic numeric-param ablation. Config-first knob.\n ablate_code_blocks: bool = False\n # A0b: real merge/ensembling. \"mean\" = legacy mean-param merge; \"ensemble\" = the Developer\n # writes a code-recombination ensemble over the two parents' solutions (verified: agent-proposed\n # ensembling 37.9%->43.9%). \"auto\" (default) = ensemble whenever the Developer actually\n # generates code (LLM/agent backends; declared via `is_code_generating`), mean for the\n # templated/toy developers where a code ensemble is meaningless — the evidence says code\n # recombination is the strongest single merge (removing it costs ~9 pp), so it is the default\n # wherever it can work. Falls back to mean when the Developer can't ensemble.\n merge_mode: str = \"auto\"\n # A0d (AIRA): inject a dynamic complexity hint into the draft/improve prompt keyed on the\n # node's child count (few children -> keep minimal; many -> escalate to ensembling/HPO).\n complexity_cue: bool = False\n # I1 (CAAFE-style) LLM feature-engineering: instruct the proposer/developer to add\n # semantically-meaningful engineered features (ratios, interactions, aggregations) as code. The\n # existing cross-validation eval + best-selection is the CV GATE — a feature set that doesn't\n # improve CV is never selected (feature engineering is non-universal, so this gate is mandatory).\n # Tabular tasks (those exposing column data). Off by default.\n feature_engineering: bool = False\n # A5: surface the REMAINING eval-compute budget into the proposal prompt so the Researcher can\n # reason over how much compute is left (explore broad while flush, exploit/cheapen when low).\n # No-op unless a max_eval_seconds budget is set.\n budget_aware: bool = False\n # A4 (LATS-style): feed a summary of the most recent FAILED branches (operator + error reason)\n # back into the proposal prompt so the proposer reflects on and avoids repeating them. ON by\n # default: it is SELECTIVE by construction (injects only when recent failures exist — the\n # evidenced form of reflection; reflecting at every step shows no gains, arXiv:2506.12928).\n failure_reflection: bool = True\n # Feed the LIVE-WATCHDOG observations (train-monitor health verdicts + ASHA intermediate-rank flags)\n # of recent experiments back into the proposal prompt, so the Researcher avoids re-proposing a config\n # whose TRAINING was already observed to be weak — even when the watchdog kills are off (default) and\n # the node ran to completion (those diagnostics are fold-ignored, so failure_reflection never sees\n # them). ON by default and SELECTIVE like failure_reflection (injects only when a recent flag exists).\n watchdog_reflection: bool = True\n # C3 deep test-driven repair: hand the Developer the failure taxonomy + a structured \"reproduce\n # then fix\" directive on debug, not just the raw stderr tail. ON by default (product surface); the\n # conservative library default in EngineOptions stays off, per that module's contract.\n deep_repair: bool = True\n # === Inline crash repair ==============================================================\n # Hybrid in-node crash repair: when an LLM-generated node CRASHES at runtime (mechanical errors\n # — bad import, removed kwarg, typo), the agent triages it and may repair the code IN PLACE within\n # the same eval (cheap; does NOT consume max_nodes and does NOT add a node to the search tree).\n # Semantic failures still flow to a new idea/debug node via the policy. ON by default; set False\n # to restore the prior behavior (every crash waits for a budgeted inter-node debug node).\n inline_repair: bool = True\n # Max in-place repair attempts per node before it fails normally. Default 0 = UNLIMITED: the same\n # agent keeps fixing + re-evaluating IN THE SAME node until it produces a clean metric — no new\n # node just to retry. Runaway is prevented not by a count but by the anti-stuck guard\n # (`inline_repair_stuck_repeat`): when the SAME error signature repeats with no progress, or the\n # agent's crash-triage says \"abandon\", the node fails. Set a positive N to cap the retries instead.\n inline_repair_attempts: int = Field(default=0, ge=0)\n # Anti-stuck: abandon in-node repair when the SAME error recurs this many times in a row (no\n # progress). Keeps \"unlimited\" repair from looping forever on an unfixable error.\n inline_repair_stuck_repeat: int = Field(default=4, ge=2)\n # Which failure reasons (_failure_reason: crash|timeout|oom|setup|no_metric|drift) are eligible for\n # inline repair. Default: mechanical crashes, timeouts AND OOM-kills — a timeout/OOM means the code\n # was too slow / too memory-hungry for the budget (or the pod's cgroup limit), not that the idea is\n # wrong, so the agent repairs it by reducing compute/memory (fewer estimators/epochs/folds/seeds, a\n # lighter model, a smaller batch, subsampling) instead of letting the node die unrecovered. Env\n # override expects a JSON array.\n inline_repair_reasons: tuple[str, ...] = (\"crash\", \"timeout\", \"oom\")\n # Cost ceiling for in-node repair of a MULTI-STAGE eval: the max number of FULL pipeline re-runs\n # (i.e. re-trains from the first stage) the inline-repair loop may do before abandoning to the\n # inter-node debug operator. A late-stage failure (a broken `score`/eval script that didn't touch\n # `train`) REUSES the completed train checkpoint and re-runs only from the failed stage — that is\n # CHEAP and NOT counted here; only a repair that changes an EARLIER stage's code (train.py/loss.py/…)\n # forces a full re-train, which each costs the whole training time. Without this bound, a repair that\n # keeps changing training code could burn many full trains (the anti-stuck guard is error-signature\n # based, not cost based). 0 = unlimited (legacy behavior). Single-command evals ignore it.\n inline_repair_retrain_cap: int = Field(default=2, ge=0)\n # Environment self-prep: when a solution crashes purely because a KNOWN library isn't installed\n # (ModuleNotFoundError), the engine pip-installs it into the eval interpreter and re-runs — so a\n # torch/XGBoost/CatBoost idea (e.g. a GRU model) actually runs on a fresh box instead of being\n # thrown away as `idea_rejected`. Trusted_local tier only (the untrusted/hostile Docker tiers run\n # --network none). A typo'd or local helper module is NOT installed (that's a code bug to repair).\n auto_install_deps: bool = True\n # Per-package wall-clock budget for an auto-install (seconds). Generous: large wheels like torch\n # can take minutes to download/build.\n dep_install_timeout: float = Field(default=900.0, gt=0)\n # --- Agent governance: who may change a setting at runtime (per-setting allow-list of roles) ---\n # Roles: \"strategist\" (A7 meta-controller; run-wide), \"boss\" (run-chat operator-proxy; run-wide),\n # \"researcher\" (per-experiment proposer; PER-NODE — e.g. set a longer eval timeout for a neural-net\n # idea). A setting ABSENT from this map is LOCKED — no agent may change it, only a human via the\n # snapshot/UI. The sole conditional exception is Strategy-only `card_scoring`: Card mode grants\n # it implicitly without changing the default flag-off snapshot, while an explicit [] revokes it.\n # The gate is ENFORCED at every AGENT seam via `_agent_may` — the strategist's whole\n # control surface (policy/operators/novelty_stance/prefer_sweep/developer/fidelity/timeout/\n # eval_parallel/llm_parallel) is checked in `_apply_strategy`, so removing a role from a knob locks it,\n # not just greys out the UI pill. The boss/max_* grants document boss-relevant knobs, but a\n # `budget_extend` is a HUMAN control intent (the boss action-builder can only emit `add_nodes`), so\n # it is applied as-is — the human always wins via the UI/snapshot. Defaults are conservative: resource/\n # search-shape knobs the agents already reason about; provider infra (llm_model/base_url/api_key,\n # docker_image) stays locked. The UI shows S/B/R pills per setting. `timeout` granted to the\n # researcher IS the \"auto\" per-node mode — the researcher sizes each experiment's wall-clock\n # (Idea.eval_timeout); the config `timeout` is the fallback for nodes it doesn't size. Env override\n # expects a JSON object. (`fidelity`/`novelty_stance`/`prefer_sweep`/`developer` are governance keys\n # for the strategist's per-node dials — not 1:1 Settings fields, but gated the same way.) The default\n # lives in the module-level DEFAULT_AGENT_CONTROL so the Engine's direct-construction default matches.\n agent_control: dict[str, list[str]] = Field(default_factory=default_agent_control)\n # C1 (Agentless localization): for repo tasks, rank the source files most relevant to the most\n # recent failure (traceback + identifiers) and surface them in the proposal/repair prompt so the\n # Developer edits the right place. Off by default; repo tasks only.\n localize_faults: bool = False\n # A2 surrogate-guided proposal (BO-lite): fit a k-NN surrogate over (params->metric) and propose\n # by acquisition instead of random/hill-climb. Numeric-bounds tasks only; bootstraps via the\n # base Researcher. Off by default. `surrogate_explore` = UCB-style exploration weight.\n surrogate_proposer: bool = False\n surrogate_explore: float = 0.1\n # E2 researcher panel: generate K candidate ideas per proposal and keep the one ranked best by a\n # cheap empirical surrogate over the (params->metric) history (NOT an LLM-judge). 1 = off.\n researcher_panel: int = 1\n # A1 ASHA / successive-halving: reduction factor (keep top 1/eta per rung) and rung budget.\n asha_eta: int = Field(default=3, ge=2)\n # Rung-0 width for ASHA/BOHB (the wide base of cheap drafts). 0 = use n_seeds (default,\n # preserving prior behavior); set >0 to seed a wider/narrower base independent of n_seeds.\n asha_rung_nodes: int = Field(default=0, ge=0)\n # A6 proxy/predictive scoring: cheaply rank a candidate's potential from early-stage signals\n # and skip a full eval for the doomed bottom fraction. 0.0 = off (no candidate skipped).\n proxy_scoring: bool = False\n proxy_kill_fraction: float = Field(default=0.0, ge=0.0, le=0.9)\n # === Novelty / dedup ==================================================================\n # E1 novelty/dedup gate: reject near-duplicate proposals (normalized param-space distance below\n # `novelty_epsilon`) by deterministically nudging them off the duplicate — stops the search\n # wasting evals re-trying the same idea. Audit event `novelty_rejected`. Off by default.\n # Novelty/dedup MODE selector: how a fresh proposal is checked against already-tried experiments.\n # \"off\" — no explicit gate; the agentic Researcher's own read-the-history judgment is trusted.\n # \"algo\" — the deterministic gate: numeric param-distance (`novelty_epsilon`) + optional embedding\n # similarity (`novelty_semantic`). Cheap, no LLM call, but can't explain itself.\n # \"llm\" — an LLM adjudicates (reads the real experiments via tools) whether the idea is a\n # near-duplicate and, if so, asks the Researcher once more for something different. One\n # extra LLM call per proposal — highest quality, follows the \"let the LLM decide\" line.\n novelty_mode: str = \"llm\"\n # Legacy sub-toggles, honored by the \"algo\" mode (and back-compat: novelty_gate=True forces \"algo\"):\n novelty_gate: bool = False\n novelty_epsilon: float = 0.05\n # T5 semantic novelty (active whenever the deterministic gate runs — novelty_mode=algo,\n # novelty_gate=true which is the legacy alias for algo, OR the Strategist novelty stance is\n # \"explore\"; a no-op only under novelty_mode=llm/off with a non-explore stance): reject a proposal\n # whose idea TEXT embeds within\n # `novelty_semantic_threshold` cosine of an existing node's. OFF BY DEFAULT: novelty is the agentic\n # Researcher's call, made by READING the actual prior experiments (read_experiment /\n # find_analogous_across_runs / list_experiments), NOT an embedding-similarity auto-reject that can't\n # explain itself and mis-fires on paraphrases. Semantic/param search stays available as TOOLS in the\n # Researcher's context; the LLM decides whether an idea is a true duplicate.\n novelty_semantic: bool = False\n novelty_semantic_threshold: float = Field(default=0.92, ge=0.5, le=1.0)\n # T10: debug-lineage depth bound for every policy — how many error-feedback repairs a failing\n # lineage gets before it is abandoned. The old default (1) abandoned lineages after ONE repair;\n # multi-turn/deeper debugging is a verified lever (AIRA2 ReAct debug +5.5 percentile points).\n debug_depth: int = Field(default=2, ge=1)\n # P4 operator bandit (GreedyTree): replace the fixed merge/ablate cadences with a\n # deterministic UCB over per-operator yield (Δmetric per eval-second, folded from the run).\n # Off by default (the cadences are well-tested and the bandit has no direct published\n # ablation); `thorough` turns it on.\n operator_bandit: bool = False\n # P1 hypothesis ledger: ask the Researcher to state the one-line hypothesis each experiment tests,\n # register deep-research directions as hypotheses, and track them to a verdict on the board. ON by\n # default. The board is shown to the Researcher and can be foresight-ordered, so it can\n # steer later proposals even though it never directly re-ranks already-evaluated nodes. Set False\n # to drop the prompt nudge + registration.\n track_hypotheses: bool = True\n # E4/M2/M3 reflection-memory -> priors (gradient-free cross-run meta-learning): at run end distill\n # the winner into `<memory_dir>/meta_notes.jsonl` AND structured lessons (incl. NEGATIVE results:\n # tested/abandoned hypotheses + failure themes) into `lessons.jsonl` with a task fingerprint; at run\n # start, inject exact-task notes + fingerprint-matched lessons from SIMILAR tasks into the proposal\n # prompt. ON by default — but a NO-OP until `memory_dir` is set (that's where cross-run memory lives).\n reflection_priors: bool = True\n # M6 comparative lessons (MARS \"comparative reflective memory\", doc 13 §7 item 2): distill\n # credit-assigned lessons from PAIRS of solutions — which SPECIFIC change made a child beat\n # (or regress from) its parent, and what fixed a failure — on top of the one-shot run-end\n # reflection. At most one extra LLM call per distillation (all pairs batched); offline a\n # deterministic param-diff credit stands in. Gated on reflection_priors + memory_dir like the\n # rest of cross-run memory — and since memory_dir has a default (~/.looplab/memory), this is\n # ACTIVE out of the box; clear memory_dir to disable cross-run memory wholesale.\n comparative_lessons: bool = True\n # M6 mid-run cadences (doc 13 §7 item 5 — the AgentRxiv live-share pattern): every N created\n # nodes, WRITE comparative lessons to the shared cross-run store during the run (lessons_every\n # — one batched LLM call per firing) and RE-READ the store so lessons distilled by CONCURRENT\n # runs reach this run's proposals (lessons_refresh_every; file re-read, no LLM call, skipped\n # when the store is unchanged). ON by default (every 4 nodes) and, like comparative_lessons\n # above, live out of the box because memory_dir defaults on; 0 = off — run-end write /\n # run-start read only, the pre-M6 behavior.\n lessons_every: int = Field(default=4, ge=0)\n lessons_refresh_every: int = Field(default=4, ge=0)\n # B3 output redaction: mask credentials (known key shapes + high-entropy tokens) in the\n # stdout/stderr tail before it is persisted to the event log / spans / UI — a leaked secret in a\n # print()/traceback must not land in the durable log. Off by default; recommend on for untrusted.\n redact_output: bool = False\n # B5 reward-hacking detector: a host-side monitor that flags suspicious wins (grader/answer-key\n # access, runtime writes to frozen files, suspiciously-perfect metrics) as a `reward_hack_suspected`\n # audit event in the Trust panel. Off by default. Whether a flag CHANGES selection is governed by\n # `trust_gate` below (default: audit-only, never changes selection).\n reward_hack_detect: bool = False\n # Trust enforcement (T2): what a reward-hack / data-leakage flag actually DOES to selection.\n # \"audit\" (default) = surface it in the Trust panel, never change selection (today's behavior);\n # \"gate\" = a flagged node can no longer be selected as BEST **and isn't bred/confirmed from**\n # (breed_excluded — see replay._apply_trust_gate / models.breedable_nodes), but stays\n # FEASIBLE so it still counts for diversity/audit — closes the \"a hacked/leaky node can\n # win OR seed its lineage\" hole;\n # \"block\" = additionally mark it fully INFEASIBLE (removed from feasible_nodes entirely).\n # Deliberately gates only high-precision CHEATING/LEAKAGE signals. Broad `critic:*` findings stay\n # advisory; `critic:hardcoded_metric` is the one narrow high-precision critic exception\n # and gates because it requires a literal metric with no computed assignment anywhere.\n trust_gate: str = \"audit\"\n # I3 data-centric: static code-leakage scan of each evaluated solution (fit-before-split,\n # fit-on-test) surfaced into the Trust panel alongside reward-hack flags. Off by default.\n code_leakage_detect: bool = False\n # 4.4 sandbox instrumentation (needs reward_hack_detect): after each eval, flag a RUNTIME write\n # to a protected/frozen file (an answer key or grader tampered mid-run) — behavioral evidence a\n # static code scan misses. ON by default; only fires when the reward-hack detector is on.\n workdir_audit: bool = True\n # D8 decoupled research Verifier: check every Deep-Research memo's CLAIMS against their cited\n # evidence (node ids / urls) before recording — synthesis is the documented weak link (Kosmos\n # 57.9% accurate). Deterministic layer always; an LLM rubric pass when the DeepResearcher has a\n # client. The verdict cannot change this run's node/champion, but finalize uses aligned supported\n # verdicts as the evidence gate for persisted D8 cross-run claims; unavailable/misaligned stays\n # unverified. ON by default (no-op with no memo/claims).\n research_verify: bool = True\n # C4 independent critic: an execution-free critic of each solution (stub / hardcoded-metric /\n # params-ignored; on host-graded tasks the metric checks become a submission-output check)\n # surfaced in the Trust panel. Broad findings are advisory; `critic:hardcoded_metric` can gate under\n # trust_gate=gate/block. Off by default.\n critic_check: bool = False\n # A7 Strategist (NEW, user-requested): optional LLM/rule meta-controller that picks the search\n # policy/allocator + operator mix + fidelity (+ Developer backend) per situation. Config-first:\n # every choice the Strategist makes is also a direct knob. Defaults to \"agent\" so the agent adapts\n # the search policy/operators/fidelity per situation; set \"off\" for static config-driven search.\n # \"agent\" = a tool-using Strategist that READS the run/data/sibling-runs/KB/memory (B1-guarded)\n # before deciding, instead of the single-shot \"llm\" call over aggregate stats.\n strategist_backend: str = \"agent\" # \"off\" | \"rule\" | \"llm\" | \"agent\" — default AGENTIC: the\n # Strategist READS the run/data/sibling-runs/KB/memory with tools before deciding, not a single-shot\n # call over aggregate stats. \"llm\" = the old non-agentic single-shot; \"rule\"/\"off\" = no LLM.\n strategist_every: int = Field(default=3, ge=1) # consult cadence (created nodes)\n # PART V (F1): concept CLASSIFIER re-tag + consolidation cadence, DECOUPLED from strategist_every. The\n # LLM concept map is heavier and slower-moving than a strategy consult, so it refreshes on its own\n # (sparser) interval. Researcher-authored idea.concepts still fold immediately at node_created — this\n # only paces the classifier-evidence + consolidation refresh (and the concept-coverage pivot snapshot).\n concept_retag_every: int = Field(default=30, ge=1)\n # === Confirmation & holdout ===========================================================\n # Multi-seed confirmation (I12, ADR-15): confirm the top-k under N seeds before\n # finishing. 0 disables (default). Only meaningful when eval has variance.\n confirm_top_k: int = 0\n confirm_seeds: int = 0\n # D1 seed-holdout discipline: the FIRST seed the confirm phase uses. Search evals run with the\n # implicit seed 0 (LOOPLAB_EVAL_SEED unset -> \"0\"), so a base of 1 keeps every confirm split\n # DISJOINT from anything the search optimized against — the confirm metric is then a\n # generalization signal, not a re-measurement (AIRA: selecting on a signal the search saw\n # overfits by 9-13 pp). Set 0 to restore the legacy overlapping seeds 0..N-1.\n confirm_seed_base: int = Field(default=1, ge=0)\n # D1 holdout-gated promotion (B6, Arbor-style): for host-graded tasks, reserve this fraction of\n # the held-out labels as a FINAL holdout partition the search never sees — every search/confirm\n # eval is scored on the remaining rows only, and at finish the val-top-k are re-scored on the\n # holdout partition (free: the predictions already exist). 0.0 = off (legacy full-label scoring).\n holdout_fraction: float = Field(default=0.25, ge=0.0, le=0.9)\n # Champion selection prefers the HOLDOUT metric among the nodes that have one (the val-top-k).\n # Recorded in run_started so replay applies the same rule; old logs fold to False (legacy pick).\n # The per-node val-holdout `generalization_gap` folds into the Trust panel either way.\n holdout_select: bool = True\n # How many val-leaders get a holdout evaluation at finish (host-graded tasks).\n holdout_top_k: int = Field(default=3, ge=1)\n # R1-c / Part IV — calibrated §12-verifier tie-break in best-selection. When a fresh node's robust\n # metric EXACTLY TIES an already-eligible node, the engine runs the §12 verifier (grounded on each\n # node's realized result, `selection_criteria`) and records a soundness score per node. The tie-break\n # then applies across the WHOLE selection path: the mean pick breaks a robust_metric tie by the score,\n # and — when `holdout_select` is on — the holdout-slot ranking prefers the sounder node so it isn't\n # denied a holdout eval, and the final holdout override breaks a holdout_metric tie by it too. Strictly\n # a TIE-BREAK — it can NEVER move a node ahead of a strictly-better metric (§21.7 advisory-never-\n # overrides). Best-effort: verification is LAZY (only fires on an actual tie) and bounded per cadence,\n # so a large/late tie stays on deterministic metric+id order unless one atomic treatment scores every\n # member. Recorded in run_started so\n # replay applies the same rule; old logs fold to False (legacy pick). Opt-in (default off); needs a\n # reachable LLM client. Enable only after validating calibration offline with `verifier.calibrate`.\n # See trust/verifier.py + core/fitness.py.\n select_verifier: bool = False\n # R1-d (§21.19): widen the verifier tie-break from EXACT-metric to a conservative STATISTICAL tie.\n # The band is capped by both the metric leader's SE and the pooled SE-of-difference, so challenger\n # variance cannot widen it and a significant difference is never a tie (§21.7). Off -> exact only.\n verifier_ci_tie: bool = False\n # Verifier sample count for `select_verifier` (the §12 repeated-sampling expectation). 3 tames the\n # single-shot variance; 1 = single-shot (cheaper, noisier).\n select_verifier_samples: int = Field(default=3, ge=1, le=32)\n # Budget (I13): hard wall-clock ceiling; the run aborts cleanly when exceeded.\n max_seconds: float | None = None\n # Eval-compute budget (#2): hard ceiling on cumulative time spent INSIDE evals (training\n # runs), separate from wall-clock. Survives resume (summed from the event log). The real\n # guard against a silent multi-hour sweep when an eval is a minutes-hours training run.\n max_eval_seconds: float | None = None\n # Cross-run memory (I19, ADR-10): if set, the best result of each run is stored as\n # a case here, and the cases become retrievable knowledge for future runs.\n memory_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"memory\"))\n # HITL (I21, ADR-11): pause for human approval of the final best before finishing.\n require_approval: bool = False\n # Diversity archive (I22): niche bucket width in parameter space.\n archive_resolution: float = 1.0\n # Coverage read-model (narrowing signal): at the strategist cadence, compute a breadth summary\n # (themes / param-niches / theme entropy / dominant-theme fraction) from the folded run, record\n # it as a `coverage_snapshot` audit event, and surface it into the Strategist's decision context.\n # Deterministic, cheap, and purely additive context — no search-behavior change on its own. On by\n # default (like the rest of the always-on situational context). See search/coverage.py.\n coverage_context: bool = True\n # PART IV Phase 2a live steering (§21.11/§21.13). When on, the `concept_retag_every` cadence (NOT\n # `strategist_every` — the producer gates on `_should_consult_concepts`, which uses the seed boundary\n # + `concept_retag_every`, default 30) records a\n # concept-graph coverage + uncovered-region snapshot (deterministic heuristic tagger over the\n # task-type skeleton) and, on an `explore` stance, the Researcher's novelty hint names the exact\n # uncovered regions (\"0 coverage in {negatives/external-mining, distillation} — go there\") instead\n # of the vague \"broaden\". ON by default in the product Settings (ce4a379), EngineOptions off: it\n # only enriches an already-mode-gated explore hint, never forces exploration, and no-ops for a task\n # with no curated concept skeleton. The snapshot is auditable, but its prompt cue can change future\n # candidate generation; it does not directly rewrite best-metric selection. See search/concept_graph.py.\n # DESIGN NOTE (2026-07-17 critique): now ON unconditionally, but a toy / non-ML task yields EMPTY concept\n # tags while still paying the per-node LLM concept cost (~10 calls/node with the full Part IV/V bundle).\n # Consider AUTO-QUIESCING: if the heuristic tagger returns empty for the first N nodes (no concept signal),\n # skip the LLM concept work for the run — keeps it on for ML tasks, free on toy/degenerate ones.\n concept_pivot: bool = True\n # PART IV Phase 2b — D3 graded novelty into the LIVE novelty gate (§21.4/§21.13). When on and the\n # task has a curated concept skeleton, the novelty gate first grades a proposal over the concept\n # graph: a level-4 \"same direction, DIFFERENT implementation\" or a level-5 \"re-opens a\n # wrongly-abandoned FAILED direction\" proposal is ALLOWED through unchanged — the flat LLM/semantic\n # dedup gate can't tell \"this DCL tweak\" from \"the whole DCL branch\" and would wrongly reject a\n # legitimate variant or never re-open a sound-but-killed direction (the node_63 archetype). Levels\n # 1/2/3 (identical / near-dup / prior-run) still fall through to the existing gate. Deterministic\n # (heuristic tagger, no LLM), audit event `novelty_graded`, replay-safe. ON by default in the product\n # Settings (ce4a379), EngineOptions off; no-ops for a task with no skeleton. See search/graded_novelty.py.\n graded_novelty: bool = True\n # PART IV Phase 2b — D7 capability-expansion forced-jump DIRECTIVE (§21.8/§21.13, issue #7). When on\n # and the concept-graph cadence detects action-space LOCK-IN (the search has stayed inside one D5\n # branch for a long consecutive streak) on an `explore` stance, the Researcher's novelty hint\n # ESCALATES from \"broaden\" to \"expand the ACTION SPACE — build the missing infra (external mining,\n # a new data pipeline, a different eval), do NOT swap another {locked} lever.\" The same\n # gate also stamps the proposal's authoritative operator as `expand`; that node competes normally\n # under SearchFitness and its separate operator yield is measured. It does not force a winner.\n # Reads the 2a concept-coverage snapshot, so it needs `concept_pivot` to record one. OPT-IN\n # (default off). See engine/proposal_cues.py + search/lock_in.py.\n capability_expansion: bool = False\n # PART IV cross-run Step 0 (§21.20.12/§21.20.13). Universal task-fingerprint tokenization: drop the\n # ASCII-only `[a-z0-9]` allowlist on goal keywords so a non-Latin goal (Russian, CJK, …) is NOT\n # silently dropped from its cross-run fingerprint (today such a goal keys only on kind/dir/metric and\n # never reaches SIMILAR-task priors/lessons/cases). Uses `[^\\W_]+`/`.casefold()` — same splitting as\n # before, any script. ON by default in the product Settings (ce4a379); the bare-library EngineOptions\n # default stays off, so a run pinned to it won't silently re-key a portfolio mid-flight. See engine/memory.py.\n fingerprint_universal: bool = True\n # PART IV cross-run Step 2 (§21.20). Fill graded-novelty's `prior_concepts` from the cross-run\n # ConceptCapsuleStore: at run end write a per-run concept capsule (the shipped `node_concepts` tags +\n # best outcome, keyed by task_fingerprint) to `memory_dir`; when a later SIMILAR run proposes an idea\n # whose concept was tried before, SURFACE the prior outcome as a `cross_run_prior` audit event (never\n # reject — D3 level 3). Audit-only, off the selection path; ON by default in the product Settings\n # (ce4a379), EngineOptions off. Needs a `memory_dir` to share capsules.\n # See engine/memory.py (ConceptCapsuleStore) + engine/novelty.py.\n # PREREQUISITES (this flag is NOT standalone): the WRITE needs per-run concept tags\n # (`node_concepts`, produced by `concept_pivot`/F1 tagging) — no tags => no capsule; the SURFACE path\n # runs inside the graded-novelty precheck, so it also needs `graded_novelty` on. With only this flag +\n # `memory_dir` it persists/surfaces nothing. Enable `concept_pivot` + `graded_novelty` alongside it.\n # A non-Latin (Russian/CJK) portfolio should ALSO set `fingerprint_universal`, else the capsule's goal\n # tokens are dropped and its fingerprint over-matches on kind/dir/metric alone (the novelty direction\n # gate narrows this, but goal-word similarity is still lost).\n cross_run_concepts: bool = True\n # PART V (B): run-base + node-DELTA concept authoring. When on, once the first evaluated node has\n # authored concepts the engine seeds RunState.run_base_concepts from them (a one-shot EV_RUN_CONCEPTS,\n # idempotent because it only fires while the base is empty), and the Researcher's proposal hint tells it\n # to author only `concepts_added`/`concepts_removed` vs the run base + its parent — minimal per-node\n # annotations that inherit down the DAG (a swap propagates). Off -> every node authors its full concept\n # set as before. Product-on; the bare EngineOptions mirror defaults off (test_options_divergence).\n concept_run_base: bool = True\n # PART IV cross-run Step 5 advisory (§21.20.5). Fold the bounded cross-run CONTEXT PACK — evidence-\n # grounded claims with BOTH support and counter-evidence (Step 4) + a portfolio-coverage line (Step 3) —\n # into the Researcher's proposal prompt, exactly like the E4 cross-run prior note. Advisory ONLY: it is\n # prompt-grounding: it can change the Researcher's later candidates, but does not directly rewrite\n # best-metric selection (§21.7). Reads `memory_dir` (lessons.jsonl + concept_capsules.jsonl); \"\" when\n # empty. ON by explicit experimental product choice; no frozen A/B validation is claimed. The\n # bare-library EngineOptions default stays off (engine/options.py, frozen in\n # test_options_divergence). See engine/proposal_cues.py.\n cross_run_advisory: bool = True\n # PART IV cross-run §21.20.13 (full CR of the lean fuzzy claim merge). Switch the claim read-model to the\n # SCOPE+POLARITY-safe STRUCTURED claim key (engine/claim_key.py): claims from different tasks never\n # merge, opposite polarity (\"X helps\" vs \"X never helps\") is surfaced as a CONTRADICTION instead of being\n # collapsed, paraphrase/inflection variants group by exact structured key (no transitive over-merge), and\n # operator governance is scope-precise (a decision in task A cannot reach a same-worded claim in task B).\n # Affects the `cross_run_advisory` context pack only; ON by default in the product Settings (ce4a379);\n # the bare-library EngineOptions default stays off (engine/options.py). See engine/claims.py.\n cross_run_structured_claims: bool = True\n # PART IV cross-run §22.4 (AGENTIC taxonomy steward). At finalize, when an LLM client is available, let\n # the concept steward (engine/concept_steward.py) review the freshly-updated portfolio concept graph and\n # PROPOSE a curation — merge duplicate slugs / split conflated ones / purge noise. Proposals are LOGGED to\n # `concept_curation_log.jsonl` for operator ratification (via the serve/CLI governance surface); the LLM\n # never mutates fold state. The calls run synchronously during finalize, so they add latency and may add\n # paid inference; a caught steward failure does not prevent terminal completion or trigger a retry.\n # ON by explicit experimental product choice when `memory_dir` + an LLM backend are present; the\n # bare-library EngineOptions default stays off (engine/options.py). See engine/lessons.py.\n cross_run_curation: bool = True\n # Deprecated compatibility flag. Steward output is untrusted and finalize is proposal-only regardless\n # of this value; retained so old snapshots still validate and logs can disclose that auto was requested.\n cross_run_curation_auto: bool = False\n # Role backend (ADR-7/14): \"toy\" (offline optimizer) | \"llm\" (live model). Validated in\n # `_check_trust_gate` alongside the other enum-ish strings — every consumer tests `== \"llm\"`\n # exactly (cli/__init__.py, adapters/tasks.py), so an untyped `--set`/file/env typo (or a mis-cased\n # \"LLM\") used to fall through to the OFFLINE toy roles and quietly produce a run that never called\n # the model. Left as `str`, not `Literal`, so old snapshots still validate through the same path.\n backend: str = \"toy\"\n # Developer backend (ADR-7): \"default\" (templated/LLM from the task) or an external\n # CLI coding agent: \"opencode\" | \"aider\" | \"goose\" | \"continue\".\n developer_backend: str = \"default\"\n # C2 best-of-N: generate N candidate implementations per node and keep the best by an\n # execution-free reward (static validity + metric-print). 1 = off. In-house LLM developer only\n # (not external agents — ADR-7 cost rule). The top SWE-bench reliability lever for weak models.\n best_of_n: int = 1\n # D10 list-wise best-of-N selection: when the top static-scorers TIE, break the tie with a\n # comparative LLM selection over the candidates presented together (+~3 pts vs pointwise on\n # GAIA, arXiv:2506.12928). The static score stays the primary filter — the LLM is a weak\n # comparative prior, never the sole oracle. ON by default (no-op when best_of_n==1).\n best_of_n_listwise: bool = True\n # === Foresight / predict-before-execute ===============================================\n # FOREAGENT predict-before-execute (arXiv:2601.05930): use the LLM as an IMPLICIT WORLD MODEL to\n # predict — WITHOUT executing — which candidate / hypothesis will score best, primed with a\n # Verified Data Analysis Report (data profile / brief) + the accumulated experiment memory. Master\n # switch, ON by default; a no-op wherever there is nothing to rank (best_of_n==1 and\n # foresight_panel==1) or no LLM client. See `looplab/search/foresight.py`.\n foresight: bool = True\n # Hypothesis foresight breadth: generate K candidate ideas per proposal and keep the one PREDICTED\n # best pre-execution by the world model — it ranks the STRUCTURAL / text ideas the numeric k-NN\n # surrogate (researcher_panel) is blind to, primed with the data profile + memory (the synergy).\n # >1 enables it (LLM backend only; needs `foresight` on); 2 = on by default at modest cost, 1 = off.\n foresight_panel: int = 2\n # AGENTIC foresight: run the ranking (hypothesis-board prioritization + K-idea pick) as a TOOL-USING\n # loop that can pull actual experiment results / data facts before deciding, instead of a one-shot\n # prediction from a pre-baked report. ON by default (needs foresight + a client; falls back to the\n # one-shot predictor on any hiccup). A few extra LLM calls per proposal; set False for the cheaper\n # single-call ranker.\n foresight_agentic: bool = True\n # §1 foresight confidence gate: the minimum predicted confidence at which a predict-before-execute\n # pick is ACTED ON. Below it the ranker abstains — the K-idea panel falls back to the first\n # proposal, best-of-N falls through to the D10 tie-break — instead of committing a low-confidence\n # choice. 0.0 (default) = OFF (act on every pick, the historical behavior); raise toward ~0.5 to\n # make the world model defer when it isn't sure. Pairs with the foresight track record (§1) the\n # predictor is now primed with. Range 0.0-1.0.\n # BOUNDED like its confidence/fraction siblings (train_monitor_kill_confidence,\n # asha_live_quantile). Unbounded, a typo'd 5.0 validated silently into a gate no score can ever\n # clear, so every foresight pick abstained forever with nothing in the snapshot to say why.\n foresight_min_confidence: float = Field(default=0.0, ge=0.0, le=1.0)\n # PART IV Phase 2c — replace the world model's SELF-REPORTED confidence (measured Pearson≈0 with the\n # realized outcome, §21.12) with a CALIBRATED §12-verifier score. When on and a client is available,\n # after the K-idea ranker picks the predicted-best candidate the §12 verifier scores it (grounded +\n # repeated + criteria-decomposed: `foresight_criteria` — likely to improve the objective, and\n # sound/feasible) and THAT calibrated score becomes the confidence the `foresight_min_confidence` gate\n # and the telemetry use. Degrades to the self-reported confidence without a client or on any verifier\n # error (the telemetry records which source was used). ON by default in the product Settings (ce4a379),\n # EngineOptions off; a few extra LLM calls per acted-on proposal. See looplab/trust/verifier.py + search/foresight.py.\n # REVISIT (cost/quality, not yet resolved): a live classification run measured ~10 LLM calls & ~3.8 min/node\n # on deepseek-v4-flash with the full Part IV/V default bundle; the calibrated verifier is part of that per-node\n # tax. Kept ON deliberately — it raises decision quality (self-reported foresight confidence has ≈0 Pearson\n # with realized outcome), and lowering quality to save calls is the wrong trade. TODO: run a frozen quality/cost\n # A/B (verifier-on vs -off) to quantify the recall@objective gain per extra call before considering a default flip.\n foresight_verify: bool = True\n # Verifier sample count for `foresight_verify` (the §12 repeated-sampling expectation on a no-logprob\n # backend). 3 tames the single-shot variance §21.12 measured; 1 = single-shot (cheaper, noisier).\n # RESUME-critical: this value is persisted in config.snapshot.json, so a hard `le` schema bound would\n # make an old run started with a larger LOOPLAB_FORESIGHT_VERIFY_SAMPLES un-resumable (Settings(**snapshot)\n # would raise on `looplab resume` / finalize-recovery / serve replay). CLAMP into [1, MAX] via a validator\n # instead of rejecting — the runtime cap is preserved without breaking the snapshots/env-compat rule.\n foresight_verify_samples: int = 3\n\n @field_validator(\"foresight_verify_samples\")\n @classmethod\n def _clamp_foresight_verify_samples(cls, v: int) -> int:\n # Coerce a persisted/env value into [1, MAX] rather than reject (see field note above).\n try:\n v = int(v)\n except (TypeError, ValueError):\n return 3\n return max(1, min(v, MAX_FORESIGHT_VERIFY_SAMPLES))\n\n # T7 LLM response cache: serve an identical DETERMINISTIC (temperature 0) request from an\n # in-process content-addressed cache instead of re-hitting the model — cuts cost on\n # retry/panel/verify flows. NEVER caches sampling calls (temp>0: best-of-N / panel / novelty\n # re-propose must vary). Off by default (most role calls run at temperature>0 anyway).\n llm_cache: bool = False\n agent_cmd: str | None = None # override the agent's launcher (path / wrapper)\n # External-agent validation (ADR-7): wrap a CLI-agent Developer with a validator that\n # audits each output (no-op/syntax/crash/timeout), retries with feedback, and falls\n # back to the in-house LLM Developer. Off -> use the raw agent output unchecked.\n validate_agent: bool = True\n agent_max_retries: int = 1 # re-prompts of the agent on an invalid result\n # Patch-gated multi-file agent (ADR-7 Rule 3): run the CLI agent in a git worktree and\n # accept only edits whose paths match `agent_surface` (reject-not-strip out-of-surface\n # touches). Lets the agent create helper modules, not just solution.py. Degrades to\n # whole-file readback if git is unavailable.\n agent_patch_gate: bool = True\n agent_surface: list[str] = [\"*.py\"] # edit-surface allow-list (globs)\n # Trust policy for an agent-authored eval/metric adapter (RepoTask onboarding, Phase 3):\n # \"ratify_freeze\" waits for human approval, \"autonomous\" accepts the proposed spec without that\n # gate, and \"ratify_freeze_drift\" also enforces the configured independent drift cross-check.\n eval_trust_mode: str = \"ratify_freeze\"\n # RepoTask node seeding policy (run-wide fallback; a task/editable `seed_mode` overrides):\n # \"auto\" (default) copies git-tracked files when the editable is a git repo (so a tree bloated\n # with untracked artifacts isn't deep-copied per node), else a full copy; \"tracked\" forces\n # code-only; \"all\" forces a full recursive copy (legacy). Genesis authors per-task from the\n # user's words; this is the global default when unspecified.\n seed_mode: str = \"auto\"\n llm_model: str = \"qwen3:8b\"\n # === LLM / transport ==================================================================\n llm_base_url: str = \"http://localhost:11434/v1\" # Ollama OpenAI-compatible endpoint\n llm_temperature: float = 0.6\n # H3 per-role model presets (5090 recipe): optionally run the Researcher and Developer on\n # DIFFERENT models/endpoints (e.g. Developer=Qwen3-Coder-30B for code, a fast model for breadth).\n # Blank = use the shared llm_model/llm_base_url. Generalizes the role-backend swap to per-role.\n researcher_model: str | None = None\n developer_model: str | None = None\n researcher_base_url: str | None = None\n developer_base_url: str | None = None\n # Per-role sampling temperature (§4.1): the Researcher wants breadth (higher temp = more diverse\n # ideas), the Developer wants determinism (lower temp = fewer syntax slips), the Strategist a\n # steady hand. Each overrides the shared `llm_temperature` for that role only; None = use the\n # shared value (byte-identical to before). Flat + nullable, mirroring the per-role model fields.\n researcher_temperature: float | None = None\n developer_temperature: float | None = None\n strategist_temperature: float | None = None\n # The Strategist had per-role TEMPERATURE but no model/endpoint of its own, so in split mode it\n # always ran on the shared llm_model however the other roles were pointed. These close that gap\n # with the same names/shape as the pair above; blank = its profile, else the shared value.\n strategist_model: str | None = None\n strategist_base_url: str | None = None\n # === Connection profiles (multi-provider) =============================================\n # A \"profile\" is a NAMED connection: {model, base_url, temperature, api_key_env, provider}. Roles\n # point at one by name through `role_profiles`; `llm_profile` is the default for every role that\n # doesn't. All three are EMPTY by default and the resolver short-circuits to the shared\n # llm_model/llm_base_url/llm_api_key path when they are — an operator running one model never\n # needs to know the word \"profile\".\n #\n # A profile stores `api_key_env`, the NAME of an environment variable, never the key itself. That\n # is ADR-11's \"a secret is a reference, not a value\" turned from a convention into a data type:\n # the whole profile map is then safe to write into config.snapshot.json, hand back over HTTP and\n # render into LOOPLAB_* — there is nothing in it to mask. It is also what makes per-role\n # CREDENTIALS expressible at all: two roles on one provider can carry different keys, which a\n # key-per-endpoint map could not say.\n llm_profiles: dict[str, dict] = Field(default_factory=dict)\n llm_profile: str | None = None\n role_profiles: dict[str, str] = Field(default_factory=dict)\n # Unified self-driving agent: one LLM identity that plays Researcher + Developer (+ Strategist)\n # across pipeline stages, choosing its own model/toolset per stage. ON by default — the agent\n # drives the loop (incl. crash triage: repair/abandon/reject_idea) and, via\n # `agent_drives_actions`, picks the next macro action within a pure legal-action gate. Set both\n # to False for the legacy byte-identical split-role behavior. (No-op unless backend=\"llm\".)\n unified_agent: bool = True\n agent_drives_actions: bool = True\n # Layer 3 Card queue ownership. False preserves the existing policy/unified-pilot action path.\n # True is pinned in run_started because changing the selector on resume would mix two search\n # treatments in one run. When both this and agent_drives_actions are true, Card selection wins.\n card_driven_selection: bool = False\n # Layer 5 bounded speculative Card buffer. Zero is a hard OFF switch and preserves the\n # historical alternating build/eval spine; positive values are pinned by run_started so a\n # resume cannot silently mix search treatments after a snapshot/default edit.\n speculation_depth: int = Field(default=0, ge=0, le=64)\n # Local evidence receipt produced by ``looplab speculation-gate``. This is deliberately absent\n # from the curated Settings UI: positive-depth execution is a calibrated rollout decision, not a\n # casual tuning knob. The Engine validates the receipt against the current scorer,\n # implementation, GPU inventory and raw paired-run evidence before it enables speculation.\n speculation_gate_receipt: str | None = Field(default=None, max_length=4096)\n # Per-stage model/endpoint overrides for the unified agent. Recognized keys (see\n # tasks.build_unified_agent): `propose` (researcher), `implement`/`repair` (developer),\n # `strategy`, `pilot`. Unlisted keys are ignored. Empty = the per-role researcher_*/developer_*\n # models, then the shared llm_model. Explicit map wins. No-op unless `unified_agent` is true.\n agent_stage_models: dict = {}\n agent_stage_base_urls: dict = {}\n llm_parser: str = \"tool_call\"\n # Reasoning/thinking toggle sent IN the request (providers differ — see `llm.reasoning_body`):\n # Qwen3 on vLLM/SGLang -> chat_template_kwargs.enable_thinking (bool)\n # OpenAI/Ollama-v1/DeepSeek -> reasoning_effort (low|medium|high|none)\n # `llm_reasoning`: \"\" = send nothing (server default — current behavior); off = actively disable;\n # on = enable at default depth; low|medium|high = enable at that effort.\n # Defaults to \"high\" so the agent reasons before proposing/repairing/triaging (better code, fewer\n # crashes); set \"\" to send nothing (server default) or \"off\" to disable. The model's chain-of-thought\n # is CAPTURED either way (reasoning_content field or inline <think>). NOTE: to get a SEPARATE\n # reasoning_content field from SGLang/vLLM, the server also needs `--reasoning-parser qwen3`.\n llm_reasoning: str = \"high\"\n llm_reasoning_style: str = \"auto\" # auto | qwen | effort | none — how to shape the request param\n llm_reasoning_extra: dict = {} # raw fields merged into the body (escape hatch, e.g. Anthropic thinking)\n # H1: drive structured calls with the endpoint's constrained decoding (vLLM/SGLang guided_json +\n # response_format json_schema) so weak models can't emit invalid JSON. Off for Ollama (default).\n llm_guided_json: bool = False\n # Stream every LLM request (SSE) and reassemble it, so the request `timeout` is an INTER-TOKEN\n # idle timeout rather than a whole-request deadline: a long-but-alive generation is never cut off\n # (tokens keep resetting the timer), while a genuinely STALLED endpoint (no token for `timeout` s)\n # is detected and retried on a fresh connection — the fix for silent multi-minute hangs. Set False\n # to use one blocking read (old per-op timeout semantics) if an endpoint streams badly.\n llm_stream: bool = True\n # LLM IDLE timeout (seconds). Stream mode: the INTER-TOKEN stall limit — a steady generation is\n # never cut off, only a silent endpoint. Non-stream: bounds the whole read. Raise for endpoints\n # with a long prefill on huge prompts; lower to fail fast on a flaky shared endpoint.\n llm_timeout: float = Field(default=180.0, gt=0)\n # First-byte (response-headers) window for STREAM attempts, seconds: an SSE response sends its\n # headers on admission, so no-headers ≈ a black-holed request — fail over to a fresh connection\n # fast instead of waiting the full idle timeout. Clamped to llm_timeout. Non-stream attempts are\n # NOT bounded by this (their headers arrive only after the whole generation). The default mirrors\n # `DEFAULT_HEADER_TIMEOUT_S` in looplab/core/llm.py is the single source (imported above).\n llm_header_timeout: float = Field(default=DEFAULT_HEADER_TIMEOUT_S, gt=0)\n # Honour HTTP(S)_PROXY / NO_PROXY / SSL_CERT_FILE env vars for LLM calls. Default False: the\n # transport connects DIRECTLY (many internal/OLAP endpoints are reachable only by bypassing an\n # ambient proxy — a picked-up proxy yields \"connection refused\"). Set true when the endpoint is\n # reachable ONLY via a corporate proxy or needs a custom CA bundle from the environment.\n llm_trust_env: bool = False\n # H4/C2: compact the growing agentic tool-call history once it exceeds this many chars (auto_summary\n # LLM-summarizes the stale middle; else middle-truncate). Sized to the model's real context window so\n # files read earlier STAY in context instead of being compacted away and re-read: ~1,000,000 chars ≈\n # 250k tokens (≈4 chars/token), matching the configured deepseek-v4-flash (≥200k tokens confirmed).\n # The old 120k-char (~30k-token) default was ~8× too aggressive and drove a read-thrash loop. 0 = off.\n context_budget_chars: int = 1_000_000\n # M5: the Researcher's always-on experiments digest budget (chars). 0 = AUTO — scales with the\n # run size (60 chars/node, bounded [1200, 6000]) instead of one flat cap for an 8-node toy run\n # and a 200-node benchmark run alike. Set a positive value to pin it.\n digest_char_cap: int = Field(default=0, ge=0)\n # D11 compression model slot (open_deep_research's four-slot pattern): a dedicated CHEAP model\n # for agent-history summarization (C2 auto-summary), so compression doesn't pay the main\n # model's price. Blank = use each loop's own model (legacy). `compressor_base_url` blank =\n # the shared llm_base_url.\n compressor_model: str | None = None\n compressor_base_url: str | None = None\n # === Agentic tool-loop ================================================================\n # Agentic tool-loop limits — apply to EVERY tool-using agent (the LLM Researcher, the unified\n # agent's pilot + crash-triage, Deep-Research, the run-chat Boss, the genesis repo-scout, and the\n # cross-run report synthesizer). The loop lets the model call read-only tools across turns before\n # emitting its structured result. Both default to UNLIMITED so the agent takes as many turns / as\n # long as it needs — it is never cut off mid-reasoning; set a positive value here (or per-run, or\n # in the UI settings) to bound latency/cost. These used to be hardcoded per call site (e.g. the\n # boss at max_turns=3 / 45s), which silently dropped a slow reasoning model to a no-op reply.\n agent_max_turns: int = 0 # max tool turns before the emit is forced (0 = unlimited)\n agent_time_budget_s: float = 0.0 # wall-clock ceiling across the loop's turns (0 = no cap)\n # G · Soft convergence SAFETY NET (not a research cap). Two thresholds on the agent's tool-turn count:\n # at `agent_emit_after` the loop NUDGES the agent once (\"you've investigated enough — emit now\"); at\n # `agent_emit_force` it FORCES the emit from what was gathered. Set GENEROUSLY — a focused researcher\n # may legitimately read many files/experiments before it can propose a grounded idea; these only catch\n # a genuine runaway that keeps issuing DIFFERENT calls forever (which stuck-detection, keyed on\n # repeats, misses). NB: the old 499-read runaway (node 63) was 79% REDUNDANT re-reads caused by a\n # broken read_file — fixing read_file (pagination) is the real cure; this is just insurance. 0 = off.\n agent_emit_after: int = 300\n agent_emit_force: int = 500\n # B1 · No-progress / stuck detection — the safety net that makes \"unlimited turns\" safe. The\n # turn/time ceilings above are only BACKSTOPS; this is what actually stops a runaway loop, on the\n # cheapest signal: when the model repeats the SAME tool call (or ping-pongs between two, or keeps\n # hitting the SAME error) with no progress, the loop forces the final emit and finishes. ON by\n # default; reading DIFFERENT files or running ONE long command never trips it. (OpenHands-style.)\n agent_stuck_detection: bool = True\n agent_stuck_repeat: int = 4 # identical calls in a row that count as \"stuck\" (>=2)\n agent_stuck_alternate: int = 4 # ping-pong cycles between two calls that count as \"stuck\" (>=2)\n # C1 · Self-plan (TodoWrite-style): expose an `update_plan` tool so a long-running agent keeps its\n # OWN working TODO and re-surfaces it every `agent_plan_reinject_every` turns — keeps the goal in\n # view across a long loop so the agent \"writes its own context\" (Claude Code style). ON by default.\n agent_self_plan: bool = True\n agent_plan_reinject_every: int = 5\n # C2 · Auto-summary (ON by default): when the tool-loop history grows long, LLM-summarize the\n # stale middle instead of only middle-truncating it. The trigger is `context_budget_chars` if set,\n # else a built-in high-water mark (context_budget.DEFAULT_SUMMARY_CHARS ≈ 120k chars) so short\n # loops are untouched. Falls back to truncation on any summarizer error; one extra model call per\n # over-budget turn.\n agent_auto_summary: bool = True\n # C4 · Repo-developer plan decomposition. A big feature attempted in ONE developer session made a\n # non-converging reasoning model loop indefinitely — it kept writing + exploring without ever\n # emitting `done` (10k+ LLM calls / hours; agentic stuck-detection can't catch VARIED exploration,\n # only literal repeats). Fix: the developer first proposes an ordered plan of ATOMIC steps; a\n # multi-step plan is executed step-by-step, each in a FRESH bounded session with only that step's\n # scope + the files accumulated so far, syntax-validated per write. Every session stays\n # small-context and convergent. ON by default for repo tasks (a 1-step plan == the old single pass).\n developer_plan_decompose: bool = True\n developer_plan_min_steps: int = 2 # a proposed plan with >= this many steps runs step-by-step\n developer_plan_max_steps: int = 8 # cap on plan length (a runaway planner can't spawn 100 steps)\n # Hard per-session backstop for the repo developer — the ONE write-heavy agent that can run away\n # even with stuck-detection on (varied writes/reads never trip the repeat/ping-pong signal). Bounds\n # the plan phase, EACH step, and the single-session fallback. Unlike the global agent_* limits\n # (0 = unlimited, meant for read-only agents), the developer always gets a FINITE ceiling so a\n # model that never emits `done` fails cleanly with the code it wrote so far, instead of looping.\n developer_session_max_turns: int = 500\n developer_session_time_budget_s: float = 1200.0 # 20 min wall-clock per developer session\n # Phase-handoff summaries. Each LLM phase in a node build (Researcher·propose → Developer·stages →\n # plan → implement) ends with ONE extra LLM call that distills its transcript — the repo structure\n # it mapped, files/data confirmed, decisions made — into a brief injected into the NEXT phase (even\n # across the role boundary). The next phase TRUSTS that instead of re-reading the same files, which\n # cuts the biggest source of tool-call thrash (stages/plan/implement each re-exploring the repo).\n # One summary call per phase in exchange for many fewer read calls downstream. ON by default.\n phase_handoff_summary: bool = True\n # Observability (ADR-17): capture each LLM call's full prompt + completion into the active\n # span (spans.jsonl) so the UI can show exactly what the model read and wrote per node.\n # Diagnostics only — `replay.fold` never reads spans. Default on for local single-user; set\n # LOOPLAB_TRACE_LLM_IO=0 to suppress (e.g. to keep spans.jsonl small or avoid storing prompts).\n trace_llm_io: bool = True\n # Run-introspection tools (ON by default): make the LLM Researcher (and DeepResearcher) a\n # tool-using agent that can read its OWN experiments (list/read/find-analogous/themes/code) and\n # the task data (schema/profile/asset) mid-loop, instead of seeing only best+parent. Advisory —\n # never changes best-selection. Off = the legacy single-shot Researcher (richer digest still added).\n researcher_tools: bool = True\n # Cross-run introspection: give the agentic Researcher / pilot read-only tools to look at\n # SIBLING runs (same task_id, same run-root) — list them, read an experiment / its code, and\n # find analogous configs across runs — so a run can build on what neighbouring runs already\n # learned instead of rediscovering it. Advisory; never changes best-selection. Needs the run's\n # own dir wired through (no-op for unit-built roles). Off = the legacy single-run view only.\n cross_run_tools: bool = True\n # Read-only access to EVERY run on this machine ACROSS ALL TASKS (not just same-task siblings):\n # the Developer/Researcher get list_all_runs + read_run_code + read_run_experiment so they can read\n # the code + result of ANY past experiment anywhere and reuse an approach. Broader than\n # `cross_run_tools` (same-task only); the agent decides when a foreign run is relevant. Advisory;\n # never changes best-selection. Needs the run's own dir wired through (no-op for unit-built roles).\n all_runs_tools: bool = True\n # PART V §22 — read-only CROSS-RUN KNOWLEDGE tool for the reasoning roles (Researcher, Strategist,\n # deep-research, and a Developer-scoped variant). Adds `cross_run_prior_attempts` / `cross_run_claims`\n # / `cross_run_atlas` over the §21.20 read-models (concept overview + claim assessments + atlas), read\n # from `memory_dir` (lessons.jsonl + concept_capsules.jsonl). Read-only is a storage-authority\n # boundary, not a behavioral no-op: retrieved content can steer later proposals, and a tool-using\n # reasoning loop can consume additional model calls. The tools never mutate cross-run truth (facts\n # are engine-written, verdicts operator-ratified, §22.4) or directly select a winner. ON by explicit\n # experimental product choice, EngineOptions off; stores only exist once Part-IV features have run.\n cross_run_read_tools: bool = True\n # Agentic retrieval (ADR-16): if set, the LLM Researcher gets grep/kb_search/read\n # tools over this directory of markdown notes and chooses when to use them.\n knowledge_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"knowledge\"))\n # T4 real embeddings: model id for an OpenAI-compatible `/embeddings` endpoint used for\n # kb_search / case retrieval. Blank (default) = the dependency-free lexical `hash_embed` (today's\n # behavior). Set e.g. \"nomic-embed-text\" (Ollama) or \"text-embedding-3-small\" for SEMANTIC\n # retrieval; a misconfigured/offline endpoint degrades back to hash_embed at call time (never\n # crashes a run). `embed_base_url` blank = reuse `llm_base_url`; the shared `llm_api_key` is used.\n embed_model: str | None = None\n embed_base_url: str | None = None\n # Harmonic memory (Memora, ICML'26 — idea import). When ON, cross-run cases and knowledge notes are\n # indexed by a short ABSTRACTION + cue ANCHORS instead of their raw text, near-duplicate memories are\n # CONSOLIDATED into one entry under a matching abstraction, and `kb_search`/case retrieval EXPAND\n # through the top hits' anchors to surface related-but-not-similar memories. ON by default. Set\n # `memora=false` to restore the pre-Memora raw-text index. Never a source of truth — abstractions\n # live only in the derived, rebuildable retrieval index (never the event log or canonical cases.jsonl).\n memora: bool = True\n # Write abstractions with the wired chat model (richer than the deterministic lexical fallback). ON\n # by default; results are CACHED by content hash (see `memora_cache`) so a re-built index doesn't\n # re-call the model on unchanged notes/cases, and any endpoint failure degrades to lexical at call\n # time — so an offline box just gets lexical abstractions, never a crash. Set false to force lexical.\n memora_llm: bool = True\n # === Cross-run memory / Memora ========================================================\n memora_anchors: int = 6 # max cue anchors kept per memory\n # Cosine similarity at/above which a new case is CONSOLIDATED into an existing one (same evolving\n # topic) rather than stored as a separate entry. Only used when `memora` is on.\n memora_consolidate_threshold: float = 0.86\n # Where the LLM-abstraction cache lives (JSON). Blank (default) derives it from `memory_dir`\n # (`<memory_dir>/memora_cache.json`) or else `knowledge_dir` (`<knowledge_dir>/.memora_cache.json`);\n # with neither set the cache is in-memory only (per process). No-op unless `memora_llm` is on.\n memora_cache: str | None = None\n # E3 literature-grounded ideation (network-OPTIONAL): give the agentic Researcher an arXiv search\n # tool to ground ideas in real techniques. Off by default (egress is unreliable on some boxes);\n # fails gracefully if the network is blocked.\n literature_search: bool = False\n # Deep-Research stage (network-OPTIONAL): give the DeepResearcher a general web search/fetch\n # tool (DuckDuckGo) on top of arXiv to read across results + the web before steering the next\n # batch. Off by default (egress is unreliable on some boxes); fails gracefully when blocked.\n web_search: bool = False\n # Cadence for the Deep-Research stage: run it automatically every N created nodes. Zero disables\n # only the cadence; manual `deep_research` and Strategist `request_research` triggers still fire.\n # Default 3: deep research analyzes the accumulating results and steers the next batch (its\n # directions become hints + open hypotheses); paired with `concurrent_research` it overlaps the\n # think with the GPU-bound eval, so the cadence costs LLM tokens but no wall-clock on the search.\n deep_research_every: int = 3\n # Overlap a DUE deep-research \"think\" with the GPU-bound eval instead of running it in its own\n # serial step (the agent is otherwise idle while a node trains). research() computes from a state\n # snapshot; the joined background task records only allowlisted, order-tolerant\n # research/hint/hypothesis events as soon as the memo finishes. ON by default: the LLM is typically\n # remote (no GPU contention with eval), so overlapping hides latency behind long training.\n concurrent_research: bool = True\n # Concurrent-research REPEAT — don't idle a multi-DAY eval. The overlapped \"think\" fires ONCE per\n # window by default; ON here, it RE-RUNS on an adaptive time cadence for the WHOLE eval window\n # (a two-day training must not leave the reasoning agents idle after one memo). Self-paced: it\n # only records a memo whose CONTENT is new (identical re-runs are skipped, so the log/board don't\n # bloat) and backs off geometrically as the analysis converges, capped so it always re-checks.\n # The appended records never rewrite the current metric champion, but their hints/open hypotheses\n # deliberately steer later proposals and replay reconstructs that advice. The library default is\n # one-shot (== today, byte-identical); this product default turns the repetition on.\n concurrent_research_repeat: bool = True\n # Base cadence (seconds) between repeated concurrent-research passes. Research is EXPENSIVE\n # (multi-turn LLM + web/arXiv), so this is a FLOOR, not a ceiling: the effective cadence is\n # max(this, ~5% of the per-experiment time budget) — a two-day eval is re-researched about hourly,\n # a short eval not at all (the first tick outlasts it). Only matters when the repeat is on.\n concurrent_research_interval_s: float = Field(default=1800.0, gt=0)\n # Per-eval-window backstop on repeated-research LLM calls (0 = cadence-only, no cap). The adaptive\n # cadence + convergence backoff are the primary budget control; this bounds a pathological\n # never-converging window. Past it the loop stops calling the LLM (the health monitor still runs).\n concurrent_research_max_calls: int = Field(default=40, ge=0)\n # Concurrent hypothesis-board CONSOLIDATION during the eval window. Repeated research keeps ADDING\n # near-duplicate directions to the open board (each memo registers up to 5 as open hypotheses), so a\n # long eval's board bloats before the between-nodes merge runs. This dedups/merges it on the SAME\n # background loop so the board stays clean for the next proposal. In Card-driven mode the same merge\n # changes ownership/readiness, so it is deferred to the joined main-task cadence instead. Needs the\n # reflect client + `track_hypotheses`.\n # Only active while `concurrent_research` runs the overlap loop. Library default off (== today).\n concurrent_consolidate: bool = True\n # Cadence for the agent-authored run report: regenerate the conclusion-first narrative every N\n # created nodes (0 = off; it still regenerates on a manual `report_refresh` from the UI). The\n # deterministic report always renders from the node set regardless of this knob.\n report_every: int = 3\n # Agent Skills (I18, ADR-9): dir of SKILL.md the Researcher can list/load as tools.\n skills_dir: str | None = None\n # Prompt store (I18, ADR-8): dir of editable, hot-reloaded role prompt .md files.\n prompt_dir: str | None = None\n # API key as a reference, never serialized as a value (ADR-11). Local servers\n # ignore it; default to a placeholder.\n llm_api_key: SecretStr | None = None\n\n @model_validator(mode=\"before\")\n @classmethod\n def _reject_bool_for_numeric_fields(cls, data):\n \"\"\"A JSON boolean is a TYPE ERROR for a numeric setting, not the number 1.\n\n Pydantic runs in lax mode here (`model_config` sets no `strict`), and `isinstance(True, int)`\n is True — so `{\"max_nodes\": true}` from a UI/API type slip validated cleanly as a budget of\n ONE node, and `{\"eval_parallel\": true}` collapsed the width to one. The run then looked\n configured and simply did almost nothing, with no 422 and nothing in the snapshot to show why.\n Rejecting at the boundary covers every numeric field at once, including the many that carry no\n `Field(ge=...)` bound of their own to trip over.\n \"\"\"\n if not isinstance(data, dict):\n return data\n for key, val in data.items():\n if type(val) is bool and _is_numeric_annotation(\n getattr(cls.model_fields.get(key), \"annotation\", None)):\n raise ValueError(f\"{key} must be a number, not a boolean (got {val!r})\")\n return data\n\n @model_validator(mode=\"before\")\n @classmethod\n def _apply_profile(cls, data):\n \"\"\"Expand `profile` into a bundle of setting defaults BEFORE validation.\n\n pydantic-settings hands this validator the fully-merged input from ALL explicit sources\n (init kwargs + LOOPLAB_* env + .env), with fields the user left unset simply ABSENT (they\n fall to field defaults afterward). So a profile value is applied only when its key is not\n already present — i.e. any explicit file/CLI/env setting wins over the profile, and the\n profile wins over the bare field default. This makes `thorough` a true convenience preset,\n never an override of what the user asked for.\"\"\"\n if not isinstance(data, dict):\n return data\n prof = data.get(\"profile\", \"default\")\n # env may deliver the key as \"profile\" (env_prefix stripped) — normalize to a str\n prof = str(prof).strip().lower() if prof is not None else \"default\"\n overrides = PROFILES.get(prof)\n if overrides is None:\n raise ValueError(f\"unknown profile {prof!r}; known: {sorted(PROFILES)}\")\n for key, val in overrides.items():\n if key not in data: # explicit source (file/CLI/env) always wins\n data[key] = val\n return data\n\n @model_validator(mode=\"after\")\n def _check_trust_gate(self):\n if self.trust_gate not in (\"audit\", \"gate\", \"block\"):\n raise ValueError(\n f\"trust_gate must be audit|gate|block, got {self.trust_gate!r}\")\n if self.merge_mode not in (\"auto\", \"mean\", \"ensemble\"):\n raise ValueError(\n f\"merge_mode must be auto|mean|ensemble, got {self.merge_mode!r}\")\n # novelty_mode drives the dedup/novelty gate; an out-of-set value (e.g. a mis-cased\n # LOOPLAB_NOVELTY_MODE=LLM, or \"on\") used to fall through _apply_novelty_gate silently as a\n # NO-OP — turning the gate off with no diagnostic. Fail loudly at config time like trust_gate/\n # merge_mode do. (Other enum-ish string fields — seed_mode/eval_trust_mode/strategist_backend —\n # share the pattern; left unvalidated here only because their full value sets are less settled.)\n if self.novelty_mode not in (\"off\", \"algo\", \"llm\"):\n raise ValueError(\n f\"novelty_mode must be off|algo|llm, got {self.novelty_mode!r}\")\n # The remaining enum-ish string fields (arch-review §5 P3): a typo used to be accepted at\n # construction and only fail-safe/later-loud downstream (e.g. a mis-cased strategist_backend\n # silently ran the default). Fail loudly here like the fields above.\n if self.strategist_backend not in (\"off\", \"rule\", \"llm\", \"agent\"):\n raise ValueError(\n f\"strategist_backend must be off|rule|llm|agent, got {self.strategist_backend!r}\")\n if self.eval_trust_mode not in (\"ratify_freeze\", \"autonomous\", \"ratify_freeze_drift\"):\n raise ValueError(\n \"eval_trust_mode must be ratify_freeze|autonomous|ratify_freeze_drift, \"\n f\"got {self.eval_trust_mode!r}\")\n if self.seed_mode not in (\"auto\", \"tracked\", \"all\"):\n raise ValueError(f\"seed_mode must be auto|tracked|all, got {self.seed_mode!r}\")\n if self.backend not in (\"toy\", \"llm\"):\n raise ValueError(f\"backend must be toy|llm, got {self.backend!r}\")\n # `parse_structured` resolves an unknown name to the DEFAULT fallback order, so a typo'd\n # llm_parser is indistinguishable from asking for the default. Validate against the parser\n # registry itself (imported here, not at module scope, to keep config import-light).\n from looplab.core.parse import _ORDER as _PARSER_ORDER\n if self.llm_parser not in _PARSER_ORDER:\n raise ValueError(f\"llm_parser must be one of {'|'.join(sorted(_PARSER_ORDER))}, \"\n f\"got {self.llm_parser!r}\")\n self._check_llm_profiles()\n return self\n\n def _check_llm_profiles(self) -> None:\n \"\"\"Validate the connection-profile maps LOUDLY, at construction.\n\n Assignment is not validated anywhere in this project, and these two maps are the kind of\n thing a typo makes into a silent no-op: an unknown role name would simply never be read, and\n a reference to a missing profile would fall back to the shared model while the operator\n believed a second provider was in play. Both are errors here instead.\n\n The `api_key_env` rules exist for a specific reason. It must LOOK like a secret to\n `runtime/sandbox.py::SECRET_ENV`, because that is the filter that strips the operator's keys\n out of the environment handed to generated candidate code — a key parked in a variable named\n `MY_HANDLE` would be invisible to it and ride straight into the sandbox. This pattern is\n deliberately the STRICTER of the two (the sandbox has to recognize whatever already exists;\n this names a variable the operator is about to create), and it is duplicated below rather than\n imported because layering forbids `core` from importing `runtime`.\n `tests/test_secret_env_pattern.py` pins the two together — including the invariant that every\n name accepted here is one the sandbox strips. And a literal key inside a profile is refused\n outright: the profile map is written verbatim into config.snapshot.json and served over HTTP\n precisely because it is supposed to contain no secrets.\"\"\"\n from looplab.core.llm import LLM_ROLE_KEYS\n if not isinstance(self.llm_profiles, dict):\n raise ValueError(\"llm_profiles must be a map of profile name -> profile\")\n for name, entry in self.llm_profiles.items():\n if not isinstance(entry, dict):\n raise ValueError(f\"llm_profiles[{name!r}] must be an object\")\n # BEFORE the unknown-field check: none of these is a known field, so checking them\n # afterwards could never fire and someone who pasted a live key was told it was a typo.\n for banned in (\"api_key\", \"key\", \"token\", \"secret\"):\n if banned in entry:\n raise ValueError(\n f\"llm_profiles[{name!r}] must not contain {banned!r}: a profile stores \"\n \"api_key_env (the NAME of an environment variable), never a key value — \"\n \"the whole map is written to config.snapshot.json and served over HTTP\")\n unknown = set(entry) - _PROFILE_FIELDS\n if unknown:\n raise ValueError(\n f\"llm_profiles[{name!r}] has unknown field(s) {sorted(unknown)}; \"\n f\"known: {sorted(_PROFILE_FIELDS)}\")\n env = entry.get(\"api_key_env\")\n if env is not None and not _SECRET_ENV_NAME.match(str(env)):\n raise ValueError(\n f\"llm_profiles[{name!r}].api_key_env={env!r} is not a usable variable name: it \"\n \"must be UPPER_SNAKE and contain KEY/SECRET/TOKEN/PASSWORD/PASSWD/CREDENTIAL, so \"\n \"the sandbox's secret-name filter strips it from generated code's environment\")\n temp = entry.get(\"temperature\")\n if temp is not None and (isinstance(temp, bool) or not isinstance(temp, (int, float))):\n raise ValueError(f\"llm_profiles[{name!r}].temperature must be a number\")\n if not isinstance(self.role_profiles, dict):\n raise ValueError(\"role_profiles must be a map of role name -> profile name\")\n for role, profile in self.role_profiles.items():\n if role not in LLM_ROLE_KEYS:\n raise ValueError(\n f\"role_profiles has unknown role {role!r}; known: {sorted(LLM_ROLE_KEYS)}\")\n if profile not in self.llm_profiles:\n raise ValueError(\n f\"role_profiles[{role!r}] names profile {profile!r}, which is not in llm_profiles\")\n if self.llm_profile is not None and self.llm_profile not in self.llm_profiles:\n raise ValueError(\n f\"llm_profile={self.llm_profile!r} is not in llm_profiles\")\n\n def masked_snapshot(self) -> dict:\n # Snapshots are a JSON contract (they are written verbatim to config.snapshot.json), not a\n # Python-object dump. In particular tuple-valued schema fields such as\n # ``inline_repair_reasons`` must become JSON arrays before callers hash the runtime envelope;\n # otherwise the strict speculation-scope canonicalizer sees a different/non-JSON preimage\n # from the one that ``json.dumps`` eventually persists.\n d = self.model_dump(mode=\"json\")\n if self.llm_api_key is not None:\n d[\"llm_api_key\"] = \"***\"\n else:\n d[\"llm_api_key\"] = None\n # The snapshot's own FORMAT version — not a Settings field (it is never settable by env or\n # file, and it describes the document, not the run). `settings_from_snapshot` fails closed on\n # a version it does not understand; see CONFIG_SNAPSHOT_SCHEMA.\n d[CONFIG_SNAPSHOT_SCHEMA_KEY] = CONFIG_SNAPSHOT_SCHEMA\n return d\n\n\n# The config snapshot's document-format version, written by `masked_snapshot` and enforced by\n# `settings_from_snapshot`. Bump it when a snapshot written by this binary would be MISREAD by an\n# older one — i.e. when a new field changes paid, concurrency or selection behaviour and silently\n# defaulting it would resume the same event history under different semantics. `Settings` is\n# `extra=\"ignore\"`, so without this an older binary drops what it does not recognize and resumes\n# anyway, with no diagnostic; a version it does not know is now a loud refusal instead.\n# Version 1 is the first VERSIONED format. An absent key means a pre-versioning snapshot, which stays\n# readable through `LEGACY_CONFIG_SNAPSHOT_DEFAULTS` — that historical contract is unchanged.\nCONFIG_SNAPSHOT_SCHEMA = 1\nCONFIG_SNAPSHOT_SCHEMA_KEY = \"config_snapshot_schema\"\n\n\nclass ConfigSnapshotVersionError(ValueError):\n \"\"\"A config snapshot was written by a NEWER binary than the one reading it.\"\"\"\n\n\n# Pre-versioned snapshots are full Settings dumps, so absence is a reliable per-field version marker.\n# Keep their historical effective behavior when newer product defaults become active: re-entry must not\n# silently add paid calls, interventions, concurrency or a different selection policy to an old run.\nLEGACY_CONFIG_SNAPSHOT_DEFAULTS: dict[str, object] = {\n \"parallel_build\": 1,\n \"eval_parallel\": None,\n \"llm_parallel\": None,\n \"train_monitor\": False,\n \"train_monitor_interval_s\": 600.0,\n \"train_monitor_kill\": False,\n \"train_monitor_kill_confidence\": 0.8,\n \"asha_live\": False,\n \"asha_live_kill\": False,\n \"asha_live_quantile\": 0.5,\n \"asha_live_min_siblings\": 3,\n # Before this operator ceiling existed, the sandbox's 24-hour process limit was authoritative.\n \"max_eval_timeout\": 24 * 3600.0,\n \"watchdog_reflection\": False,\n \"card_driven_selection\": False,\n \"speculation_depth\": 0,\n \"speculation_gate_receipt\": None,\n \"concurrent_research_repeat\": False,\n \"concurrent_research_interval_s\": 1800.0,\n # Zero means unlimited; 40 was the first bounded default, and is latent while repeat is disabled.\n \"concurrent_research_max_calls\": 40,\n \"concurrent_consolidate\": False,\n # The map originally covered only the flags added in the days just before it was written\n # (the entries above are all 2026-07-19..07-21; the map itself landed 07-22). Everything below was\n # added 2026-06-24..07-17 — AFTER `config.snapshot.json` existed (it ships in the initial commit,\n # 2026-06-23) and BEFORE this map did — so a snapshot from that window carries none of these keys\n # and, without an entry here, resumed with today's paid product default ACTIVE. That is precisely\n # the \"silently add paid calls ... to an old run\" the paragraph above forbids: the resumed half of\n # the run would make predict-before-execute and cross-run LLM calls the original never made, and\n # admit candidates under a different novelty policy.\n # `setdefault` makes every one of these a no-op for a snapshot that DOES carry the key, so only\n # genuinely older runs are affected. Each value is the behaviour before the field existed: absent\n # feature = off, absent cadence = 0 (the same \"conservative library\" value the frozen\n # Settings-vs-EngineOptions divergence table records for these fields). Each was dated against the\n # tree before being listed — e.g. `report_every` (2026-06-25) is 0 because the run-report writer\n # itself did not exist before that commit, so an older run wrote no report to preserve.\n \"foresight\": False,\n \"foresight_agentic\": False,\n \"foresight_verify\": False,\n \"concept_pivot\": False,\n \"graded_novelty\": False,\n \"cross_run_concepts\": False,\n \"cross_run_advisory\": False,\n \"cross_run_structured_claims\": False,\n \"cross_run_curation\": False,\n \"cross_run_read_tools\": False,\n \"concept_run_base\": False,\n \"fingerprint_universal\": False,\n \"memora_llm\": False,\n \"comparative_lessons\": False,\n \"lessons_every\": 0,\n \"lessons_refresh_every\": 0,\n \"unified_agent\": False,\n \"deep_research_every\": 0,\n \"report_every\": 0,\n \"failure_reflection\": False,\n \"reflection_priors\": False,\n \"agent_drives_actions\": False,\n \"concurrent_research\": False,\n \"deep_repair\": False,\n # WHAT THIS MAP IS NOT. It is a hand-maintained list of FEATURE switches whose before-the-field\n # value is unambiguous, not a complete partition of `Settings`. Two classes stay out on purpose,\n # because for them a wrong entry is worse than a missing one — it would silently REMOVE behaviour\n # the original run really had:\n # * knobs that are latent while their parent feature is off — `foresight_panel`,\n # `foresight_verify_samples`, `memora_anchors`, `holdout_top_k`, `asha_eta`: the parent above\n # already pins them out of play, and their own before-the-field value is not recoverable;\n # * magnitudes and depths for behaviour that ALREADY existed — `debug_depth`,\n # `context_budget_chars`, `developer_plan_max_steps`, `agent_emit_after`: guessing 0/off there\n # disables working machinery rather than preserving history.\n # A new field belongs here only when it (a) postdates 2026-06-23, (b) defaults to adding paid\n # calls / interventions / concurrency / a different selection policy, and (c) has a historical\n # value you can point at a commit for. When (c) fails, leave it out and say so here.\n}\n\n\ndef migrate_config_snapshot(data: dict) -> dict:\n \"\"\"Return an effective copy of one legacy/current snapshot without rewriting evidence bytes.\"\"\"\n if not isinstance(data, dict):\n raise TypeError(\"config snapshot must be a JSON object\")\n migrated = dict(data)\n for key, value in LEGACY_CONFIG_SNAPSHOT_DEFAULTS.items():\n migrated.setdefault(key, value)\n return migrated\n\n\ndef config_snapshot_schema(data: dict) -> int:\n \"\"\"The snapshot's declared format version. 0 = pre-versioning (the key did not exist yet).\n\n A malformed value is treated as UNREADABLE rather than as 0: a snapshot whose own version marker\n is corrupt cannot be assumed to be the oldest format.\"\"\"\n raw = data.get(CONFIG_SNAPSHOT_SCHEMA_KEY, 0) if isinstance(data, dict) else 0\n if isinstance(raw, bool) or not isinstance(raw, int) or raw < 0:\n raise ConfigSnapshotVersionError(\n f\"config snapshot has a malformed {CONFIG_SNAPSHOT_SCHEMA_KEY}: {raw!r}\")\n return raw\n\n\ndef settings_from_snapshot(data: dict) -> Settings:\n \"\"\"Build re-entry Settings from a masked snapshot under its historical missing-field contract.\n\n Fails closed on a snapshot written by a NEWER binary. `Settings` is `extra=\"ignore\"`, so such a\n snapshot would otherwise load with every unrecognized control silently dropped — the run would\n resume on the same event history under different paid/concurrency/selection semantics, with\n nothing to show why. Refusing is the only honest answer: this binary cannot know what it is\n dropping. Older and pre-versioning snapshots keep loading exactly as before.\"\"\"\n found = config_snapshot_schema(data)\n if found > CONFIG_SNAPSHOT_SCHEMA:\n raise ConfigSnapshotVersionError(\n f\"this run's config.snapshot.json declares format v{found}, but this build understands \"\n f\"at most v{CONFIG_SNAPSHOT_SCHEMA}. It was written by a newer LoopLab; resuming here \"\n \"would silently drop settings this build does not know. Upgrade LoopLab to resume it.\")\n migrated = migrate_config_snapshot(data)\n migrated.pop(\"llm_api_key\", None)\n migrated.pop(CONFIG_SNAPSHOT_SCHEMA_KEY, None) # a document marker, never a Settings field\n return Settings(**migrated)\n"}, "ce06fadfe7cbdb4b740bdea512aa78647c82df8f": {"t": 1785768738.056969, "n": 116742, "h": "64de0ed0548ca641", "c": "\"\"\"Engine config: the schema (what settings exist). (I0, ADR-11)\n\nOne pydantic-settings class declaring every engine knob, its default, and its docs. The loader —\nhow a run file / CLI / env becomes a `Settings` — is `looplab.core.appconfig`, which also owns\nthe config-precedence order. A resolved, secret-masked snapshot is written next to each run for\nreproducibility. (No real secrets in P0, but the masking discipline is in place.)\n\"\"\"\nfrom __future__ import annotations\n\nimport re\nimport types\nimport typing\nfrom pathlib import Path\n\nfrom pydantic import Field, SecretStr, field_validator, model_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n# Single source of the LLM first-byte (response-headers) default — see core/llm.py.\nfrom looplab.core.llm import DEFAULT_HEADER_TIMEOUT_S\n\n# Default home for cross-run memory + the knowledge base (shared across every run). Both are ON by\n# default now (a user asked for it) so a fresh install accumulates + consults knowledge with no setup;\n# set the env var to \"\" to disable, or to a path to relocate. `~/.looplab/` keeps them out of any one\n# project + shared across projects.\n_LL_HOME = Path.home() / \".looplab\"\n\n# What one `llm_profiles` entry may contain. Deliberately closed: an unrecognized field is a typo the\n# operator would otherwise never see fail, and the map is a public artifact (config.snapshot.json,\n# HTTP, LOOPLAB_*), so it must never accrete free-form keys.\n_PROFILE_FIELDS = frozenset({\"model\", \"base_url\", \"temperature\", \"api_key_env\"})\n# A variable name that `runtime/sandbox.py::SECRET_ENV` will recognize as holding a secret and strip\n# from generated code's environment. Duplicated here, not imported: layering forbids `core` from\n# importing `runtime`. `tests/test_secret_env_pattern.py` holds the two in agreement.\n_SECRET_ENV_NAME = re.compile(\n r\"\\A(?=[A-Z][A-Z0-9_]{0,63}\\Z)(?=.*(KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL)).*\\Z\")\n\n\ndef _is_numeric_annotation(ann) -> bool:\n \"\"\"True when a field's annotation admits int/float but NOT bool.\n\n Used by `Settings._reject_bool_for_numeric_fields`: pydantic resolves annotations at class build,\n so `model_fields[...].annotation` is a real type object here even under\n `from __future__ import annotations`. Optionals are numeric when every non-None member is\n (`int | None` yes, `int | str` no), so a boolean can never slip in through a union arm.\"\"\"\n if ann is bool:\n return False # a bool field legitimately takes a bool\n if ann is int or ann is float:\n return True\n args = [a for a in typing.get_args(ann) if a is not type(None)]\n origin = typing.get_origin(ann)\n if args and origin in (typing.Union, types.UnionType):\n return all(_is_numeric_annotation(a) for a in args)\n return False\n\n# Autonomous roles that may be granted per-setting write access (see Settings.agent_control).\n# strategist = A7 meta-controller (run-wide); boss = run-chat operator-proxy (run-wide);\n# researcher = per-experiment proposer (per-node sizing, e.g. eval timeout for a heavy model).\nAGENT_ROLES = (\"strategist\", \"boss\", \"researcher\")\n\n# A foresight score is computed for every acted-on proposal. Keep this lower than the generic\n# verifier's emergency ceiling so a typo cannot multiply paid synchronous calls across a run.\nMAX_FORESIGHT_VERIFY_SAMPLES = 8\n\n\n# Layer-2 parallelism renamed two public settings without removing the durable legacy fields. Keep\n# the equivalence in one place: config-source precedence, governance migration, and live strategy\n# application must all agree which names describe the same authority/value.\nPARALLELISM_ALIASES: dict[str, str] = {\n \"max_parallel\": \"eval_parallel\",\n \"parallel_build\": \"llm_parallel\",\n}\n\n\ndef parallelism_aliases(setting: str) -> tuple[str, ...]:\n \"\"\"Return every spelling in ``setting``'s alias family, canonical name first.\"\"\"\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if setting in (legacy, canonical):\n return canonical, legacy\n return (setting,)\n\n\ndef canonicalize_parallelism_source(\n values: dict, *, promote_build_to_llm_parallel: bool = False,\n) -> dict:\n \"\"\"Promote legacy parallel keys *within one config-precedence source*.\n\n Applying this only after file/CLI/env were flattened would let a lower-priority canonical env\n value shadow a higher-priority legacy file value (or a file canonical value shadow a CLI legacy\n override), so callers with sub-layers normalize each layer independently.\n\n ``max_parallel``/``eval_parallel`` are true aliases (pure eval width, no extra semantics) and are\n always promoted. ``parallel_build``/``llm_parallel`` are NOT: a positive ``llm_parallel`` also flips\n on the finite shared LLM broker (``orchestrator._startup_llm_total``), while legacy ``parallel_build``\n means \"legacy build width, unbounded overlap, no shared total\". So promoting a legacy-only\n ``parallel_build`` layer (e.g. just ``LOOPLAB_PARALLEL_BUILD=1`` in env) would silently serialize\n every provider call — contradicting llm_broker.py's docstring, orchestrator's \"legacy-only preserves\n unbounded overlap\" note, the broker-neutral live-control path, and the configuration.md ``llm_parallel``\n row (\"falls back ... without enabling a shared total\"). Config/startup loads therefore keep broker\n opt-in tied to an EXPLICITLY-spelled canonical ``llm_parallel``; the orchestrator's own width fallback\n (``llm_parallel if not None else parallel_build``) still carries the legacy build width. Only the\n Strategist live-control path (``canonicalize_strategy_parallelism``) opts into\n ``promote_build_to_llm_parallel``, where ``parallel_build`` is a deliberate full alias of the\n canonical knob (see strategy.py ``_apply_strategy``).\n \"\"\"\n if not isinstance(values, dict):\n return values\n out = dict(values)\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if canonical == \"llm_parallel\" and not promote_build_to_llm_parallel:\n continue\n # None is the durable legacy mode for llm_parallel, not a missing value. A normal snapshot\n # contains llm_parallel:null plus parallel_build:1; promoting null to 1 would unexpectedly enable\n # the global one-call broker. Promote aliases only when the canonical key is absent, and pin\n # snapshot round-trip parity. Canonicalize inside each precedence layer, never across the\n # flattened result, so the alias migration cannot invert CLI > file > env precedence.\n if canonical not in out and out.get(legacy) is not None:\n out[canonical] = out[legacy]\n return out\n\n\ndef flatten_parallelism_layers(layers) -> dict:\n \"\"\"Flatten same-tier precedence layers (LOWEST priority first) and resolve the legacy pair.\n\n PRECEDENCE REPAIR for the non-promoted `parallel_build`/`llm_parallel` pair. Declining to promote\n in `canonicalize_parallelism_source` is right (a legacy-only layer must not flip on the shared\n broker), but it let a LOWER-priority canonical value survive a HIGHER-priority legacy override:\n file `llm_parallel: 2` + CLI `-s parallel_build=8` flattened to BOTH keys, and the orchestrator\n prefers `llm_parallel`, so the operator's explicit CLI width was silently discarded. When a higher\n layer names the legacy width over a lower layer's canonical one, mask with the LEGACY SENTINEL\n (`llm_parallel=None` is the durable \"legacy mode — build width from parallel_build, no shared\n total\"), so the higher layer wins with the semantics it asked for.\n\n The mask lives HERE, not in the per-layer canonicalizer, because only a flatten knows the layer\n ORDER. Stamping it per layer meant any layer naming `parallel_build` emitted `llm_parallel=None`\n even when NO layer in the tier set the canonical key — and since pydantic-settings ranks init\n kwargs above env, that sentinel then silently overrode a `LOOPLAB_LLM_PARALLEL` the operator had\n exported as a deliberate provider rate/cost ceiling. Precedence is per FIELD: a tier that never\n spelled `llm_parallel` has not set it, and it must fall through to env/.env/defaults — which is\n exactly what `build_settings`' docstring promises (\"the merged dict wins only where it actually\n sets a value\").\n \"\"\"\n merged: dict = {}\n legacy_at = canonical_at = -1\n for index, layer in enumerate(layers):\n if not isinstance(layer, dict):\n continue\n merged.update(layer)\n if layer.get(\"parallel_build\") is not None:\n legacy_at = index\n if \"llm_parallel\" in layer:\n canonical_at = index\n # Only when this tier DID set the canonical key, at a strictly lower layer than the legacy one.\n if canonical_at >= 0 and legacy_at > canonical_at:\n merged[\"llm_parallel\"] = None\n return merged\n\n# Settings whose semantics are committed by ``run_started`` and restored from the folded event log\n# on every re-entry. Changing one only in ``config.snapshot.json`` would mix incompatible evidence\n# (a different holdout split) or make the live producer disagree with replay's selection policy.\n# The HTTP config editor and the engine both consume this one contract; keep deliberately mutable\n# controls such as ``trust_gate`` out (that field has its own durable change event).\nRUN_START_PINNED_FIELDS = frozenset({\n \"card_driven_selection\",\n \"speculation_depth\",\n \"holdout_fraction\",\n \"holdout_select\",\n \"select_verifier\",\n \"select_verifier_samples\",\n \"verifier_ci_tie\",\n})\n\n\ndef run_start_pinned_settings(state) -> dict:\n \"\"\"Return the effective Settings names committed by a folded run-start record.\n\n Pre-pin legacy logs have no recorded holdout fraction, so their snapshot remains authoritative\n for both holdout knobs. Verifier fields, however, have always been re-pinned from the legacy-safe\n fold defaults whenever a valid ``run_started`` identity exists.\n \"\"\"\n if not getattr(state, \"run_id\", \"\"):\n return {}\n values = {\n # Layer 3 changes which queue owns macro-action selection. Once a run starts, replay and\n # resume must keep the same owner even if the snapshot or ambient default changes later.\n \"card_driven_selection\": bool(getattr(state, \"card_driven_selection\", False)),\n # Layer 5 overlaps Card production with evaluation only when this pinned treatment is\n # positive. Old logs omit it and therefore retain the exact serial build/eval spine.\n \"speculation_depth\": int(getattr(state, \"speculation_depth\", 0)),\n \"select_verifier\": bool(getattr(state, \"select_verifier_tiebreak\", False)),\n \"select_verifier_samples\": int(getattr(state, \"select_verifier_samples\", 3)),\n \"verifier_ci_tie\": bool(getattr(state, \"verifier_ci_tie\", False)),\n }\n holdout_fraction = getattr(state, \"holdout_fraction\", None)\n if holdout_fraction is not None:\n values.update({\n \"holdout_fraction\": holdout_fraction,\n \"holdout_select\": bool(getattr(state, \"holdout_select\", False)),\n })\n if not values.keys() <= RUN_START_PINNED_FIELDS:\n raise RuntimeError(\"folded run-start pinned settings contract drifted\")\n return values\n\n# --- Run profiles (config-first presets) -------------------------------------------------------\n# A `profile` is a NAMED BUNDLE of setting defaults — nothing more. It only fills fields the user\n# did NOT set explicitly (via file / --set / LOOPLAB_* env / init-kwargs), so any explicit knob\n# always wins (see `_apply_profile`). Most engine intelligence lives behind individual flags. The\n# PART IV/V cross-run + concept-graph machinery (concept_pivot, graded_novelty, cross_run_concepts /\n# _advisory / _structured_claims / _curation / _read_tools, fingerprint_universal, foresight_verify)\n# now ships ON in the product `Settings` default. That is an explicit experimental product choice,\n# not a claim that a frozen A/B or workload-wide cost/quality gate has cleared: the bundle can steer\n# prompts and candidate generation, change graded-novelty admission / pre-execution choices, and add\n# paid LLM work when its client and memory prerequisites are present. The bare-library `EngineOptions`\n# default stays lean (see tests/test_options_divergence.py — product side is the aggressive one) so a\n# toy `Engine(...)` in a test doesn't fire cross-run LLM work unasked. The trust/confirm/ablate QUALITY\n# bundle still ships OFF and is turned on by `thorough`. Every value here is reachable by hand — the\n# profile is a convenience, never a hidden mode.\n# keep this rationale behavioral and evidence-based; do not relabel read-only storage\n# access or proposal-only governance as a no-op for agent behavior, cost, or proposal admission.\n#\n# `thorough` deliberately touches only QUALITY/TRUST machinery, not spend: it does NOT raise\n# max_nodes / eval_parallel (those are cost knobs the user owns). It enables multi-seed confirmation\n# (demote seed-lucky leaders), the reward-hack / leakage / critic monitors AND flips them from\n# advisory to *gating* (`trust_gate=\"gate\"` — a\n# flagged win can no longer be selected as best), ablation-driven refinement, and the prompt cues\n# (complexity / budget / failure-reflection / watchdog-reflection). Agentic novelty and the experimental\n# Part IV/V bundle are already product defaults, not profile changes. Effect is large; marginal cost is a\n# few extra evals.\nPROFILES: dict[str, dict] = {\n \"default\": {}, # explicit no-op alias for \"ship-as-is\" (== omitting profile)\n \"fast\": {}, # today's lean defaults, named for symmetry with `thorough`\n \"thorough\": {\n \"confirm_top_k\": 3,\n \"confirm_seeds\": 3,\n # novelty_gate intentionally NOT enabled here: duplicate-detection is the agentic Researcher's\n # job (it reads past experiments + find_analogous_across_runs and DECIDES), not an algorithmic\n # param-distance / embedding auto-reject that mis-fired (it nudged a good fresh idea onto a\n # twice-dead lineage — live node 61). Search stays as tools; the LLM judges novelty.\n \"operator_bandit\": True, # P4: adaptive operator mix over folded yields (GreedyTree)\n \"reward_hack_detect\": True,\n \"code_leakage_detect\": True,\n \"critic_check\": True,\n \"trust_gate\": \"gate\", # detectors stop gating a flagged node from WINNING (not just audit)\n \"ablate_every\": 3,\n \"complexity_cue\": True,\n \"budget_aware\": True,\n \"failure_reflection\": True,\n \"watchdog_reflection\": True, # feed recent live-watchdog (train-monitor/ASHA) flags to proposals\n \"reflection_priors\": True, # no-op unless memory_dir is set (cross-run priors)\n },\n}\n\n\n# The shipped agent-governance matrix (who may change which setting at runtime). Defined as a module\n# constant so the Engine's direct-construction default matches this shipped default exactly (the\n# EngineOptions \"Engine() == shipped defaults\" invariant) instead of diverging to an all-locked map.\nDEFAULT_AGENT_CONTROL: dict[str, list[str]] = {\n \"timeout\": [\"researcher\", \"strategist\"],\n # Layer-2 canonical names are the presented/default governance surface. `_agent_may` falls back\n # to an old snapshot's legacy grant only when its canonical counterpart is absent, so removing\n # duplicate defaults preserves resume authority while an explicit canonical revocation still wins.\n \"eval_parallel\": [\"strategist\"],\n \"llm_parallel\": [\"strategist\"],\n # Strategy-only pseudo-setting: the closed per-lane allocation inside the canonical LLM total.\n # It is not a launch Settings field; operators pin it through `set_strategy` and the Strategist\n # may reallocate it only while this grant is present.\n \"llm_lane_limits\": [\"strategist\"],\n \"max_nodes\": [\"strategist\", \"boss\"],\n \"max_eval_seconds\": [\"strategist\", \"boss\"],\n \"policy\": [\"strategist\"],\n # The policy NAME and its PARAMS are gated independently in `_apply_strategy`; grant both by default\n # so the Strategist that may switch to (say) MCTS may also apply the `c`/`eta` it decided — else its\n # tuned params are silently dropped and the recorded `active_strategy` diverges from the live engine.\n \"policy_params\": [\"strategist\"],\n \"n_seeds\": [\"strategist\"],\n \"ablate_every\": [\"strategist\"],\n \"merge_mode\": [\"strategist\"],\n \"complexity_cue\": [\"strategist\"],\n \"ablate_code_blocks\": [\"strategist\"],\n \"prefer_sweep\": [\"strategist\"],\n \"novelty_stance\": [\"strategist\"],\n \"developer\": [\"strategist\"],\n \"fidelity\": [\"strategist\", \"researcher\"],\n}\n\n\ndef default_agent_control() -> dict[str, list[str]]:\n \"\"\"A FRESH deep-ish copy of the shipped governance matrix (each role list copied so a caller can\n mutate its own map without touching the module constant). The SINGLE constructor of the default,\n called by both `Settings.agent_control`'s default_factory AND the Engine's direct-construction\n default — so \"Engine() == shipped defaults\" can't drift if the copy depth ever needs to change\n (e.g. nested values requiring copy.deepcopy), instead of two verbatim copy expressions.\"\"\"\n return {k: list(v) for k, v in DEFAULT_AGENT_CONTROL.items()}\n\n\nclass Settings(BaseSettings):\n \"\"\"The engine settings schema (every knob a run accepts).\n\n Timeout family — six distinct knobs, each owned by a different subsystem:\n - `timeout`: per-eval wall-clock budget for ONE experiment's evaluation (engine/eval).\n - `max_eval_timeout`: hard ceiling for a governed per-node Researcher timeout override.\n - `llm_timeout`: LLM request idle timeout — inter-token stall limit in stream mode\n (core.llm OpenAICompatibleClient).\n - `llm_header_timeout`: LLM first-byte (response-headers) window for stream attempts (core.llm).\n - `agent_time_budget_s`: wall-clock ceiling across a tool-using agent loop's turns (agents).\n - `dep_install_timeout`: per-package budget for auto-installing a missing dep (env self-prep).\n\n Config-source precedence is owned by `looplab.core.appconfig` (the loader) — see its module\n docstring for the one canonical order.\n \"\"\"\n # Config precedence: see appconfig.py — the loader owns the full order. The `.env` is read\n # so `looplab run`/`looplab ui` pick up LOOPLAB_LLM_BASE_URL etc. without exporting them by hand\n # (keys MUST carry the LOOPLAB_ prefix, same as the env vars). utf-8-sig tolerates a BOM from\n # Windows editors. The test suite disables this (tests/conftest.py) so a dev's real .env in the\n # repo root can't leak into assertions.\n model_config = SettingsConfigDict(\n env_prefix=\"LOOPLAB_\",\n env_file=\".env\",\n env_file_encoding=\"utf-8-sig\",\n extra=\"ignore\",\n allow_inf_nan=False,\n )\n\n @classmethod\n def settings_customise_sources(\n cls, settings_cls, init_settings, env_settings, dotenv_settings,\n file_secret_settings):\n \"\"\"Preserve source precedence across canonical/legacy parallelism names.\n\n Pydantic ranks init > env > dotenv correctly, but it ranks field names rather than semantic\n alias families. Promoting a legacy value inside each source before the merge makes an old\n explicit snapshot/file value beat a lower-priority canonical env value. ``appconfig`` also\n promotes its file/typed/--set sub-layers before combining them into the init source.\n \"\"\"\n def _with_parallel_aliases(source):\n return lambda: canonicalize_parallelism_source(source())\n\n return (\n _with_parallel_aliases(init_settings),\n _with_parallel_aliases(env_settings),\n _with_parallel_aliases(dotenv_settings),\n _with_parallel_aliases(file_secret_settings),\n )\n\n # Run profile (config-first preset, see PROFILES above). \"default\"/\"fast\" = today's lean\n # defaults; \"thorough\" turns the built quality/trust machinery ON in one word. A profile only\n # fills fields the user did NOT set — every knob it touches stays individually overridable.\n profile: str = \"default\"\n\n # Bounds (review C/⚪): a non-positive node/seed budget or timeout is never valid. Upper bounds are\n # equally load-bearing — the UI /start path writes these straight into the engine's env, and an\n # unbounded `eval_parallel` (or legacy `max_parallel`; e.g. 100000 from a crafted preflight) would\n # fan out that many sandboxes → resource exhaustion. Reject at config time, not mid-run. Ceilings\n # are generous; tests top out near 50.\n # Pydantic's lax mode coerces a JSON boolean into any numeric field (`true` -> 1), so a UI/API type\n # error used to silently collapse a run budget to ONE node instead of returning 422. Rejected for\n # EVERY numeric field by `_reject_bool_for_numeric_fields` below, not just the ones bounded here.\n n_seeds: int = Field(default=3, ge=1, le=1024)\n max_nodes: int = Field(default=8, ge=1, le=1_000_000)\n # legacy evaluation-width input. `eval_parallel` below is canonical and wins when set;\n # keep this field for old env/config/snapshots and direct Engine call sites. A width > 1 admits\n # concurrent evals, whose resource reservations may be CPU-only or span one/more GPUs — there is no\n # one-node/one-GPU promise. `max_parallel=0` is launch-time AUTO (detected GPU count, at least 1).\n max_parallel: int = Field(default=1, ge=0, le=1024)\n # legacy build-width input. `llm_parallel` below is canonical and also controls the\n # shared provider-call total when positive. A width > 1 uses fresh pooled researcher/developer pairs;\n # it says nothing about the later eval's CPU/GPU reservation. Startup `0` derives build fan-out from\n # resolved eval width; a live Strategist/operator zero settles to 1. Without role_factory it clamps to 1.\n parallel_build: int = Field(default=1, ge=0, le=64)\n # Hypothesis-card Kanban re-architecture (docs/23, Layer 2). Two INDEPENDENT concurrency axes,\n # decoupled from each other so cost (LLM work) can be tuned separately from throughput (GPU evals).\n # These are the CANONICAL names going forward; `max_parallel`/`parallel_build` above are kept as thin\n # back-compat aliases (env vars / snapshots / ~100 Engine(...) call sites depend on the exact names,\n # so they cannot be renamed — CLAUDE.md). Resolution (orchestrator.__init__): a NEW name, when set,\n # WINS over its legacy alias; None => fall back to the legacy field => byte-identical to today.\n # `eval_parallel`: concurrent EVALS (the GPU/experiment consumer). None -> max_parallel; 0 = AUTO\n # (one experiment per detected GPU). This is the in-Run eval-concurrency ceiling; the resource\n # scheduler bin-packs declared footprints separately. Sibling local GPU-owning Runs serialize\n # through the conservative host-pool lease; external execution needs its own admission boundary.\n # `llm_parallel`: total concurrent LLM provider calls + BUILD fan-out. None -> legacy\n # parallel_build semantics without a shared total; 0 = startup AUTO (= resolved eval_parallel\n # for build fan-out, historical research overlap unbounded). A positive canonical value enables\n # the named-lane broker the Strategist can reallocate at cadence.\n eval_parallel: int | None = Field(default=None, ge=0, le=1024)\n llm_parallel: int | None = Field(default=None, ge=0, le=64)\n # Training-log monitor (I-series watchdog family): a per-eval background observer that tails the live\n # training log while a (often multi-hour) declared stage runs in a worker thread. ON in the product\n # surface (Settings). Its alert event is fold-ignored and cannot directly change lifecycle/champion;\n # with watchdog_reflection on, that alert is nevertheless advice in a later Researcher\n # prompt and can change future proposals. It only fires on the command-eval path with an LLM client;\n # bare-library EngineOptions stays OFF. See test_options_divergence.\n # `_interval_s` is the BASE tick cadence (the effective one adapts to the per-experiment budget).\n train_monitor: bool = True\n train_monitor_interval_s: float = Field(default=600.0, gt=0)\n # Phase 3 — INTERVENTION (opt-in, separate from observation): let the monitor tree-kill a training the\n # LLM judges 'broken' (diverged / silent CPU fallback / not learning) EARLY, instead of burning the\n # whole budget. Off by default — the observer only WATCHES unless this is on. A kill only fires on a\n # 'broken' verdict (a plateau is 'watch', never killed) with confidence >= the threshold; the node then\n # fails normally (reason='monitor_broken'), so replay reconstructs it from that one terminal event.\n train_monitor_kill: bool = False\n train_monitor_kill_confidence: float = Field(default=0.8, ge=0.0, le=1.0)\n # ASHA live-curve watchdog (sibling of the training monitor): reads the latest INTERMEDIATE value of\n # the objective metric off the live log (reusing the eval's OWN metric reader). Finished-endpoint rank\n # remains advisory. An opt-in KILL additionally requires an operator-declared metric.resource_key and\n # enough sibling observations at exactly that resource; absent comparable evidence cannot stop a run.\n # Records a fold-ignored `asha_rank` diagnostic + trace span; the library default is OFF (off == today).\n asha_live: bool = True\n # Opt-in INTERVENTION (separate from the advisory signal): let the watchdog tree-kill a node whose\n # intermediate metric stays below the SAME-RESOURCE bar past a short grace window. Off by default —\n # it only surfaces endpoint rank unless this is on. A kill fails normally (asha_underperforming).\n asha_live_kill: bool = False\n # The bar sits at `asha_live_quantile` along a WORST->BEST ordering of the finished siblings' finals:\n # 0.5 = the median (a node worse than the median peer flags); a SMALLER value is more conservative —\n # it lowers the bar toward the WORST finished peer, so only a node worse than nearly all peers flags\n # (0.0 = worse than the worst). `min_siblings` is the floor of finished peers required before it ranks\n # at all, so it never acts on too little evidence.\n asha_live_quantile: float = Field(default=0.5, ge=0.0, le=1.0)\n asha_live_min_siblings: int = Field(default=3, ge=1)\n timeout: float = Field(default=30.0, gt=0)\n # Hard ceiling for the Researcher's governed per-node `Idea.eval_timeout`. One hour preserves the\n # existing heavy/neural-net authoring example while staying well below the sandbox's defensive\n # 24-hour subprocess ceiling. This is operator-owned run config: agents may request less or more,\n # but the accepted action is always clamped here after the `agent_control.timeout` permission gate.\n max_eval_timeout: float = Field(default=3600.0, gt=0, le=24 * 3600.0)\n # Intra-node sweep: a sweep node runs a whole grid in one process, so it gets this multiple of\n # `timeout` as its wall-clock budget (solution.py path; RepoTasks use their per-profile timeout).\n sweep_timeout_mult: float = Field(default=8.0, ge=1)\n # Eval STALL watchdog CAP (seconds): a stage that is completely SILENT on stdout/stderr for this\n # long — while still alive and BELOW its wall-clock deadline — is tree-killed early with a STALLED\n # marker (a hung dataloader/deadlock dies in minutes instead of burning a multi-hour timeout). The\n # per-stage window is `min(this, the stage's own timeout)`, so a short scorer's deadline fires first.\n # `0` DISABLES the watchdog entirely (only the hard deadline applies) — for a legitimately quiet\n # non-Python stage (block-buffered stdout, a script that logs only to its own file). Threaded into\n # the eval and surfaced to the Developer so its code can emit periodic progress to stay alive.\n eval_stall_timeout_s: float = Field(default=1800.0, ge=0)\n # Sandbox tier (ADR-13): \"trusted_local\" (subprocess, no Docker) for the CLI;\n # \"untrusted\" (Docker --network none, shared-kernel runtime) for hosted/multi-tenant UI;\n # \"hostile\" (untrusted + a true-isolation OCI runtime, gVisor `runsc` by default / Kata) for\n # running untrusted code. See runtime/sandbox.py::make_sandbox for the tier selection.\n trust_mode: str = \"trusted_local\"\n # Image for the untrusted command-eval tier (RepoTask, Phase 4): the framework's deps\n # should be baked in (the container runs --network none, so a pip setup can't fetch).\n docker_image: str = \"python:3.12-slim\"\n # Resource caps for the untrusted/hostile Docker tier (passed to `docker run --memory/--cpus`).\n # The default 4g bound protects other tenants from an OOM, but a model-training MLE-bench eval\n # routinely needs more — raise it (e.g. \"16g\") for such runs. \"\" disables the cap (unbounded).\n # Ignored by the trusted_local (subprocess) tier.\n sandbox_memory: str = \"4g\"\n sandbox_cpus: str = \"\"\n # Best-effort host-OOM guard for the TRUSTED-LOCAL (subprocess) tier. When set (e.g. \"8g\"), each\n # eval child gets an RLIMIT_AS address-space cap so a runaway trainer hits MemoryError instead of\n # OOM-killing the whole host (and the engine). POSIX only; a no-op on Windows. Default \"\" = OFF:\n # RLIMIT_AS bounds VIRTUAL memory, and CUDA/torch reserve tens of GB of virtual without using it,\n # so a default cap would spuriously kill GPU evals — leave it off for those and use the Docker\n # tier's real --memory cgroup bound (sandbox_memory) instead. Ideal for CPU/numpy/sklearn runs.\n sandbox_memory_local: str = \"\"\n # Best-effort disk-fill guard for the TRUSTED-LOCAL tier (P1-5): an RLIMIT_FSIZE cap on the size of\n # any single file an eval child writes (e.g. \"2g\"), so a runaway that writes an unbounded file gets\n # SIGXFSZ instead of filling the host disk. POSIX only. Default \"\" = OFF (large model checkpoints\n # need big files); set it for tasks that write only small artifacts.\n sandbox_fsize_local: str = \"\"\n # Search policy (ADR-2): \"greedy\" | \"evolutionary\" | \"mcts\" | \"asha\".\n policy: str = \"greedy\"\n # Ablation-driven refinement (I7): every N improves, ablate the best to find the\n # highest-impact parameter and refine it. 0 = off. (GreedyTree only.)\n ablate_every: int = 0\n # A0a (MLE-STAR): when ablating, treat each generated pipeline *code block* (not just a\n # numeric param) as an ablation unit — neutralize a block, measure the metric delta, refine\n # the highest-impact block. Off => the classic numeric-param ablation. Config-first knob.\n ablate_code_blocks: bool = False\n # A0b: real merge/ensembling. \"mean\" = legacy mean-param merge; \"ensemble\" = the Developer\n # writes a code-recombination ensemble over the two parents' solutions (verified: agent-proposed\n # ensembling 37.9%->43.9%). \"auto\" (default) = ensemble whenever the Developer actually\n # generates code (LLM/agent backends; declared via `is_code_generating`), mean for the\n # templated/toy developers where a code ensemble is meaningless — the evidence says code\n # recombination is the strongest single merge (removing it costs ~9 pp), so it is the default\n # wherever it can work. Falls back to mean when the Developer can't ensemble.\n merge_mode: str = \"auto\"\n # A0d (AIRA): inject a dynamic complexity hint into the draft/improve prompt keyed on the\n # node's child count (few children -> keep minimal; many -> escalate to ensembling/HPO).\n complexity_cue: bool = False\n # I1 (CAAFE-style) LLM feature-engineering: instruct the proposer/developer to add\n # semantically-meaningful engineered features (ratios, interactions, aggregations) as code. The\n # existing cross-validation eval + best-selection is the CV GATE — a feature set that doesn't\n # improve CV is never selected (feature engineering is non-universal, so this gate is mandatory).\n # Tabular tasks (those exposing column data). Off by default.\n feature_engineering: bool = False\n # A5: surface the REMAINING eval-compute budget into the proposal prompt so the Researcher can\n # reason over how much compute is left (explore broad while flush, exploit/cheapen when low).\n # No-op unless a max_eval_seconds budget is set.\n budget_aware: bool = False\n # A4 (LATS-style): feed a summary of the most recent FAILED branches (operator + error reason)\n # back into the proposal prompt so the proposer reflects on and avoids repeating them. ON by\n # default: it is SELECTIVE by construction (injects only when recent failures exist — the\n # evidenced form of reflection; reflecting at every step shows no gains, arXiv:2506.12928).\n failure_reflection: bool = True\n # Feed the LIVE-WATCHDOG observations (train-monitor health verdicts + ASHA intermediate-rank flags)\n # of recent experiments back into the proposal prompt, so the Researcher avoids re-proposing a config\n # whose TRAINING was already observed to be weak — even when the watchdog kills are off (default) and\n # the node ran to completion (those diagnostics are fold-ignored, so failure_reflection never sees\n # them). ON by default and SELECTIVE like failure_reflection (injects only when a recent flag exists).\n watchdog_reflection: bool = True\n # C3 deep test-driven repair: hand the Developer the failure taxonomy + a structured \"reproduce\n # then fix\" directive on debug, not just the raw stderr tail. ON by default (product surface); the\n # conservative library default in EngineOptions stays off, per that module's contract.\n deep_repair: bool = True\n # === Inline crash repair ==============================================================\n # Hybrid in-node crash repair: when an LLM-generated node CRASHES at runtime (mechanical errors\n # — bad import, removed kwarg, typo), the agent triages it and may repair the code IN PLACE within\n # the same eval (cheap; does NOT consume max_nodes and does NOT add a node to the search tree).\n # Semantic failures still flow to a new idea/debug node via the policy. ON by default; set False\n # to restore the prior behavior (every crash waits for a budgeted inter-node debug node).\n inline_repair: bool = True\n # Max in-place repair attempts per node before it fails normally. Default 0 = UNLIMITED: the same\n # agent keeps fixing + re-evaluating IN THE SAME node until it produces a clean metric — no new\n # node just to retry. Runaway is prevented not by a count but by the anti-stuck guard\n # (`inline_repair_stuck_repeat`): when the SAME error signature repeats with no progress, or the\n # agent's crash-triage says \"abandon\", the node fails. Set a positive N to cap the retries instead.\n inline_repair_attempts: int = Field(default=0, ge=0)\n # Anti-stuck: abandon in-node repair when the SAME error recurs this many times in a row (no\n # progress). Keeps \"unlimited\" repair from looping forever on an unfixable error.\n inline_repair_stuck_repeat: int = Field(default=4, ge=2)\n # Which failure reasons (_failure_reason: crash|timeout|oom|setup|no_metric|drift) are eligible for\n # inline repair. Default: mechanical crashes, timeouts AND OOM-kills — a timeout/OOM means the code\n # was too slow / too memory-hungry for the budget (or the pod's cgroup limit), not that the idea is\n # wrong, so the agent repairs it by reducing compute/memory (fewer estimators/epochs/folds/seeds, a\n # lighter model, a smaller batch, subsampling) instead of letting the node die unrecovered. Env\n # override expects a JSON array.\n inline_repair_reasons: tuple[str, ...] = (\"crash\", \"timeout\", \"oom\")\n # Cost ceiling for in-node repair of a MULTI-STAGE eval: the max number of FULL pipeline re-runs\n # (i.e. re-trains from the first stage) the inline-repair loop may do before abandoning to the\n # inter-node debug operator. A late-stage failure (a broken `score`/eval script that didn't touch\n # `train`) REUSES the completed train checkpoint and re-runs only from the failed stage — that is\n # CHEAP and NOT counted here; only a repair that changes an EARLIER stage's code (train.py/loss.py/…)\n # forces a full re-train, which each costs the whole training time. Without this bound, a repair that\n # keeps changing training code could burn many full trains (the anti-stuck guard is error-signature\n # based, not cost based). 0 = unlimited (legacy behavior). Single-command evals ignore it.\n inline_repair_retrain_cap: int = Field(default=2, ge=0)\n # Environment self-prep: when a solution crashes purely because a KNOWN library isn't installed\n # (ModuleNotFoundError), the engine pip-installs it into the eval interpreter and re-runs — so a\n # torch/XGBoost/CatBoost idea (e.g. a GRU model) actually runs on a fresh box instead of being\n # thrown away as `idea_rejected`. Trusted_local tier only (the untrusted/hostile Docker tiers run\n # --network none). A typo'd or local helper module is NOT installed (that's a code bug to repair).\n auto_install_deps: bool = True\n # Per-package wall-clock budget for an auto-install (seconds). Generous: large wheels like torch\n # can take minutes to download/build.\n dep_install_timeout: float = Field(default=900.0, gt=0)\n # --- Agent governance: who may change a setting at runtime (per-setting allow-list of roles) ---\n # Roles: \"strategist\" (A7 meta-controller; run-wide), \"boss\" (run-chat operator-proxy; run-wide),\n # \"researcher\" (per-experiment proposer; PER-NODE — e.g. set a longer eval timeout for a neural-net\n # idea). A setting ABSENT from this map is LOCKED — no agent may change it, only a human via the\n # snapshot/UI. The sole conditional exception is Strategy-only `card_scoring`: Card mode grants\n # it implicitly without changing the default flag-off snapshot, while an explicit [] revokes it.\n # The gate is ENFORCED at every AGENT seam via `_agent_may` — the strategist's whole\n # control surface (policy/operators/novelty_stance/prefer_sweep/developer/fidelity/timeout/\n # eval_parallel/llm_parallel) is checked in `_apply_strategy`, so removing a role from a knob locks it,\n # not just greys out the UI pill. The boss/max_* grants document boss-relevant knobs, but a\n # `budget_extend` is a HUMAN control intent (the boss action-builder can only emit `add_nodes`), so\n # it is applied as-is — the human always wins via the UI/snapshot. Defaults are conservative: resource/\n # search-shape knobs the agents already reason about; provider infra (llm_model/base_url/api_key,\n # docker_image) stays locked. The UI shows S/B/R pills per setting. `timeout` granted to the\n # researcher IS the \"auto\" per-node mode — the researcher sizes each experiment's wall-clock\n # (Idea.eval_timeout); the config `timeout` is the fallback for nodes it doesn't size. Env override\n # expects a JSON object. (`fidelity`/`novelty_stance`/`prefer_sweep`/`developer` are governance keys\n # for the strategist's per-node dials — not 1:1 Settings fields, but gated the same way.) The default\n # lives in the module-level DEFAULT_AGENT_CONTROL so the Engine's direct-construction default matches.\n agent_control: dict[str, list[str]] = Field(default_factory=default_agent_control)\n # C1 (Agentless localization): for repo tasks, rank the source files most relevant to the most\n # recent failure (traceback + identifiers) and surface them in the proposal/repair prompt so the\n # Developer edits the right place. Off by default; repo tasks only.\n localize_faults: bool = False\n # A2 surrogate-guided proposal (BO-lite): fit a k-NN surrogate over (params->metric) and propose\n # by acquisition instead of random/hill-climb. Numeric-bounds tasks only; bootstraps via the\n # base Researcher. Off by default. `surrogate_explore` = UCB-style exploration weight.\n surrogate_proposer: bool = False\n surrogate_explore: float = 0.1\n # E2 researcher panel: generate K candidate ideas per proposal and keep the one ranked best by a\n # cheap empirical surrogate over the (params->metric) history (NOT an LLM-judge). 1 = off.\n researcher_panel: int = 1\n # A1 ASHA / successive-halving: reduction factor (keep top 1/eta per rung) and rung budget.\n asha_eta: int = Field(default=3, ge=2)\n # Rung-0 width for ASHA/BOHB (the wide base of cheap drafts). 0 = use n_seeds (default,\n # preserving prior behavior); set >0 to seed a wider/narrower base independent of n_seeds.\n asha_rung_nodes: int = Field(default=0, ge=0)\n # A6 proxy/predictive scoring: cheaply rank a candidate's potential from early-stage signals\n # and skip a full eval for the doomed bottom fraction. 0.0 = off (no candidate skipped).\n proxy_scoring: bool = False\n proxy_kill_fraction: float = Field(default=0.0, ge=0.0, le=0.9)\n # === Novelty / dedup ==================================================================\n # E1 novelty/dedup gate: reject near-duplicate proposals (normalized param-space distance below\n # `novelty_epsilon`) by deterministically nudging them off the duplicate — stops the search\n # wasting evals re-trying the same idea. Audit event `novelty_rejected`. Off by default.\n # Novelty/dedup MODE selector: how a fresh proposal is checked against already-tried experiments.\n # \"off\" — no explicit gate; the agentic Researcher's own read-the-history judgment is trusted.\n # \"algo\" — the deterministic gate: numeric param-distance (`novelty_epsilon`) + optional embedding\n # similarity (`novelty_semantic`). Cheap, no LLM call, but can't explain itself.\n # \"llm\" — an LLM adjudicates (reads the real experiments via tools) whether the idea is a\n # near-duplicate and, if so, asks the Researcher once more for something different. One\n # extra LLM call per proposal — highest quality, follows the \"let the LLM decide\" line.\n novelty_mode: str = \"llm\"\n # Legacy sub-toggles, honored by the \"algo\" mode (and back-compat: novelty_gate=True forces \"algo\"):\n novelty_gate: bool = False\n novelty_epsilon: float = 0.05\n # T5 semantic novelty (active whenever the deterministic gate runs — novelty_mode=algo,\n # novelty_gate=true which is the legacy alias for algo, OR the Strategist novelty stance is\n # \"explore\"; a no-op only under novelty_mode=llm/off with a non-explore stance): reject a proposal\n # whose idea TEXT embeds within\n # `novelty_semantic_threshold` cosine of an existing node's. OFF BY DEFAULT: novelty is the agentic\n # Researcher's call, made by READING the actual prior experiments (read_experiment /\n # find_analogous_across_runs / list_experiments), NOT an embedding-similarity auto-reject that can't\n # explain itself and mis-fires on paraphrases. Semantic/param search stays available as TOOLS in the\n # Researcher's context; the LLM decides whether an idea is a true duplicate.\n novelty_semantic: bool = False\n novelty_semantic_threshold: float = Field(default=0.92, ge=0.5, le=1.0)\n # T10: debug-lineage depth bound for every policy — how many error-feedback repairs a failing\n # lineage gets before it is abandoned. The old default (1) abandoned lineages after ONE repair;\n # multi-turn/deeper debugging is a verified lever (AIRA2 ReAct debug +5.5 percentile points).\n debug_depth: int = Field(default=2, ge=1)\n # P4 operator bandit (GreedyTree): replace the fixed merge/ablate cadences with a\n # deterministic UCB over per-operator yield (Δmetric per eval-second, folded from the run).\n # Off by default (the cadences are well-tested and the bandit has no direct published\n # ablation); `thorough` turns it on.\n operator_bandit: bool = False\n # P1 hypothesis ledger: ask the Researcher to state the one-line hypothesis each experiment tests,\n # register deep-research directions as hypotheses, and track them to a verdict on the board. ON by\n # default. The board is shown to the Researcher and can be foresight-ordered, so it can\n # steer later proposals even though it never directly re-ranks already-evaluated nodes. Set False\n # to drop the prompt nudge + registration.\n track_hypotheses: bool = True\n # E4/M2/M3 reflection-memory -> priors (gradient-free cross-run meta-learning): at run end distill\n # the winner into `<memory_dir>/meta_notes.jsonl` AND structured lessons (incl. NEGATIVE results:\n # tested/abandoned hypotheses + failure themes) into `lessons.jsonl` with a task fingerprint; at run\n # start, inject exact-task notes + fingerprint-matched lessons from SIMILAR tasks into the proposal\n # prompt. ON by default — but a NO-OP until `memory_dir` is set (that's where cross-run memory lives).\n reflection_priors: bool = True\n # M6 comparative lessons (MARS \"comparative reflective memory\", doc 13 §7 item 2): distill\n # credit-assigned lessons from PAIRS of solutions — which SPECIFIC change made a child beat\n # (or regress from) its parent, and what fixed a failure — on top of the one-shot run-end\n # reflection. At most one extra LLM call per distillation (all pairs batched); offline a\n # deterministic param-diff credit stands in. Gated on reflection_priors + memory_dir like the\n # rest of cross-run memory — and since memory_dir has a default (~/.looplab/memory), this is\n # ACTIVE out of the box; clear memory_dir to disable cross-run memory wholesale.\n comparative_lessons: bool = True\n # M6 mid-run cadences (doc 13 §7 item 5 — the AgentRxiv live-share pattern): every N created\n # nodes, WRITE comparative lessons to the shared cross-run store during the run (lessons_every\n # — one batched LLM call per firing) and RE-READ the store so lessons distilled by CONCURRENT\n # runs reach this run's proposals (lessons_refresh_every; file re-read, no LLM call, skipped\n # when the store is unchanged). ON by default (every 4 nodes) and, like comparative_lessons\n # above, live out of the box because memory_dir defaults on; 0 = off — run-end write /\n # run-start read only, the pre-M6 behavior.\n lessons_every: int = Field(default=4, ge=0)\n lessons_refresh_every: int = Field(default=4, ge=0)\n # B3 output redaction: mask credentials (known key shapes + high-entropy tokens) in the\n # stdout/stderr tail before it is persisted to the event log / spans / UI — a leaked secret in a\n # print()/traceback must not land in the durable log. Off by default; recommend on for untrusted.\n redact_output: bool = False\n # B5 reward-hacking detector: a host-side monitor that flags suspicious wins (grader/answer-key\n # access, runtime writes to frozen files, suspiciously-perfect metrics) as a `reward_hack_suspected`\n # audit event in the Trust panel. Off by default. Whether a flag CHANGES selection is governed by\n # `trust_gate` below (default: audit-only, never changes selection).\n reward_hack_detect: bool = False\n # Trust enforcement (T2): what a reward-hack / data-leakage flag actually DOES to selection.\n # \"audit\" (default) = surface it in the Trust panel, never change selection (today's behavior);\n # \"gate\" = a flagged node can no longer be selected as BEST **and isn't bred/confirmed from**\n # (breed_excluded — see replay._apply_trust_gate / models.breedable_nodes), but stays\n # FEASIBLE so it still counts for diversity/audit — closes the \"a hacked/leaky node can\n # win OR seed its lineage\" hole;\n # \"block\" = additionally mark it fully INFEASIBLE (removed from feasible_nodes entirely).\n # Deliberately gates only high-precision CHEATING/LEAKAGE signals. Broad `critic:*` findings stay\n # advisory; `critic:hardcoded_metric` is the one narrow high-precision critic exception\n # and gates because it requires a literal metric with no computed assignment anywhere.\n trust_gate: str = \"audit\"\n # I3 data-centric: static code-leakage scan of each evaluated solution (fit-before-split,\n # fit-on-test) surfaced into the Trust panel alongside reward-hack flags. Off by default.\n code_leakage_detect: bool = False\n # 4.4 sandbox instrumentation (needs reward_hack_detect): after each eval, flag a RUNTIME write\n # to a protected/frozen file (an answer key or grader tampered mid-run) — behavioral evidence a\n # static code scan misses. ON by default; only fires when the reward-hack detector is on.\n workdir_audit: bool = True\n # D8 decoupled research Verifier: check every Deep-Research memo's CLAIMS against their cited\n # evidence (node ids / urls) before recording — synthesis is the documented weak link (Kosmos\n # 57.9% accurate). Deterministic layer always; an LLM rubric pass when the DeepResearcher has a\n # client. The verdict cannot change this run's node/champion, but finalize uses aligned supported\n # verdicts as the evidence gate for persisted D8 cross-run claims; unavailable/misaligned stays\n # unverified. ON by default (no-op with no memo/claims).\n research_verify: bool = True\n # C4 independent critic: an execution-free critic of each solution (stub / hardcoded-metric /\n # params-ignored; on host-graded tasks the metric checks become a submission-output check)\n # surfaced in the Trust panel. Broad findings are advisory; `critic:hardcoded_metric` can gate under\n # trust_gate=gate/block. Off by default.\n critic_check: bool = False\n # A7 Strategist (NEW, user-requested): optional LLM/rule meta-controller that picks the search\n # policy/allocator + operator mix + fidelity (+ Developer backend) per situation. Config-first:\n # every choice the Strategist makes is also a direct knob. Defaults to \"agent\" so the agent adapts\n # the search policy/operators/fidelity per situation; set \"off\" for static config-driven search.\n # \"agent\" = a tool-using Strategist that READS the run/data/sibling-runs/KB/memory (B1-guarded)\n # before deciding, instead of the single-shot \"llm\" call over aggregate stats.\n strategist_backend: str = \"agent\" # \"off\" | \"rule\" | \"llm\" | \"agent\" — default AGENTIC: the\n # Strategist READS the run/data/sibling-runs/KB/memory with tools before deciding, not a single-shot\n # call over aggregate stats. \"llm\" = the old non-agentic single-shot; \"rule\"/\"off\" = no LLM.\n strategist_every: int = Field(default=3, ge=1) # consult cadence (created nodes)\n # PART V (F1): concept CLASSIFIER re-tag + consolidation cadence, DECOUPLED from strategist_every. The\n # LLM concept map is heavier and slower-moving than a strategy consult, so it refreshes on its own\n # (sparser) interval. Researcher-authored idea.concepts still fold immediately at node_created — this\n # only paces the classifier-evidence + consolidation refresh (and the concept-coverage pivot snapshot).\n concept_retag_every: int = Field(default=30, ge=1)\n # === Confirmation & holdout ===========================================================\n # Multi-seed confirmation (I12, ADR-15): confirm the top-k under N seeds before\n # finishing. 0 disables (default). Only meaningful when eval has variance.\n confirm_top_k: int = 0\n confirm_seeds: int = 0\n # D1 seed-holdout discipline: the FIRST seed the confirm phase uses. Search evals run with the\n # implicit seed 0 (LOOPLAB_EVAL_SEED unset -> \"0\"), so a base of 1 keeps every confirm split\n # DISJOINT from anything the search optimized against — the confirm metric is then a\n # generalization signal, not a re-measurement (AIRA: selecting on a signal the search saw\n # overfits by 9-13 pp). Set 0 to restore the legacy overlapping seeds 0..N-1.\n confirm_seed_base: int = Field(default=1, ge=0)\n # D1 holdout-gated promotion (B6, Arbor-style): for host-graded tasks, reserve this fraction of\n # the held-out labels as a FINAL holdout partition the search never sees — every search/confirm\n # eval is scored on the remaining rows only, and at finish the val-top-k are re-scored on the\n # holdout partition (free: the predictions already exist). 0.0 = off (legacy full-label scoring).\n holdout_fraction: float = Field(default=0.25, ge=0.0, le=0.9)\n # Champion selection prefers the HOLDOUT metric among the nodes that have one (the val-top-k).\n # Recorded in run_started so replay applies the same rule; old logs fold to False (legacy pick).\n # The per-node val-holdout `generalization_gap` folds into the Trust panel either way.\n holdout_select: bool = True\n # How many val-leaders get a holdout evaluation at finish (host-graded tasks).\n holdout_top_k: int = Field(default=3, ge=1)\n # R1-c / Part IV — calibrated §12-verifier tie-break in best-selection. When a fresh node's robust\n # metric EXACTLY TIES an already-eligible node, the engine runs the §12 verifier (grounded on each\n # node's realized result, `selection_criteria`) and records a soundness score per node. The tie-break\n # then applies across the WHOLE selection path: the mean pick breaks a robust_metric tie by the score,\n # and — when `holdout_select` is on — the holdout-slot ranking prefers the sounder node so it isn't\n # denied a holdout eval, and the final holdout override breaks a holdout_metric tie by it too. Strictly\n # a TIE-BREAK — it can NEVER move a node ahead of a strictly-better metric (§21.7 advisory-never-\n # overrides). Best-effort: verification is LAZY (only fires on an actual tie) and bounded per cadence,\n # so a large/late tie stays on deterministic metric+id order unless one atomic treatment scores every\n # member. Recorded in run_started so\n # replay applies the same rule; old logs fold to False (legacy pick). Opt-in (default off); needs a\n # reachable LLM client. Enable only after validating calibration offline with `verifier.calibrate`.\n # See trust/verifier.py + core/fitness.py.\n select_verifier: bool = False\n # R1-d (§21.19): widen the verifier tie-break from EXACT-metric to a conservative STATISTICAL tie.\n # The band is capped by both the metric leader's SE and the pooled SE-of-difference, so challenger\n # variance cannot widen it and a significant difference is never a tie (§21.7). Off -> exact only.\n verifier_ci_tie: bool = False\n # Verifier sample count for `select_verifier` (the §12 repeated-sampling expectation). 3 tames the\n # single-shot variance; 1 = single-shot (cheaper, noisier).\n select_verifier_samples: int = Field(default=3, ge=1, le=32)\n # Budget (I13): hard wall-clock ceiling; the run aborts cleanly when exceeded.\n max_seconds: float | None = None\n # Eval-compute budget (#2): hard ceiling on cumulative time spent INSIDE evals (training\n # runs), separate from wall-clock. Survives resume (summed from the event log). The real\n # guard against a silent multi-hour sweep when an eval is a minutes-hours training run.\n max_eval_seconds: float | None = None\n # Cross-run memory (I19, ADR-10): if set, the best result of each run is stored as\n # a case here, and the cases become retrievable knowledge for future runs.\n memory_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"memory\"))\n # HITL (I21, ADR-11): pause for human approval of the final best before finishing.\n require_approval: bool = False\n # Diversity archive (I22): niche bucket width in parameter space.\n archive_resolution: float = 1.0\n # Coverage read-model (narrowing signal): at the strategist cadence, compute a breadth summary\n # (themes / param-niches / theme entropy / dominant-theme fraction) from the folded run, record\n # it as a `coverage_snapshot` audit event, and surface it into the Strategist's decision context.\n # Deterministic, cheap, and purely additive context — no search-behavior change on its own. On by\n # default (like the rest of the always-on situational context). See search/coverage.py.\n coverage_context: bool = True\n # PART IV Phase 2a live steering (§21.11/§21.13). When on, the `concept_retag_every` cadence (NOT\n # `strategist_every` — the producer gates on `_should_consult_concepts`, which uses the seed boundary\n # + `concept_retag_every`, default 30) records a\n # concept-graph coverage + uncovered-region snapshot (deterministic heuristic tagger over the\n # task-type skeleton) and, on an `explore` stance, the Researcher's novelty hint names the exact\n # uncovered regions (\"0 coverage in {negatives/external-mining, distillation} — go there\") instead\n # of the vague \"broaden\". ON by default in the product Settings (ce4a379), EngineOptions off: it\n # only enriches an already-mode-gated explore hint, never forces exploration, and no-ops for a task\n # with no curated concept skeleton. The snapshot is auditable, but its prompt cue can change future\n # candidate generation; it does not directly rewrite best-metric selection. See search/concept_graph.py.\n # DESIGN NOTE (2026-07-17 critique): now ON unconditionally, but a toy / non-ML task yields EMPTY concept\n # tags while still paying the per-node LLM concept cost (~10 calls/node with the full Part IV/V bundle).\n # Consider AUTO-QUIESCING: if the heuristic tagger returns empty for the first N nodes (no concept signal),\n # skip the LLM concept work for the run — keeps it on for ML tasks, free on toy/degenerate ones.\n concept_pivot: bool = True\n # PART IV Phase 2b — D3 graded novelty into the LIVE novelty gate (§21.4/§21.13). When on and the\n # task has a curated concept skeleton, the novelty gate first grades a proposal over the concept\n # graph: a level-4 \"same direction, DIFFERENT implementation\" or a level-5 \"re-opens a\n # wrongly-abandoned FAILED direction\" proposal is ALLOWED through unchanged — the flat LLM/semantic\n # dedup gate can't tell \"this DCL tweak\" from \"the whole DCL branch\" and would wrongly reject a\n # legitimate variant or never re-open a sound-but-killed direction (the node_63 archetype). Levels\n # 1/2/3 (identical / near-dup / prior-run) still fall through to the existing gate. Deterministic\n # (heuristic tagger, no LLM), audit event `novelty_graded`, replay-safe. ON by default in the product\n # Settings (ce4a379), EngineOptions off; no-ops for a task with no skeleton. See search/graded_novelty.py.\n graded_novelty: bool = True\n # PART IV Phase 2b — D7 capability-expansion forced-jump DIRECTIVE (§21.8/§21.13, issue #7). When on\n # and the concept-graph cadence detects action-space LOCK-IN (the search has stayed inside one D5\n # branch for a long consecutive streak) on an `explore` stance, the Researcher's novelty hint\n # ESCALATES from \"broaden\" to \"expand the ACTION SPACE — build the missing infra (external mining,\n # a new data pipeline, a different eval), do NOT swap another {locked} lever.\" The same\n # gate also stamps the proposal's authoritative operator as `expand`; that node competes normally\n # under SearchFitness and its separate operator yield is measured. It does not force a winner.\n # Reads the 2a concept-coverage snapshot, so it needs `concept_pivot` to record one. OPT-IN\n # (default off). See engine/proposal_cues.py + search/lock_in.py.\n capability_expansion: bool = False\n # PART IV cross-run Step 0 (§21.20.12/§21.20.13). Universal task-fingerprint tokenization: drop the\n # ASCII-only `[a-z0-9]` allowlist on goal keywords so a non-Latin goal (Russian, CJK, …) is NOT\n # silently dropped from its cross-run fingerprint (today such a goal keys only on kind/dir/metric and\n # never reaches SIMILAR-task priors/lessons/cases). Uses `[^\\W_]+`/`.casefold()` — same splitting as\n # before, any script. ON by default in the product Settings (ce4a379); the bare-library EngineOptions\n # default stays off, so a run pinned to it won't silently re-key a portfolio mid-flight. See engine/memory.py.\n fingerprint_universal: bool = True\n # PART IV cross-run Step 2 (§21.20). Fill graded-novelty's `prior_concepts` from the cross-run\n # ConceptCapsuleStore: at run end write a per-run concept capsule (the shipped `node_concepts` tags +\n # best outcome, keyed by task_fingerprint) to `memory_dir`; when a later SIMILAR run proposes an idea\n # whose concept was tried before, SURFACE the prior outcome as a `cross_run_prior` audit event (never\n # reject — D3 level 3). Audit-only, off the selection path; ON by default in the product Settings\n # (ce4a379), EngineOptions off. Needs a `memory_dir` to share capsules.\n # See engine/memory.py (ConceptCapsuleStore) + engine/novelty.py.\n # PREREQUISITES (this flag is NOT standalone): the WRITE needs per-run concept tags\n # (`node_concepts`, produced by `concept_pivot`/F1 tagging) — no tags => no capsule; the SURFACE path\n # runs inside the graded-novelty precheck, so it also needs `graded_novelty` on. With only this flag +\n # `memory_dir` it persists/surfaces nothing. Enable `concept_pivot` + `graded_novelty` alongside it.\n # A non-Latin (Russian/CJK) portfolio should ALSO set `fingerprint_universal`, else the capsule's goal\n # tokens are dropped and its fingerprint over-matches on kind/dir/metric alone (the novelty direction\n # gate narrows this, but goal-word similarity is still lost).\n cross_run_concepts: bool = True\n # PART V (B): run-base + node-DELTA concept authoring. When on, once the first evaluated node has\n # authored concepts the engine seeds RunState.run_base_concepts from them (a one-shot EV_RUN_CONCEPTS,\n # idempotent because it only fires while the base is empty), and the Researcher's proposal hint tells it\n # to author only `concepts_added`/`concepts_removed` vs the run base + its parent — minimal per-node\n # annotations that inherit down the DAG (a swap propagates). Off -> every node authors its full concept\n # set as before. Product-on; the bare EngineOptions mirror defaults off (test_options_divergence).\n concept_run_base: bool = True\n # PART IV cross-run Step 5 advisory (§21.20.5). Fold the bounded cross-run CONTEXT PACK — evidence-\n # grounded claims with BOTH support and counter-evidence (Step 4) + a portfolio-coverage line (Step 3) —\n # into the Researcher's proposal prompt, exactly like the E4 cross-run prior note. Advisory ONLY: it is\n # prompt-grounding: it can change the Researcher's later candidates, but does not directly rewrite\n # best-metric selection (§21.7). Reads `memory_dir` (lessons.jsonl + concept_capsules.jsonl); \"\" when\n # empty. ON by explicit experimental product choice; no frozen A/B validation is claimed. The\n # bare-library EngineOptions default stays off (engine/options.py, frozen in\n # test_options_divergence). See engine/proposal_cues.py.\n cross_run_advisory: bool = True\n # PART IV cross-run §21.20.13 (full CR of the lean fuzzy claim merge). Switch the claim read-model to the\n # SCOPE+POLARITY-safe STRUCTURED claim key (engine/claim_key.py): claims from different tasks never\n # merge, opposite polarity (\"X helps\" vs \"X never helps\") is surfaced as a CONTRADICTION instead of being\n # collapsed, paraphrase/inflection variants group by exact structured key (no transitive over-merge), and\n # operator governance is scope-precise (a decision in task A cannot reach a same-worded claim in task B).\n # Affects the `cross_run_advisory` context pack only; ON by default in the product Settings (ce4a379);\n # the bare-library EngineOptions default stays off (engine/options.py). See engine/claims.py.\n cross_run_structured_claims: bool = True\n # PART IV cross-run §22.4 (AGENTIC taxonomy steward). At finalize, when an LLM client is available, let\n # the concept steward (engine/concept_steward.py) review the freshly-updated portfolio concept graph and\n # PROPOSE a curation — merge duplicate slugs / split conflated ones / purge noise. Proposals are LOGGED to\n # `concept_curation_log.jsonl` for operator ratification (via the serve/CLI governance surface); the LLM\n # never mutates fold state. The calls run synchronously during finalize, so they add latency and may add\n # paid inference; a caught steward failure does not prevent terminal completion or trigger a retry.\n # ON by explicit experimental product choice when `memory_dir` + an LLM backend are present; the\n # bare-library EngineOptions default stays off (engine/options.py). See engine/lessons.py.\n cross_run_curation: bool = True\n # Deprecated compatibility flag. Steward output is untrusted and finalize is proposal-only regardless\n # of this value; retained so old snapshots still validate and logs can disclose that auto was requested.\n cross_run_curation_auto: bool = False\n # Role backend (ADR-7/14): \"toy\" (offline optimizer) | \"llm\" (live model). Validated in\n # `_check_trust_gate` alongside the other enum-ish strings — every consumer tests `== \"llm\"`\n # exactly (cli/__init__.py, adapters/tasks.py), so an untyped `--set`/file/env typo (or a mis-cased\n # \"LLM\") used to fall through to the OFFLINE toy roles and quietly produce a run that never called\n # the model. Left as `str`, not `Literal`, so old snapshots still validate through the same path.\n backend: str = \"toy\"\n # Developer backend (ADR-7): \"default\" (templated/LLM from the task) or an external\n # CLI coding agent: \"opencode\" | \"aider\" | \"goose\" | \"continue\".\n developer_backend: str = \"default\"\n # C2 best-of-N: generate N candidate implementations per node and keep the best by an\n # execution-free reward (static validity + metric-print). 1 = off. In-house LLM developer only\n # (not external agents — ADR-7 cost rule). The top SWE-bench reliability lever for weak models.\n best_of_n: int = 1\n # D10 list-wise best-of-N selection: when the top static-scorers TIE, break the tie with a\n # comparative LLM selection over the candidates presented together (+~3 pts vs pointwise on\n # GAIA, arXiv:2506.12928). The static score stays the primary filter — the LLM is a weak\n # comparative prior, never the sole oracle. ON by default (no-op when best_of_n==1).\n best_of_n_listwise: bool = True\n # === Foresight / predict-before-execute ===============================================\n # FOREAGENT predict-before-execute (arXiv:2601.05930): use the LLM as an IMPLICIT WORLD MODEL to\n # predict — WITHOUT executing — which candidate / hypothesis will score best, primed with a\n # Verified Data Analysis Report (data profile / brief) + the accumulated experiment memory. Master\n # switch, ON by default; a no-op wherever there is nothing to rank (best_of_n==1 and\n # foresight_panel==1) or no LLM client. See `looplab/search/foresight.py`.\n foresight: bool = True\n # Hypothesis foresight breadth: generate K candidate ideas per proposal and keep the one PREDICTED\n # best pre-execution by the world model — it ranks the STRUCTURAL / text ideas the numeric k-NN\n # surrogate (researcher_panel) is blind to, primed with the data profile + memory (the synergy).\n # >1 enables it (LLM backend only; needs `foresight` on); 2 = on by default at modest cost, 1 = off.\n foresight_panel: int = 2\n # AGENTIC foresight: run the ranking (hypothesis-board prioritization + K-idea pick) as a TOOL-USING\n # loop that can pull actual experiment results / data facts before deciding, instead of a one-shot\n # prediction from a pre-baked report. ON by default (needs foresight + a client; falls back to the\n # one-shot predictor on any hiccup). A few extra LLM calls per proposal; set False for the cheaper\n # single-call ranker.\n foresight_agentic: bool = True\n # §1 foresight confidence gate: the minimum predicted confidence at which a predict-before-execute\n # pick is ACTED ON. Below it the ranker abstains — the K-idea panel falls back to the first\n # proposal, best-of-N falls through to the D10 tie-break — instead of committing a low-confidence\n # choice. 0.0 (default) = OFF (act on every pick, the historical behavior); raise toward ~0.5 to\n # make the world model defer when it isn't sure. Pairs with the foresight track record (§1) the\n # predictor is now primed with. Range 0.0-1.0.\n # CLAUDE REVIEW: [EDGE-CASE] the field comment says \"Range 0.0-1.0\" but unlike the sibling\n # confidence/fraction knobs (train_monitor_kill_confidence, asha_live_quantile, ...) there is no\n # Field(ge=0.0, le=1.0) bound, so e.g. 5.0 validates silently — a gate that can never pass, so\n # every foresight pick permanently abstains with nothing in the snapshot to show why.\n foresight_min_confidence: float = 0.0\n # PART IV Phase 2c — replace the world model's SELF-REPORTED confidence (measured Pearson≈0 with the\n # realized outcome, §21.12) with a CALIBRATED §12-verifier score. When on and a client is available,\n # after the K-idea ranker picks the predicted-best candidate the §12 verifier scores it (grounded +\n # repeated + criteria-decomposed: `foresight_criteria` — likely to improve the objective, and\n # sound/feasible) and THAT calibrated score becomes the confidence the `foresight_min_confidence` gate\n # and the telemetry use. Degrades to the self-reported confidence without a client or on any verifier\n # error (the telemetry records which source was used). ON by default in the product Settings (ce4a379),\n # EngineOptions off; a few extra LLM calls per acted-on proposal. See looplab/trust/verifier.py + search/foresight.py.\n # REVISIT (cost/quality, not yet resolved): a live classification run measured ~10 LLM calls & ~3.8 min/node\n # on deepseek-v4-flash with the full Part IV/V default bundle; the calibrated verifier is part of that per-node\n # tax. Kept ON deliberately — it raises decision quality (self-reported foresight confidence has ≈0 Pearson\n # with realized outcome), and lowering quality to save calls is the wrong trade. TODO: run a frozen quality/cost\n # A/B (verifier-on vs -off) to quantify the recall@objective gain per extra call before considering a default flip.\n foresight_verify: bool = True\n # Verifier sample count for `foresight_verify` (the §12 repeated-sampling expectation on a no-logprob\n # backend). 3 tames the single-shot variance §21.12 measured; 1 = single-shot (cheaper, noisier).\n # RESUME-critical: this value is persisted in config.snapshot.json, so a hard `le` schema bound would\n # make an old run started with a larger LOOPLAB_FORESIGHT_VERIFY_SAMPLES un-resumable (Settings(**snapshot)\n # would raise on `looplab resume` / finalize-recovery / serve replay). CLAMP into [1, MAX] via a validator\n # instead of rejecting — the runtime cap is preserved without breaking the snapshots/env-compat rule.\n foresight_verify_samples: int = 3\n\n @field_validator(\"foresight_verify_samples\")\n @classmethod\n def _clamp_foresight_verify_samples(cls, v: int) -> int:\n # Coerce a persisted/env value into [1, MAX] rather than reject (see field note above).\n try:\n v = int(v)\n except (TypeError, ValueError):\n return 3\n return max(1, min(v, MAX_FORESIGHT_VERIFY_SAMPLES))\n\n # T7 LLM response cache: serve an identical DETERMINISTIC (temperature 0) request from an\n # in-process content-addressed cache instead of re-hitting the model — cuts cost on\n # retry/panel/verify flows. NEVER caches sampling calls (temp>0: best-of-N / panel / novelty\n # re-propose must vary). Off by default (most role calls run at temperature>0 anyway).\n llm_cache: bool = False\n agent_cmd: str | None = None # override the agent's launcher (path / wrapper)\n # External-agent validation (ADR-7): wrap a CLI-agent Developer with a validator that\n # audits each output (no-op/syntax/crash/timeout), retries with feedback, and falls\n # back to the in-house LLM Developer. Off -> use the raw agent output unchecked.\n validate_agent: bool = True\n agent_max_retries: int = 1 # re-prompts of the agent on an invalid result\n # Patch-gated multi-file agent (ADR-7 Rule 3): run the CLI agent in a git worktree and\n # accept only edits whose paths match `agent_surface` (reject-not-strip out-of-surface\n # touches). Lets the agent create helper modules, not just solution.py. Degrades to\n # whole-file readback if git is unavailable.\n agent_patch_gate: bool = True\n agent_surface: list[str] = [\"*.py\"] # edit-surface allow-list (globs)\n # Trust policy for an agent-authored eval/metric adapter (RepoTask onboarding, Phase 3):\n # \"ratify_freeze\" waits for human approval, \"autonomous\" accepts the proposed spec without that\n # gate, and \"ratify_freeze_drift\" also enforces the configured independent drift cross-check.\n eval_trust_mode: str = \"ratify_freeze\"\n # RepoTask node seeding policy (run-wide fallback; a task/editable `seed_mode` overrides):\n # \"auto\" (default) copies git-tracked files when the editable is a git repo (so a tree bloated\n # with untracked artifacts isn't deep-copied per node), else a full copy; \"tracked\" forces\n # code-only; \"all\" forces a full recursive copy (legacy). Genesis authors per-task from the\n # user's words; this is the global default when unspecified.\n seed_mode: str = \"auto\"\n llm_model: str = \"qwen3:8b\"\n # === LLM / transport ==================================================================\n llm_base_url: str = \"http://localhost:11434/v1\" # Ollama OpenAI-compatible endpoint\n llm_temperature: float = 0.6\n # H3 per-role model presets (5090 recipe): optionally run the Researcher and Developer on\n # DIFFERENT models/endpoints (e.g. Developer=Qwen3-Coder-30B for code, a fast model for breadth).\n # Blank = use the shared llm_model/llm_base_url. Generalizes the role-backend swap to per-role.\n researcher_model: str | None = None\n developer_model: str | None = None\n researcher_base_url: str | None = None\n developer_base_url: str | None = None\n # Per-role sampling temperature (§4.1): the Researcher wants breadth (higher temp = more diverse\n # ideas), the Developer wants determinism (lower temp = fewer syntax slips), the Strategist a\n # steady hand. Each overrides the shared `llm_temperature` for that role only; None = use the\n # shared value (byte-identical to before). Flat + nullable, mirroring the per-role model fields.\n researcher_temperature: float | None = None\n developer_temperature: float | None = None\n strategist_temperature: float | None = None\n # The Strategist had per-role TEMPERATURE but no model/endpoint of its own, so in split mode it\n # always ran on the shared llm_model however the other roles were pointed. These close that gap\n # with the same names/shape as the pair above; blank = its profile, else the shared value.\n strategist_model: str | None = None\n strategist_base_url: str | None = None\n # === Connection profiles (multi-provider) =============================================\n # A \"profile\" is a NAMED connection: {model, base_url, temperature, api_key_env, provider}. Roles\n # point at one by name through `role_profiles`; `llm_profile` is the default for every role that\n # doesn't. All three are EMPTY by default and the resolver short-circuits to the shared\n # llm_model/llm_base_url/llm_api_key path when they are — an operator running one model never\n # needs to know the word \"profile\".\n #\n # A profile stores `api_key_env`, the NAME of an environment variable, never the key itself. That\n # is ADR-11's \"a secret is a reference, not a value\" turned from a convention into a data type:\n # the whole profile map is then safe to write into config.snapshot.json, hand back over HTTP and\n # render into LOOPLAB_* — there is nothing in it to mask. It is also what makes per-role\n # CREDENTIALS expressible at all: two roles on one provider can carry different keys, which a\n # key-per-endpoint map could not say.\n llm_profiles: dict[str, dict] = Field(default_factory=dict)\n llm_profile: str | None = None\n role_profiles: dict[str, str] = Field(default_factory=dict)\n # Unified self-driving agent: one LLM identity that plays Researcher + Developer (+ Strategist)\n # across pipeline stages, choosing its own model/toolset per stage. ON by default — the agent\n # drives the loop (incl. crash triage: repair/abandon/reject_idea) and, via\n # `agent_drives_actions`, picks the next macro action within a pure legal-action gate. Set both\n # to False for the legacy byte-identical split-role behavior. (No-op unless backend=\"llm\".)\n unified_agent: bool = True\n agent_drives_actions: bool = True\n # Layer 3 Card queue ownership. False preserves the existing policy/unified-pilot action path.\n # True is pinned in run_started because changing the selector on resume would mix two search\n # treatments in one run. When both this and agent_drives_actions are true, Card selection wins.\n card_driven_selection: bool = False\n # Layer 5 bounded speculative Card buffer. Zero is a hard OFF switch and preserves the\n # historical alternating build/eval spine; positive values are pinned by run_started so a\n # resume cannot silently mix search treatments after a snapshot/default edit.\n speculation_depth: int = Field(default=0, ge=0, le=64)\n # Local evidence receipt produced by ``looplab speculation-gate``. This is deliberately absent\n # from the curated Settings UI: positive-depth execution is a calibrated rollout decision, not a\n # casual tuning knob. The Engine validates the receipt against the current scorer,\n # implementation, GPU inventory and raw paired-run evidence before it enables speculation.\n speculation_gate_receipt: str | None = Field(default=None, max_length=4096)\n # Per-stage model/endpoint overrides for the unified agent. Recognized keys (see\n # tasks.build_unified_agent): `propose` (researcher), `implement`/`repair` (developer),\n # `strategy`, `pilot`. Unlisted keys are ignored. Empty = the per-role researcher_*/developer_*\n # models, then the shared llm_model. Explicit map wins. No-op unless `unified_agent` is true.\n agent_stage_models: dict = {}\n agent_stage_base_urls: dict = {}\n llm_parser: str = \"tool_call\"\n # Reasoning/thinking toggle sent IN the request (providers differ — see `llm.reasoning_body`):\n # Qwen3 on vLLM/SGLang -> chat_template_kwargs.enable_thinking (bool)\n # OpenAI/Ollama-v1/DeepSeek -> reasoning_effort (low|medium|high|none)\n # `llm_reasoning`: \"\" = send nothing (server default — current behavior); off = actively disable;\n # on = enable at default depth; low|medium|high = enable at that effort.\n # Defaults to \"high\" so the agent reasons before proposing/repairing/triaging (better code, fewer\n # crashes); set \"\" to send nothing (server default) or \"off\" to disable. The model's chain-of-thought\n # is CAPTURED either way (reasoning_content field or inline <think>). NOTE: to get a SEPARATE\n # reasoning_content field from SGLang/vLLM, the server also needs `--reasoning-parser qwen3`.\n llm_reasoning: str = \"high\"\n llm_reasoning_style: str = \"auto\" # auto | qwen | effort | none — how to shape the request param\n llm_reasoning_extra: dict = {} # raw fields merged into the body (escape hatch, e.g. Anthropic thinking)\n # H1: drive structured calls with the endpoint's constrained decoding (vLLM/SGLang guided_json +\n # response_format json_schema) so weak models can't emit invalid JSON. Off for Ollama (default).\n llm_guided_json: bool = False\n # Stream every LLM request (SSE) and reassemble it, so the request `timeout` is an INTER-TOKEN\n # idle timeout rather than a whole-request deadline: a long-but-alive generation is never cut off\n # (tokens keep resetting the timer), while a genuinely STALLED endpoint (no token for `timeout` s)\n # is detected and retried on a fresh connection — the fix for silent multi-minute hangs. Set False\n # to use one blocking read (old per-op timeout semantics) if an endpoint streams badly.\n llm_stream: bool = True\n # LLM IDLE timeout (seconds). Stream mode: the INTER-TOKEN stall limit — a steady generation is\n # never cut off, only a silent endpoint. Non-stream: bounds the whole read. Raise for endpoints\n # with a long prefill on huge prompts; lower to fail fast on a flaky shared endpoint.\n llm_timeout: float = Field(default=180.0, gt=0)\n # First-byte (response-headers) window for STREAM attempts, seconds: an SSE response sends its\n # headers on admission, so no-headers ≈ a black-holed request — fail over to a fresh connection\n # fast instead of waiting the full idle timeout. Clamped to llm_timeout. Non-stream attempts are\n # NOT bounded by this (their headers arrive only after the whole generation). The default mirrors\n # `DEFAULT_HEADER_TIMEOUT_S` in looplab/core/llm.py is the single source (imported above).\n llm_header_timeout: float = Field(default=DEFAULT_HEADER_TIMEOUT_S, gt=0)\n # Honour HTTP(S)_PROXY / NO_PROXY / SSL_CERT_FILE env vars for LLM calls. Default False: the\n # transport connects DIRECTLY (many internal/OLAP endpoints are reachable only by bypassing an\n # ambient proxy — a picked-up proxy yields \"connection refused\"). Set true when the endpoint is\n # reachable ONLY via a corporate proxy or needs a custom CA bundle from the environment.\n llm_trust_env: bool = False\n # H4/C2: compact the growing agentic tool-call history once it exceeds this many chars (auto_summary\n # LLM-summarizes the stale middle; else middle-truncate). Sized to the model's real context window so\n # files read earlier STAY in context instead of being compacted away and re-read: ~1,000,000 chars ≈\n # 250k tokens (≈4 chars/token), matching the configured deepseek-v4-flash (≥200k tokens confirmed).\n # The old 120k-char (~30k-token) default was ~8× too aggressive and drove a read-thrash loop. 0 = off.\n context_budget_chars: int = 1_000_000\n # M5: the Researcher's always-on experiments digest budget (chars). 0 = AUTO — scales with the\n # run size (60 chars/node, bounded [1200, 6000]) instead of one flat cap for an 8-node toy run\n # and a 200-node benchmark run alike. Set a positive value to pin it.\n digest_char_cap: int = Field(default=0, ge=0)\n # D11 compression model slot (open_deep_research's four-slot pattern): a dedicated CHEAP model\n # for agent-history summarization (C2 auto-summary), so compression doesn't pay the main\n # model's price. Blank = use each loop's own model (legacy). `compressor_base_url` blank =\n # the shared llm_base_url.\n compressor_model: str | None = None\n compressor_base_url: str | None = None\n # === Agentic tool-loop ================================================================\n # Agentic tool-loop limits — apply to EVERY tool-using agent (the LLM Researcher, the unified\n # agent's pilot + crash-triage, Deep-Research, the run-chat Boss, the genesis repo-scout, and the\n # cross-run report synthesizer). The loop lets the model call read-only tools across turns before\n # emitting its structured result. Both default to UNLIMITED so the agent takes as many turns / as\n # long as it needs — it is never cut off mid-reasoning; set a positive value here (or per-run, or\n # in the UI settings) to bound latency/cost. These used to be hardcoded per call site (e.g. the\n # boss at max_turns=3 / 45s), which silently dropped a slow reasoning model to a no-op reply.\n agent_max_turns: int = 0 # max tool turns before the emit is forced (0 = unlimited)\n agent_time_budget_s: float = 0.0 # wall-clock ceiling across the loop's turns (0 = no cap)\n # G · Soft convergence SAFETY NET (not a research cap). Two thresholds on the agent's tool-turn count:\n # at `agent_emit_after` the loop NUDGES the agent once (\"you've investigated enough — emit now\"); at\n # `agent_emit_force` it FORCES the emit from what was gathered. Set GENEROUSLY — a focused researcher\n # may legitimately read many files/experiments before it can propose a grounded idea; these only catch\n # a genuine runaway that keeps issuing DIFFERENT calls forever (which stuck-detection, keyed on\n # repeats, misses). NB: the old 499-read runaway (node 63) was 79% REDUNDANT re-reads caused by a\n # broken read_file — fixing read_file (pagination) is the real cure; this is just insurance. 0 = off.\n agent_emit_after: int = 300\n agent_emit_force: int = 500\n # B1 · No-progress / stuck detection — the safety net that makes \"unlimited turns\" safe. The\n # turn/time ceilings above are only BACKSTOPS; this is what actually stops a runaway loop, on the\n # cheapest signal: when the model repeats the SAME tool call (or ping-pongs between two, or keeps\n # hitting the SAME error) with no progress, the loop forces the final emit and finishes. ON by\n # default; reading DIFFERENT files or running ONE long command never trips it. (OpenHands-style.)\n agent_stuck_detection: bool = True\n agent_stuck_repeat: int = 4 # identical calls in a row that count as \"stuck\" (>=2)\n agent_stuck_alternate: int = 4 # ping-pong cycles between two calls that count as \"stuck\" (>=2)\n # C1 · Self-plan (TodoWrite-style): expose an `update_plan` tool so a long-running agent keeps its\n # OWN working TODO and re-surfaces it every `agent_plan_reinject_every` turns — keeps the goal in\n # view across a long loop so the agent \"writes its own context\" (Claude Code style). ON by default.\n agent_self_plan: bool = True\n agent_plan_reinject_every: int = 5\n # C2 · Auto-summary (ON by default): when the tool-loop history grows long, LLM-summarize the\n # stale middle instead of only middle-truncating it. The trigger is `context_budget_chars` if set,\n # else a built-in high-water mark (context_budget.DEFAULT_SUMMARY_CHARS ≈ 120k chars) so short\n # loops are untouched. Falls back to truncation on any summarizer error; one extra model call per\n # over-budget turn.\n agent_auto_summary: bool = True\n # C4 · Repo-developer plan decomposition. A big feature attempted in ONE developer session made a\n # non-converging reasoning model loop indefinitely — it kept writing + exploring without ever\n # emitting `done` (10k+ LLM calls / hours; agentic stuck-detection can't catch VARIED exploration,\n # only literal repeats). Fix: the developer first proposes an ordered plan of ATOMIC steps; a\n # multi-step plan is executed step-by-step, each in a FRESH bounded session with only that step's\n # scope + the files accumulated so far, syntax-validated per write. Every session stays\n # small-context and convergent. ON by default for repo tasks (a 1-step plan == the old single pass).\n developer_plan_decompose: bool = True\n developer_plan_min_steps: int = 2 # a proposed plan with >= this many steps runs step-by-step\n developer_plan_max_steps: int = 8 # cap on plan length (a runaway planner can't spawn 100 steps)\n # Hard per-session backstop for the repo developer — the ONE write-heavy agent that can run away\n # even with stuck-detection on (varied writes/reads never trip the repeat/ping-pong signal). Bounds\n # the plan phase, EACH step, and the single-session fallback. Unlike the global agent_* limits\n # (0 = unlimited, meant for read-only agents), the developer always gets a FINITE ceiling so a\n # model that never emits `done` fails cleanly with the code it wrote so far, instead of looping.\n developer_session_max_turns: int = 500\n developer_session_time_budget_s: float = 1200.0 # 20 min wall-clock per developer session\n # Phase-handoff summaries. Each LLM phase in a node build (Researcher·propose → Developer·stages →\n # plan → implement) ends with ONE extra LLM call that distills its transcript — the repo structure\n # it mapped, files/data confirmed, decisions made — into a brief injected into the NEXT phase (even\n # across the role boundary). The next phase TRUSTS that instead of re-reading the same files, which\n # cuts the biggest source of tool-call thrash (stages/plan/implement each re-exploring the repo).\n # One summary call per phase in exchange for many fewer read calls downstream. ON by default.\n phase_handoff_summary: bool = True\n # Observability (ADR-17): capture each LLM call's full prompt + completion into the active\n # span (spans.jsonl) so the UI can show exactly what the model read and wrote per node.\n # Diagnostics only — `replay.fold` never reads spans. Default on for local single-user; set\n # LOOPLAB_TRACE_LLM_IO=0 to suppress (e.g. to keep spans.jsonl small or avoid storing prompts).\n trace_llm_io: bool = True\n # Run-introspection tools (ON by default): make the LLM Researcher (and DeepResearcher) a\n # tool-using agent that can read its OWN experiments (list/read/find-analogous/themes/code) and\n # the task data (schema/profile/asset) mid-loop, instead of seeing only best+parent. Advisory —\n # never changes best-selection. Off = the legacy single-shot Researcher (richer digest still added).\n researcher_tools: bool = True\n # Cross-run introspection: give the agentic Researcher / pilot read-only tools to look at\n # SIBLING runs (same task_id, same run-root) — list them, read an experiment / its code, and\n # find analogous configs across runs — so a run can build on what neighbouring runs already\n # learned instead of rediscovering it. Advisory; never changes best-selection. Needs the run's\n # own dir wired through (no-op for unit-built roles). Off = the legacy single-run view only.\n cross_run_tools: bool = True\n # Read-only access to EVERY run on this machine ACROSS ALL TASKS (not just same-task siblings):\n # the Developer/Researcher get list_all_runs + read_run_code + read_run_experiment so they can read\n # the code + result of ANY past experiment anywhere and reuse an approach. Broader than\n # `cross_run_tools` (same-task only); the agent decides when a foreign run is relevant. Advisory;\n # never changes best-selection. Needs the run's own dir wired through (no-op for unit-built roles).\n all_runs_tools: bool = True\n # PART V §22 — read-only CROSS-RUN KNOWLEDGE tool for the reasoning roles (Researcher, Strategist,\n # deep-research, and a Developer-scoped variant). Adds `cross_run_prior_attempts` / `cross_run_claims`\n # / `cross_run_atlas` over the §21.20 read-models (concept overview + claim assessments + atlas), read\n # from `memory_dir` (lessons.jsonl + concept_capsules.jsonl). Read-only is a storage-authority\n # boundary, not a behavioral no-op: retrieved content can steer later proposals, and a tool-using\n # reasoning loop can consume additional model calls. The tools never mutate cross-run truth (facts\n # are engine-written, verdicts operator-ratified, §22.4) or directly select a winner. ON by explicit\n # experimental product choice, EngineOptions off; stores only exist once Part-IV features have run.\n cross_run_read_tools: bool = True\n # Agentic retrieval (ADR-16): if set, the LLM Researcher gets grep/kb_search/read\n # tools over this directory of markdown notes and chooses when to use them.\n knowledge_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"knowledge\"))\n # T4 real embeddings: model id for an OpenAI-compatible `/embeddings` endpoint used for\n # kb_search / case retrieval. Blank (default) = the dependency-free lexical `hash_embed` (today's\n # behavior). Set e.g. \"nomic-embed-text\" (Ollama) or \"text-embedding-3-small\" for SEMANTIC\n # retrieval; a misconfigured/offline endpoint degrades back to hash_embed at call time (never\n # crashes a run). `embed_base_url` blank = reuse `llm_base_url`; the shared `llm_api_key` is used.\n embed_model: str | None = None\n embed_base_url: str | None = None\n # Harmonic memory (Memora, ICML'26 — idea import). When ON, cross-run cases and knowledge notes are\n # indexed by a short ABSTRACTION + cue ANCHORS instead of their raw text, near-duplicate memories are\n # CONSOLIDATED into one entry under a matching abstraction, and `kb_search`/case retrieval EXPAND\n # through the top hits' anchors to surface related-but-not-similar memories. ON by default. Set\n # `memora=false` to restore the pre-Memora raw-text index. Never a source of truth — abstractions\n # live only in the derived, rebuildable retrieval index (never the event log or canonical cases.jsonl).\n memora: bool = True\n # Write abstractions with the wired chat model (richer than the deterministic lexical fallback). ON\n # by default; results are CACHED by content hash (see `memora_cache`) so a re-built index doesn't\n # re-call the model on unchanged notes/cases, and any endpoint failure degrades to lexical at call\n # time — so an offline box just gets lexical abstractions, never a crash. Set false to force lexical.\n memora_llm: bool = True\n # === Cross-run memory / Memora ========================================================\n memora_anchors: int = 6 # max cue anchors kept per memory\n # Cosine similarity at/above which a new case is CONSOLIDATED into an existing one (same evolving\n # topic) rather than stored as a separate entry. Only used when `memora` is on.\n memora_consolidate_threshold: float = 0.86\n # Where the LLM-abstraction cache lives (JSON). Blank (default) derives it from `memory_dir`\n # (`<memory_dir>/memora_cache.json`) or else `knowledge_dir` (`<knowledge_dir>/.memora_cache.json`);\n # with neither set the cache is in-memory only (per process). No-op unless `memora_llm` is on.\n memora_cache: str | None = None\n # E3 literature-grounded ideation (network-OPTIONAL): give the agentic Researcher an arXiv search\n # tool to ground ideas in real techniques. Off by default (egress is unreliable on some boxes);\n # fails gracefully if the network is blocked.\n literature_search: bool = False\n # Deep-Research stage (network-OPTIONAL): give the DeepResearcher a general web search/fetch\n # tool (DuckDuckGo) on top of arXiv to read across results + the web before steering the next\n # batch. Off by default (egress is unreliable on some boxes); fails gracefully when blocked.\n web_search: bool = False\n # Cadence for the Deep-Research stage: run it automatically every N created nodes. Zero disables\n # only the cadence; manual `deep_research` and Strategist `request_research` triggers still fire.\n # Default 3: deep research analyzes the accumulating results and steers the next batch (its\n # directions become hints + open hypotheses); paired with `concurrent_research` it overlaps the\n # think with the GPU-bound eval, so the cadence costs LLM tokens but no wall-clock on the search.\n deep_research_every: int = 3\n # Overlap a DUE deep-research \"think\" with the GPU-bound eval instead of running it in its own\n # serial step (the agent is otherwise idle while a node trains). research() computes from a state\n # snapshot; the joined background task records only allowlisted, order-tolerant\n # research/hint/hypothesis events as soon as the memo finishes. ON by default: the LLM is typically\n # remote (no GPU contention with eval), so overlapping hides latency behind long training.\n concurrent_research: bool = True\n # Concurrent-research REPEAT — don't idle a multi-DAY eval. The overlapped \"think\" fires ONCE per\n # window by default; ON here, it RE-RUNS on an adaptive time cadence for the WHOLE eval window\n # (a two-day training must not leave the reasoning agents idle after one memo). Self-paced: it\n # only records a memo whose CONTENT is new (identical re-runs are skipped, so the log/board don't\n # bloat) and backs off geometrically as the analysis converges, capped so it always re-checks.\n # The appended records never rewrite the current metric champion, but their hints/open hypotheses\n # deliberately steer later proposals and replay reconstructs that advice. The library default is\n # one-shot (== today, byte-identical); this product default turns the repetition on.\n concurrent_research_repeat: bool = True\n # Base cadence (seconds) between repeated concurrent-research passes. Research is EXPENSIVE\n # (multi-turn LLM + web/arXiv), so this is a FLOOR, not a ceiling: the effective cadence is\n # max(this, ~5% of the per-experiment time budget) — a two-day eval is re-researched about hourly,\n # a short eval not at all (the first tick outlasts it). Only matters when the repeat is on.\n concurrent_research_interval_s: float = Field(default=1800.0, gt=0)\n # Per-eval-window backstop on repeated-research LLM calls (0 = cadence-only, no cap). The adaptive\n # cadence + convergence backoff are the primary budget control; this bounds a pathological\n # never-converging window. Past it the loop stops calling the LLM (the health monitor still runs).\n concurrent_research_max_calls: int = Field(default=40, ge=0)\n # Concurrent hypothesis-board CONSOLIDATION during the eval window. Repeated research keeps ADDING\n # near-duplicate directions to the open board (each memo registers up to 5 as open hypotheses), so a\n # long eval's board bloats before the between-nodes merge runs. This dedups/merges it on the SAME\n # background loop so the board stays clean for the next proposal. In Card-driven mode the same merge\n # changes ownership/readiness, so it is deferred to the joined main-task cadence instead. Needs the\n # reflect client + `track_hypotheses`.\n # Only active while `concurrent_research` runs the overlap loop. Library default off (== today).\n concurrent_consolidate: bool = True\n # Cadence for the agent-authored run report: regenerate the conclusion-first narrative every N\n # created nodes (0 = off; it still regenerates on a manual `report_refresh` from the UI). The\n # deterministic report always renders from the node set regardless of this knob.\n report_every: int = 3\n # Agent Skills (I18, ADR-9): dir of SKILL.md the Researcher can list/load as tools.\n skills_dir: str | None = None\n # Prompt store (I18, ADR-8): dir of editable, hot-reloaded role prompt .md files.\n prompt_dir: str | None = None\n # API key as a reference, never serialized as a value (ADR-11). Local servers\n # ignore it; default to a placeholder.\n llm_api_key: SecretStr | None = None\n\n @model_validator(mode=\"before\")\n @classmethod\n def _reject_bool_for_numeric_fields(cls, data):\n \"\"\"A JSON boolean is a TYPE ERROR for a numeric setting, not the number 1.\n\n Pydantic runs in lax mode here (`model_config` sets no `strict`), and `isinstance(True, int)`\n is True — so `{\"max_nodes\": true}` from a UI/API type slip validated cleanly as a budget of\n ONE node, and `{\"eval_parallel\": true}` collapsed the width to one. The run then looked\n configured and simply did almost nothing, with no 422 and nothing in the snapshot to show why.\n Rejecting at the boundary covers every numeric field at once, including the many that carry no\n `Field(ge=...)` bound of their own to trip over.\n \"\"\"\n if not isinstance(data, dict):\n return data\n for key, val in data.items():\n if type(val) is bool and _is_numeric_annotation(\n getattr(cls.model_fields.get(key), \"annotation\", None)):\n raise ValueError(f\"{key} must be a number, not a boolean (got {val!r})\")\n return data\n\n @model_validator(mode=\"before\")\n @classmethod\n def _apply_profile(cls, data):\n \"\"\"Expand `profile` into a bundle of setting defaults BEFORE validation.\n\n pydantic-settings hands this validator the fully-merged input from ALL explicit sources\n (init kwargs + LOOPLAB_* env + .env), with fields the user left unset simply ABSENT (they\n fall to field defaults afterward). So a profile value is applied only when its key is not\n already present — i.e. any explicit file/CLI/env setting wins over the profile, and the\n profile wins over the bare field default. This makes `thorough` a true convenience preset,\n never an override of what the user asked for.\"\"\"\n if not isinstance(data, dict):\n return data\n prof = data.get(\"profile\", \"default\")\n # env may deliver the key as \"profile\" (env_prefix stripped) — normalize to a str\n prof = str(prof).strip().lower() if prof is not None else \"default\"\n overrides = PROFILES.get(prof)\n if overrides is None:\n raise ValueError(f\"unknown profile {prof!r}; known: {sorted(PROFILES)}\")\n for key, val in overrides.items():\n if key not in data: # explicit source (file/CLI/env) always wins\n data[key] = val\n return data\n\n @model_validator(mode=\"after\")\n def _check_trust_gate(self):\n if self.trust_gate not in (\"audit\", \"gate\", \"block\"):\n raise ValueError(\n f\"trust_gate must be audit|gate|block, got {self.trust_gate!r}\")\n if self.merge_mode not in (\"auto\", \"mean\", \"ensemble\"):\n raise ValueError(\n f\"merge_mode must be auto|mean|ensemble, got {self.merge_mode!r}\")\n # novelty_mode drives the dedup/novelty gate; an out-of-set value (e.g. a mis-cased\n # LOOPLAB_NOVELTY_MODE=LLM, or \"on\") used to fall through _apply_novelty_gate silently as a\n # NO-OP — turning the gate off with no diagnostic. Fail loudly at config time like trust_gate/\n # merge_mode do. (Other enum-ish string fields — seed_mode/eval_trust_mode/strategist_backend —\n # share the pattern; left unvalidated here only because their full value sets are less settled.)\n if self.novelty_mode not in (\"off\", \"algo\", \"llm\"):\n raise ValueError(\n f\"novelty_mode must be off|algo|llm, got {self.novelty_mode!r}\")\n # The remaining enum-ish string fields (arch-review §5 P3): a typo used to be accepted at\n # construction and only fail-safe/later-loud downstream (e.g. a mis-cased strategist_backend\n # silently ran the default). Fail loudly here like the fields above.\n if self.strategist_backend not in (\"off\", \"rule\", \"llm\", \"agent\"):\n raise ValueError(\n f\"strategist_backend must be off|rule|llm|agent, got {self.strategist_backend!r}\")\n if self.eval_trust_mode not in (\"ratify_freeze\", \"autonomous\", \"ratify_freeze_drift\"):\n raise ValueError(\n \"eval_trust_mode must be ratify_freeze|autonomous|ratify_freeze_drift, \"\n f\"got {self.eval_trust_mode!r}\")\n if self.seed_mode not in (\"auto\", \"tracked\", \"all\"):\n raise ValueError(f\"seed_mode must be auto|tracked|all, got {self.seed_mode!r}\")\n if self.backend not in (\"toy\", \"llm\"):\n raise ValueError(f\"backend must be toy|llm, got {self.backend!r}\")\n # `parse_structured` resolves an unknown name to the DEFAULT fallback order, so a typo'd\n # llm_parser is indistinguishable from asking for the default. Validate against the parser\n # registry itself (imported here, not at module scope, to keep config import-light).\n from looplab.core.parse import _ORDER as _PARSER_ORDER\n if self.llm_parser not in _PARSER_ORDER:\n raise ValueError(f\"llm_parser must be one of {'|'.join(sorted(_PARSER_ORDER))}, \"\n f\"got {self.llm_parser!r}\")\n self._check_llm_profiles()\n return self\n\n def _check_llm_profiles(self) -> None:\n \"\"\"Validate the connection-profile maps LOUDLY, at construction.\n\n Assignment is not validated anywhere in this project, and these two maps are the kind of\n thing a typo makes into a silent no-op: an unknown role name would simply never be read, and\n a reference to a missing profile would fall back to the shared model while the operator\n believed a second provider was in play. Both are errors here instead.\n\n The `api_key_env` rules exist for a specific reason. It must LOOK like a secret to\n `runtime/sandbox.py::SECRET_ENV`, because that is the filter that strips the operator's keys\n out of the environment handed to generated candidate code — a key parked in a variable named\n `MY_HANDLE` would be invisible to it and ride straight into the sandbox. This pattern is\n deliberately the STRICTER of the two (the sandbox has to recognize whatever already exists;\n this names a variable the operator is about to create), and it is duplicated below rather than\n imported because layering forbids `core` from importing `runtime`.\n `tests/test_secret_env_pattern.py` pins the two together — including the invariant that every\n name accepted here is one the sandbox strips. And a literal key inside a profile is refused\n outright: the profile map is written verbatim into config.snapshot.json and served over HTTP\n precisely because it is supposed to contain no secrets.\"\"\"\n from looplab.core.llm import LLM_ROLE_KEYS\n if not isinstance(self.llm_profiles, dict):\n raise ValueError(\"llm_profiles must be a map of profile name -> profile\")\n for name, entry in self.llm_profiles.items():\n if not isinstance(entry, dict):\n raise ValueError(f\"llm_profiles[{name!r}] must be an object\")\n # BEFORE the unknown-field check: none of these is a known field, so checking them\n # afterwards could never fire and someone who pasted a live key was told it was a typo.\n for banned in (\"api_key\", \"key\", \"token\", \"secret\"):\n if banned in entry:\n raise ValueError(\n f\"llm_profiles[{name!r}] must not contain {banned!r}: a profile stores \"\n \"api_key_env (the NAME of an environment variable), never a key value — \"\n \"the whole map is written to config.snapshot.json and served over HTTP\")\n unknown = set(entry) - _PROFILE_FIELDS\n if unknown:\n raise ValueError(\n f\"llm_profiles[{name!r}] has unknown field(s) {sorted(unknown)}; \"\n f\"known: {sorted(_PROFILE_FIELDS)}\")\n env = entry.get(\"api_key_env\")\n if env is not None and not _SECRET_ENV_NAME.match(str(env)):\n raise ValueError(\n f\"llm_profiles[{name!r}].api_key_env={env!r} is not a usable variable name: it \"\n \"must be UPPER_SNAKE and contain KEY/SECRET/TOKEN/PASSWORD/PASSWD/CREDENTIAL, so \"\n \"the sandbox's secret-name filter strips it from generated code's environment\")\n temp = entry.get(\"temperature\")\n if temp is not None and (isinstance(temp, bool) or not isinstance(temp, (int, float))):\n raise ValueError(f\"llm_profiles[{name!r}].temperature must be a number\")\n if not isinstance(self.role_profiles, dict):\n raise ValueError(\"role_profiles must be a map of role name -> profile name\")\n for role, profile in self.role_profiles.items():\n if role not in LLM_ROLE_KEYS:\n raise ValueError(\n f\"role_profiles has unknown role {role!r}; known: {sorted(LLM_ROLE_KEYS)}\")\n if profile not in self.llm_profiles:\n raise ValueError(\n f\"role_profiles[{role!r}] names profile {profile!r}, which is not in llm_profiles\")\n if self.llm_profile is not None and self.llm_profile not in self.llm_profiles:\n raise ValueError(\n f\"llm_profile={self.llm_profile!r} is not in llm_profiles\")\n\n def masked_snapshot(self) -> dict:\n # Snapshots are a JSON contract (they are written verbatim to config.snapshot.json), not a\n # Python-object dump. In particular tuple-valued schema fields such as\n # ``inline_repair_reasons`` must become JSON arrays before callers hash the runtime envelope;\n # otherwise the strict speculation-scope canonicalizer sees a different/non-JSON preimage\n # from the one that ``json.dumps`` eventually persists.\n d = self.model_dump(mode=\"json\")\n if self.llm_api_key is not None:\n d[\"llm_api_key\"] = \"***\"\n else:\n d[\"llm_api_key\"] = None\n # The snapshot's own FORMAT version — not a Settings field (it is never settable by env or\n # file, and it describes the document, not the run). `settings_from_snapshot` fails closed on\n # a version it does not understand; see CONFIG_SNAPSHOT_SCHEMA.\n d[CONFIG_SNAPSHOT_SCHEMA_KEY] = CONFIG_SNAPSHOT_SCHEMA\n return d\n\n\n# The config snapshot's document-format version, written by `masked_snapshot` and enforced by\n# `settings_from_snapshot`. Bump it when a snapshot written by this binary would be MISREAD by an\n# older one — i.e. when a new field changes paid, concurrency or selection behaviour and silently\n# defaulting it would resume the same event history under different semantics. `Settings` is\n# `extra=\"ignore\"`, so without this an older binary drops what it does not recognize and resumes\n# anyway, with no diagnostic; a version it does not know is now a loud refusal instead.\n# Version 1 is the first VERSIONED format. An absent key means a pre-versioning snapshot, which stays\n# readable through `LEGACY_CONFIG_SNAPSHOT_DEFAULTS` — that historical contract is unchanged.\nCONFIG_SNAPSHOT_SCHEMA = 1\nCONFIG_SNAPSHOT_SCHEMA_KEY = \"config_snapshot_schema\"\n\n\nclass ConfigSnapshotVersionError(ValueError):\n \"\"\"A config snapshot was written by a NEWER binary than the one reading it.\"\"\"\n\n\n# Pre-versioned snapshots are full Settings dumps, so absence is a reliable per-field version marker.\n# Keep their historical effective behavior when newer product defaults become active: re-entry must not\n# silently add paid calls, interventions, concurrency or a different selection policy to an old run.\nLEGACY_CONFIG_SNAPSHOT_DEFAULTS: dict[str, object] = {\n \"parallel_build\": 1,\n \"eval_parallel\": None,\n \"llm_parallel\": None,\n \"train_monitor\": False,\n \"train_monitor_interval_s\": 600.0,\n \"train_monitor_kill\": False,\n \"train_monitor_kill_confidence\": 0.8,\n \"asha_live\": False,\n \"asha_live_kill\": False,\n \"asha_live_quantile\": 0.5,\n \"asha_live_min_siblings\": 3,\n # Before this operator ceiling existed, the sandbox's 24-hour process limit was authoritative.\n \"max_eval_timeout\": 24 * 3600.0,\n \"watchdog_reflection\": False,\n \"card_driven_selection\": False,\n \"speculation_depth\": 0,\n \"speculation_gate_receipt\": None,\n \"concurrent_research_repeat\": False,\n \"concurrent_research_interval_s\": 1800.0,\n # Zero means unlimited; 40 was the first bounded default, and is latent while repeat is disabled.\n \"concurrent_research_max_calls\": 40,\n \"concurrent_consolidate\": False,\n # The map originally covered only the flags added in the days just before it was written\n # (the entries above are all 2026-07-19..07-21; the map itself landed 07-22). Everything below was\n # added 2026-06-24..07-17 — AFTER `config.snapshot.json` existed (it ships in the initial commit,\n # 2026-06-23) and BEFORE this map did — so a snapshot from that window carries none of these keys\n # and, without an entry here, resumed with today's paid product default ACTIVE. That is precisely\n # the \"silently add paid calls ... to an old run\" the paragraph above forbids: the resumed half of\n # the run would make predict-before-execute and cross-run LLM calls the original never made, and\n # admit candidates under a different novelty policy.\n # `setdefault` makes every one of these a no-op for a snapshot that DOES carry the key, so only\n # genuinely older runs are affected. Each value is the behaviour before the field existed: absent\n # feature = off, absent cadence = 0 (the same \"conservative library\" value the frozen\n # Settings-vs-EngineOptions divergence table records for these fields). Each was dated against the\n # tree before being listed — e.g. `report_every` (2026-06-25) is 0 because the run-report writer\n # itself did not exist before that commit, so an older run wrote no report to preserve.\n \"foresight\": False,\n \"foresight_agentic\": False,\n \"foresight_verify\": False,\n \"concept_pivot\": False,\n \"graded_novelty\": False,\n \"cross_run_concepts\": False,\n \"cross_run_advisory\": False,\n \"cross_run_structured_claims\": False,\n \"cross_run_curation\": False,\n \"cross_run_read_tools\": False,\n \"concept_run_base\": False,\n \"fingerprint_universal\": False,\n \"memora_llm\": False,\n \"comparative_lessons\": False,\n \"lessons_every\": 0,\n \"lessons_refresh_every\": 0,\n \"unified_agent\": False,\n \"deep_research_every\": 0,\n \"report_every\": 0,\n \"failure_reflection\": False,\n \"reflection_priors\": False,\n \"agent_drives_actions\": False,\n \"concurrent_research\": False,\n \"deep_repair\": False,\n # WHAT THIS MAP IS NOT. It is a hand-maintained list of FEATURE switches whose before-the-field\n # value is unambiguous, not a complete partition of `Settings`. Two classes stay out on purpose,\n # because for them a wrong entry is worse than a missing one — it would silently REMOVE behaviour\n # the original run really had:\n # * knobs that are latent while their parent feature is off — `foresight_panel`,\n # `foresight_verify_samples`, `memora_anchors`, `holdout_top_k`, `asha_eta`: the parent above\n # already pins them out of play, and their own before-the-field value is not recoverable;\n # * magnitudes and depths for behaviour that ALREADY existed — `debug_depth`,\n # `context_budget_chars`, `developer_plan_max_steps`, `agent_emit_after`: guessing 0/off there\n # disables working machinery rather than preserving history.\n # A new field belongs here only when it (a) postdates 2026-06-23, (b) defaults to adding paid\n # calls / interventions / concurrency / a different selection policy, and (c) has a historical\n # value you can point at a commit for. When (c) fails, leave it out and say so here.\n}\n\n\ndef migrate_config_snapshot(data: dict) -> dict:\n \"\"\"Return an effective copy of one legacy/current snapshot without rewriting evidence bytes.\"\"\"\n if not isinstance(data, dict):\n raise TypeError(\"config snapshot must be a JSON object\")\n migrated = dict(data)\n for key, value in LEGACY_CONFIG_SNAPSHOT_DEFAULTS.items():\n migrated.setdefault(key, value)\n return migrated\n\n\ndef config_snapshot_schema(data: dict) -> int:\n \"\"\"The snapshot's declared format version. 0 = pre-versioning (the key did not exist yet).\n\n A malformed value is treated as UNREADABLE rather than as 0: a snapshot whose own version marker\n is corrupt cannot be assumed to be the oldest format.\"\"\"\n raw = data.get(CONFIG_SNAPSHOT_SCHEMA_KEY, 0) if isinstance(data, dict) else 0\n if isinstance(raw, bool) or not isinstance(raw, int) or raw < 0:\n raise ConfigSnapshotVersionError(\n f\"config snapshot has a malformed {CONFIG_SNAPSHOT_SCHEMA_KEY}: {raw!r}\")\n return raw\n\n\ndef settings_from_snapshot(data: dict) -> Settings:\n \"\"\"Build re-entry Settings from a masked snapshot under its historical missing-field contract.\n\n Fails closed on a snapshot written by a NEWER binary. `Settings` is `extra=\"ignore\"`, so such a\n snapshot would otherwise load with every unrecognized control silently dropped — the run would\n resume on the same event history under different paid/concurrency/selection semantics, with\n nothing to show why. Refusing is the only honest answer: this binary cannot know what it is\n dropping. Older and pre-versioning snapshots keep loading exactly as before.\"\"\"\n found = config_snapshot_schema(data)\n if found > CONFIG_SNAPSHOT_SCHEMA:\n raise ConfigSnapshotVersionError(\n f\"this run's config.snapshot.json declares format v{found}, but this build understands \"\n f\"at most v{CONFIG_SNAPSHOT_SCHEMA}. It was written by a newer LoopLab; resuming here \"\n \"would silently drop settings this build does not know. Upgrade LoopLab to resume it.\")\n migrated = migrate_config_snapshot(data)\n migrated.pop(\"llm_api_key\", None)\n migrated.pop(CONFIG_SNAPSHOT_SCHEMA_KEY, None) # a document marker, never a Settings field\n return Settings(**migrated)\n"}, "3ae8c5a4fab3ccab9c4deb20a6b07be5811bab60": {"t": 1785768739.9151995, "n": 116190, "h": "7213982372d8c058", "c": "\"\"\"Engine config: the schema (what settings exist). (I0, ADR-11)\n\nOne pydantic-settings class declaring every engine knob, its default, and its docs. The loader —\nhow a run file / CLI / env becomes a `Settings` — is `looplab.core.appconfig`, which also owns\nthe config-precedence order. A resolved, secret-masked snapshot is written next to each run for\nreproducibility. (No real secrets in P0, but the masking discipline is in place.)\n\"\"\"\nfrom __future__ import annotations\n\nimport re\nimport types\nimport typing\nfrom pathlib import Path\n\nfrom pydantic import Field, SecretStr, field_validator, model_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n# Single source of the LLM first-byte (response-headers) default — see core/llm.py.\nfrom looplab.core.llm import DEFAULT_HEADER_TIMEOUT_S\n\n# Default home for cross-run memory + the knowledge base (shared across every run). Both are ON by\n# default now (a user asked for it) so a fresh install accumulates + consults knowledge with no setup;\n# set the env var to \"\" to disable, or to a path to relocate. `~/.looplab/` keeps them out of any one\n# project + shared across projects.\n_LL_HOME = Path.home() / \".looplab\"\n\n# What one `llm_profiles` entry may contain. Deliberately closed: an unrecognized field is a typo the\n# operator would otherwise never see fail, and the map is a public artifact (config.snapshot.json,\n# HTTP, LOOPLAB_*), so it must never accrete free-form keys.\n_PROFILE_FIELDS = frozenset({\"model\", \"base_url\", \"temperature\", \"api_key_env\"})\n# A variable name that `runtime/sandbox.py::SECRET_ENV` will recognize as holding a secret and strip\n# from generated code's environment. Duplicated here, not imported: layering forbids `core` from\n# importing `runtime`. `tests/test_secret_env_pattern.py` holds the two in agreement.\n_SECRET_ENV_NAME = re.compile(\n r\"\\A(?=[A-Z][A-Z0-9_]{0,63}\\Z)(?=.*(KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL)).*\\Z\")\n\n\ndef _is_numeric_annotation(ann) -> bool:\n \"\"\"True when a field's annotation admits int/float but NOT bool.\n\n Used by `Settings._reject_bool_for_numeric_fields`: pydantic resolves annotations at class build,\n so `model_fields[...].annotation` is a real type object here even under\n `from __future__ import annotations`. Optionals are numeric when every non-None member is\n (`int | None` yes, `int | str` no), so a boolean can never slip in through a union arm.\"\"\"\n if ann is bool:\n return False # a bool field legitimately takes a bool\n if ann is int or ann is float:\n return True\n args = [a for a in typing.get_args(ann) if a is not type(None)]\n origin = typing.get_origin(ann)\n if args and origin in (typing.Union, types.UnionType):\n return all(_is_numeric_annotation(a) for a in args)\n return False\n\n# Autonomous roles that may be granted per-setting write access (see Settings.agent_control).\n# strategist = A7 meta-controller (run-wide); boss = run-chat operator-proxy (run-wide);\n# researcher = per-experiment proposer (per-node sizing, e.g. eval timeout for a heavy model).\nAGENT_ROLES = (\"strategist\", \"boss\", \"researcher\")\n\n# A foresight score is computed for every acted-on proposal. Keep this lower than the generic\n# verifier's emergency ceiling so a typo cannot multiply paid synchronous calls across a run.\nMAX_FORESIGHT_VERIFY_SAMPLES = 8\n\n\n# Layer-2 parallelism renamed two public settings without removing the durable legacy fields. Keep\n# the equivalence in one place: config-source precedence, governance migration, and live strategy\n# application must all agree which names describe the same authority/value.\nPARALLELISM_ALIASES: dict[str, str] = {\n \"max_parallel\": \"eval_parallel\",\n \"parallel_build\": \"llm_parallel\",\n}\n\n\ndef parallelism_aliases(setting: str) -> tuple[str, ...]:\n \"\"\"Return every spelling in ``setting``'s alias family, canonical name first.\"\"\"\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if setting in (legacy, canonical):\n return canonical, legacy\n return (setting,)\n\n\ndef canonicalize_parallelism_source(\n values: dict, *, promote_build_to_llm_parallel: bool = False,\n) -> dict:\n \"\"\"Promote legacy parallel keys *within one config-precedence source*.\n\n Applying this only after file/CLI/env were flattened would let a lower-priority canonical env\n value shadow a higher-priority legacy file value (or a file canonical value shadow a CLI legacy\n override), so callers with sub-layers normalize each layer independently.\n\n ``max_parallel``/``eval_parallel`` are true aliases (pure eval width, no extra semantics) and are\n always promoted. ``parallel_build``/``llm_parallel`` are NOT: a positive ``llm_parallel`` also flips\n on the finite shared LLM broker (``orchestrator._startup_llm_total``), while legacy ``parallel_build``\n means \"legacy build width, unbounded overlap, no shared total\". So promoting a legacy-only\n ``parallel_build`` layer (e.g. just ``LOOPLAB_PARALLEL_BUILD=1`` in env) would silently serialize\n every provider call — contradicting llm_broker.py's docstring, orchestrator's \"legacy-only preserves\n unbounded overlap\" note, the broker-neutral live-control path, and the configuration.md ``llm_parallel``\n row (\"falls back ... without enabling a shared total\"). Config/startup loads therefore keep broker\n opt-in tied to an EXPLICITLY-spelled canonical ``llm_parallel``; the orchestrator's own width fallback\n (``llm_parallel if not None else parallel_build``) still carries the legacy build width. Only the\n Strategist live-control path (``canonicalize_strategy_parallelism``) opts into\n ``promote_build_to_llm_parallel``, where ``parallel_build`` is a deliberate full alias of the\n canonical knob (see strategy.py ``_apply_strategy``).\n \"\"\"\n if not isinstance(values, dict):\n return values\n out = dict(values)\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if canonical == \"llm_parallel\" and not promote_build_to_llm_parallel:\n continue\n # None is the durable legacy mode for llm_parallel, not a missing value. A normal snapshot\n # contains llm_parallel:null plus parallel_build:1; promoting null to 1 would unexpectedly enable\n # the global one-call broker. Promote aliases only when the canonical key is absent, and pin\n # snapshot round-trip parity. Canonicalize inside each precedence layer, never across the\n # flattened result, so the alias migration cannot invert CLI > file > env precedence.\n if canonical not in out and out.get(legacy) is not None:\n out[canonical] = out[legacy]\n return out\n\n\ndef flatten_parallelism_layers(layers) -> dict:\n \"\"\"Flatten same-tier precedence layers (LOWEST priority first) and resolve the legacy pair.\n\n PRECEDENCE REPAIR for the non-promoted `parallel_build`/`llm_parallel` pair. Declining to promote\n in `canonicalize_parallelism_source` is right (a legacy-only layer must not flip on the shared\n broker), but it let a LOWER-priority canonical value survive a HIGHER-priority legacy override:\n file `llm_parallel: 2` + CLI `-s parallel_build=8` flattened to BOTH keys, and the orchestrator\n prefers `llm_parallel`, so the operator's explicit CLI width was silently discarded. When a higher\n layer names the legacy width over a lower layer's canonical one, mask with the LEGACY SENTINEL\n (`llm_parallel=None` is the durable \"legacy mode — build width from parallel_build, no shared\n total\"), so the higher layer wins with the semantics it asked for.\n\n The mask lives HERE, not in the per-layer canonicalizer, because only a flatten knows the layer\n ORDER. Stamping it per layer meant any layer naming `parallel_build` emitted `llm_parallel=None`\n even when NO layer in the tier set the canonical key — and since pydantic-settings ranks init\n kwargs above env, that sentinel then silently overrode a `LOOPLAB_LLM_PARALLEL` the operator had\n exported as a deliberate provider rate/cost ceiling. Precedence is per FIELD: a tier that never\n spelled `llm_parallel` has not set it, and it must fall through to env/.env/defaults — which is\n exactly what `build_settings`' docstring promises (\"the merged dict wins only where it actually\n sets a value\").\n \"\"\"\n merged: dict = {}\n legacy_at = canonical_at = -1\n for index, layer in enumerate(layers):\n if not isinstance(layer, dict):\n continue\n merged.update(layer)\n if layer.get(\"parallel_build\") is not None:\n legacy_at = index\n if \"llm_parallel\" in layer:\n canonical_at = index\n # Only when this tier DID set the canonical key, at a strictly lower layer than the legacy one.\n if canonical_at >= 0 and legacy_at > canonical_at:\n merged[\"llm_parallel\"] = None\n return merged\n\n# Settings whose semantics are committed by ``run_started`` and restored from the folded event log\n# on every re-entry. Changing one only in ``config.snapshot.json`` would mix incompatible evidence\n# (a different holdout split) or make the live producer disagree with replay's selection policy.\n# The HTTP config editor and the engine both consume this one contract; keep deliberately mutable\n# controls such as ``trust_gate`` out (that field has its own durable change event).\nRUN_START_PINNED_FIELDS = frozenset({\n \"card_driven_selection\",\n \"speculation_depth\",\n \"holdout_fraction\",\n \"holdout_select\",\n \"select_verifier\",\n \"select_verifier_samples\",\n \"verifier_ci_tie\",\n})\n\n\ndef run_start_pinned_settings(state) -> dict:\n \"\"\"Return the effective Settings names committed by a folded run-start record.\n\n Pre-pin legacy logs have no recorded holdout fraction, so their snapshot remains authoritative\n for both holdout knobs. Verifier fields, however, have always been re-pinned from the legacy-safe\n fold defaults whenever a valid ``run_started`` identity exists.\n \"\"\"\n if not getattr(state, \"run_id\", \"\"):\n return {}\n values = {\n # Layer 3 changes which queue owns macro-action selection. Once a run starts, replay and\n # resume must keep the same owner even if the snapshot or ambient default changes later.\n \"card_driven_selection\": bool(getattr(state, \"card_driven_selection\", False)),\n # Layer 5 overlaps Card production with evaluation only when this pinned treatment is\n # positive. Old logs omit it and therefore retain the exact serial build/eval spine.\n \"speculation_depth\": int(getattr(state, \"speculation_depth\", 0)),\n \"select_verifier\": bool(getattr(state, \"select_verifier_tiebreak\", False)),\n \"select_verifier_samples\": int(getattr(state, \"select_verifier_samples\", 3)),\n \"verifier_ci_tie\": bool(getattr(state, \"verifier_ci_tie\", False)),\n }\n holdout_fraction = getattr(state, \"holdout_fraction\", None)\n if holdout_fraction is not None:\n values.update({\n \"holdout_fraction\": holdout_fraction,\n \"holdout_select\": bool(getattr(state, \"holdout_select\", False)),\n })\n if not values.keys() <= RUN_START_PINNED_FIELDS:\n raise RuntimeError(\"folded run-start pinned settings contract drifted\")\n return values\n\n# --- Run profiles (config-first presets) -------------------------------------------------------\n# A `profile` is a NAMED BUNDLE of setting defaults — nothing more. It only fills fields the user\n# did NOT set explicitly (via file / --set / LOOPLAB_* env / init-kwargs), so any explicit knob\n# always wins (see `_apply_profile`). Most engine intelligence lives behind individual flags. The\n# PART IV/V cross-run + concept-graph machinery (concept_pivot, graded_novelty, cross_run_concepts /\n# _advisory / _structured_claims / _curation / _read_tools, fingerprint_universal, foresight_verify)\n# now ships ON in the product `Settings` default. That is an explicit experimental product choice,\n# not a claim that a frozen A/B or workload-wide cost/quality gate has cleared: the bundle can steer\n# prompts and candidate generation, change graded-novelty admission / pre-execution choices, and add\n# paid LLM work when its client and memory prerequisites are present. The bare-library `EngineOptions`\n# default stays lean (see tests/test_options_divergence.py — product side is the aggressive one) so a\n# toy `Engine(...)` in a test doesn't fire cross-run LLM work unasked. The trust/confirm/ablate QUALITY\n# bundle still ships OFF and is turned on by `thorough`. Every value here is reachable by hand — the\n# profile is a convenience, never a hidden mode.\n# keep this rationale behavioral and evidence-based; do not relabel read-only storage\n# access or proposal-only governance as a no-op for agent behavior, cost, or proposal admission.\n#\n# `thorough` deliberately touches only QUALITY/TRUST machinery, not spend: it does NOT raise\n# max_nodes / eval_parallel (those are cost knobs the user owns). It enables multi-seed confirmation\n# (demote seed-lucky leaders), the reward-hack / leakage / critic monitors AND flips them from\n# advisory to *gating* (`trust_gate=\"gate\"` — a\n# flagged win can no longer be selected as best), ablation-driven refinement, and the prompt cues\n# (complexity / budget / failure-reflection / watchdog-reflection). Agentic novelty and the experimental\n# Part IV/V bundle are already product defaults, not profile changes. Effect is large; marginal cost is a\n# few extra evals.\nPROFILES: dict[str, dict] = {\n \"default\": {}, # explicit no-op alias for \"ship-as-is\" (== omitting profile)\n \"fast\": {}, # today's lean defaults, named for symmetry with `thorough`\n \"thorough\": {\n \"confirm_top_k\": 3,\n \"confirm_seeds\": 3,\n # novelty_gate intentionally NOT enabled here: duplicate-detection is the agentic Researcher's\n # job (it reads past experiments + find_analogous_across_runs and DECIDES), not an algorithmic\n # param-distance / embedding auto-reject that mis-fired (it nudged a good fresh idea onto a\n # twice-dead lineage — live node 61). Search stays as tools; the LLM judges novelty.\n \"operator_bandit\": True, # P4: adaptive operator mix over folded yields (GreedyTree)\n \"reward_hack_detect\": True,\n \"code_leakage_detect\": True,\n \"critic_check\": True,\n \"trust_gate\": \"gate\", # detectors stop gating a flagged node from WINNING (not just audit)\n \"ablate_every\": 3,\n \"complexity_cue\": True,\n \"budget_aware\": True,\n \"failure_reflection\": True,\n \"watchdog_reflection\": True, # feed recent live-watchdog (train-monitor/ASHA) flags to proposals\n \"reflection_priors\": True, # no-op unless memory_dir is set (cross-run priors)\n },\n}\n\n\n# The shipped agent-governance matrix (who may change which setting at runtime). Defined as a module\n# constant so the Engine's direct-construction default matches this shipped default exactly (the\n# EngineOptions \"Engine() == shipped defaults\" invariant) instead of diverging to an all-locked map.\nDEFAULT_AGENT_CONTROL: dict[str, list[str]] = {\n \"timeout\": [\"researcher\", \"strategist\"],\n # Layer-2 canonical names are the presented/default governance surface. `_agent_may` falls back\n # to an old snapshot's legacy grant only when its canonical counterpart is absent, so removing\n # duplicate defaults preserves resume authority while an explicit canonical revocation still wins.\n \"eval_parallel\": [\"strategist\"],\n \"llm_parallel\": [\"strategist\"],\n # Strategy-only pseudo-setting: the closed per-lane allocation inside the canonical LLM total.\n # It is not a launch Settings field; operators pin it through `set_strategy` and the Strategist\n # may reallocate it only while this grant is present.\n \"llm_lane_limits\": [\"strategist\"],\n \"max_nodes\": [\"strategist\", \"boss\"],\n \"max_eval_seconds\": [\"strategist\", \"boss\"],\n \"policy\": [\"strategist\"],\n # The policy NAME and its PARAMS are gated independently in `_apply_strategy`; grant both by default\n # so the Strategist that may switch to (say) MCTS may also apply the `c`/`eta` it decided — else its\n # tuned params are silently dropped and the recorded `active_strategy` diverges from the live engine.\n \"policy_params\": [\"strategist\"],\n \"n_seeds\": [\"strategist\"],\n \"ablate_every\": [\"strategist\"],\n \"merge_mode\": [\"strategist\"],\n \"complexity_cue\": [\"strategist\"],\n \"ablate_code_blocks\": [\"strategist\"],\n \"prefer_sweep\": [\"strategist\"],\n \"novelty_stance\": [\"strategist\"],\n \"developer\": [\"strategist\"],\n \"fidelity\": [\"strategist\", \"researcher\"],\n}\n\n\ndef default_agent_control() -> dict[str, list[str]]:\n \"\"\"A FRESH deep-ish copy of the shipped governance matrix (each role list copied so a caller can\n mutate its own map without touching the module constant). The SINGLE constructor of the default,\n called by both `Settings.agent_control`'s default_factory AND the Engine's direct-construction\n default — so \"Engine() == shipped defaults\" can't drift if the copy depth ever needs to change\n (e.g. nested values requiring copy.deepcopy), instead of two verbatim copy expressions.\"\"\"\n return {k: list(v) for k, v in DEFAULT_AGENT_CONTROL.items()}\n\n\nclass Settings(BaseSettings):\n \"\"\"The engine settings schema (every knob a run accepts).\n\n Timeout family — six distinct knobs, each owned by a different subsystem:\n - `timeout`: per-eval wall-clock budget for ONE experiment's evaluation (engine/eval).\n - `max_eval_timeout`: hard ceiling for a governed per-node Researcher timeout override.\n - `llm_timeout`: LLM request idle timeout — inter-token stall limit in stream mode\n (core.llm OpenAICompatibleClient).\n - `llm_header_timeout`: LLM first-byte (response-headers) window for stream attempts (core.llm).\n - `agent_time_budget_s`: wall-clock ceiling across a tool-using agent loop's turns (agents).\n - `dep_install_timeout`: per-package budget for auto-installing a missing dep (env self-prep).\n\n Config-source precedence is owned by `looplab.core.appconfig` (the loader) — see its module\n docstring for the one canonical order.\n \"\"\"\n # Config precedence: see appconfig.py — the loader owns the full order. The `.env` is read\n # so `looplab run`/`looplab ui` pick up LOOPLAB_LLM_BASE_URL etc. without exporting them by hand\n # (keys MUST carry the LOOPLAB_ prefix, same as the env vars). utf-8-sig tolerates a BOM from\n # Windows editors. The test suite disables this (tests/conftest.py) so a dev's real .env in the\n # repo root can't leak into assertions.\n model_config = SettingsConfigDict(\n env_prefix=\"LOOPLAB_\",\n env_file=\".env\",\n env_file_encoding=\"utf-8-sig\",\n extra=\"ignore\",\n allow_inf_nan=False,\n )\n\n @classmethod\n def settings_customise_sources(\n cls, settings_cls, init_settings, env_settings, dotenv_settings,\n file_secret_settings):\n \"\"\"Preserve source precedence across canonical/legacy parallelism names.\n\n Pydantic ranks init > env > dotenv correctly, but it ranks field names rather than semantic\n alias families. Promoting a legacy value inside each source before the merge makes an old\n explicit snapshot/file value beat a lower-priority canonical env value. ``appconfig`` also\n promotes its file/typed/--set sub-layers before combining them into the init source.\n \"\"\"\n def _with_parallel_aliases(source):\n return lambda: canonicalize_parallelism_source(source())\n\n return (\n _with_parallel_aliases(init_settings),\n _with_parallel_aliases(env_settings),\n _with_parallel_aliases(dotenv_settings),\n _with_parallel_aliases(file_secret_settings),\n )\n\n # Run profile (config-first preset, see PROFILES above). \"default\"/\"fast\" = today's lean\n # defaults; \"thorough\" turns the built quality/trust machinery ON in one word. A profile only\n # fills fields the user did NOT set — every knob it touches stays individually overridable.\n profile: str = \"default\"\n\n # Bounds (review C/⚪): a non-positive node/seed budget or timeout is never valid. Upper bounds are\n # equally load-bearing — the UI /start path writes these straight into the engine's env, and an\n # unbounded `eval_parallel` (or legacy `max_parallel`; e.g. 100000 from a crafted preflight) would\n # fan out that many sandboxes → resource exhaustion. Reject at config time, not mid-run. Ceilings\n # are generous; tests top out near 50.\n # Pydantic's lax mode coerces a JSON boolean into any numeric field (`true` -> 1), so a UI/API type\n # error used to silently collapse a run budget to ONE node instead of returning 422. Rejected for\n # EVERY numeric field by `_reject_bool_for_numeric_fields` below, not just the ones bounded here.\n n_seeds: int = Field(default=3, ge=1, le=1024)\n max_nodes: int = Field(default=8, ge=1, le=1_000_000)\n # legacy evaluation-width input. `eval_parallel` below is canonical and wins when set;\n # keep this field for old env/config/snapshots and direct Engine call sites. A width > 1 admits\n # concurrent evals, whose resource reservations may be CPU-only or span one/more GPUs — there is no\n # one-node/one-GPU promise. `max_parallel=0` is launch-time AUTO (detected GPU count, at least 1).\n max_parallel: int = Field(default=1, ge=0, le=1024)\n # legacy build-width input. `llm_parallel` below is canonical and also controls the\n # shared provider-call total when positive. A width > 1 uses fresh pooled researcher/developer pairs;\n # it says nothing about the later eval's CPU/GPU reservation. Startup `0` derives build fan-out from\n # resolved eval width; a live Strategist/operator zero settles to 1. Without role_factory it clamps to 1.\n parallel_build: int = Field(default=1, ge=0, le=64)\n # Hypothesis-card Kanban re-architecture (docs/23, Layer 2). Two INDEPENDENT concurrency axes,\n # decoupled from each other so cost (LLM work) can be tuned separately from throughput (GPU evals).\n # These are the CANONICAL names going forward; `max_parallel`/`parallel_build` above are kept as thin\n # back-compat aliases (env vars / snapshots / ~100 Engine(...) call sites depend on the exact names,\n # so they cannot be renamed — CLAUDE.md). Resolution (orchestrator.__init__): a NEW name, when set,\n # WINS over its legacy alias; None => fall back to the legacy field => byte-identical to today.\n # `eval_parallel`: concurrent EVALS (the GPU/experiment consumer). None -> max_parallel; 0 = AUTO\n # (one experiment per detected GPU). This is the in-Run eval-concurrency ceiling; the resource\n # scheduler bin-packs declared footprints separately. Sibling local GPU-owning Runs serialize\n # through the conservative host-pool lease; external execution needs its own admission boundary.\n # `llm_parallel`: total concurrent LLM provider calls + BUILD fan-out. None -> legacy\n # parallel_build semantics without a shared total; 0 = startup AUTO (= resolved eval_parallel\n # for build fan-out, historical research overlap unbounded). A positive canonical value enables\n # the named-lane broker the Strategist can reallocate at cadence.\n eval_parallel: int | None = Field(default=None, ge=0, le=1024)\n llm_parallel: int | None = Field(default=None, ge=0, le=64)\n # Training-log monitor (I-series watchdog family): a per-eval background observer that tails the live\n # training log while a (often multi-hour) declared stage runs in a worker thread. ON in the product\n # surface (Settings). Its alert event is fold-ignored and cannot directly change lifecycle/champion;\n # with watchdog_reflection on, that alert is nevertheless advice in a later Researcher\n # prompt and can change future proposals. It only fires on the command-eval path with an LLM client;\n # bare-library EngineOptions stays OFF. See test_options_divergence.\n # `_interval_s` is the BASE tick cadence (the effective one adapts to the per-experiment budget).\n train_monitor: bool = True\n train_monitor_interval_s: float = Field(default=600.0, gt=0)\n # Phase 3 — INTERVENTION (opt-in, separate from observation): let the monitor tree-kill a training the\n # LLM judges 'broken' (diverged / silent CPU fallback / not learning) EARLY, instead of burning the\n # whole budget. Off by default — the observer only WATCHES unless this is on. A kill only fires on a\n # 'broken' verdict (a plateau is 'watch', never killed) with confidence >= the threshold; the node then\n # fails normally (reason='monitor_broken'), so replay reconstructs it from that one terminal event.\n train_monitor_kill: bool = False\n train_monitor_kill_confidence: float = Field(default=0.8, ge=0.0, le=1.0)\n # ASHA live-curve watchdog (sibling of the training monitor): reads the latest INTERMEDIATE value of\n # the objective metric off the live log (reusing the eval's OWN metric reader). Finished-endpoint rank\n # remains advisory. An opt-in KILL additionally requires an operator-declared metric.resource_key and\n # enough sibling observations at exactly that resource; absent comparable evidence cannot stop a run.\n # Records a fold-ignored `asha_rank` diagnostic + trace span; the library default is OFF (off == today).\n asha_live: bool = True\n # Opt-in INTERVENTION (separate from the advisory signal): let the watchdog tree-kill a node whose\n # intermediate metric stays below the SAME-RESOURCE bar past a short grace window. Off by default —\n # it only surfaces endpoint rank unless this is on. A kill fails normally (asha_underperforming).\n asha_live_kill: bool = False\n # The bar sits at `asha_live_quantile` along a WORST->BEST ordering of the finished siblings' finals:\n # 0.5 = the median (a node worse than the median peer flags); a SMALLER value is more conservative —\n # it lowers the bar toward the WORST finished peer, so only a node worse than nearly all peers flags\n # (0.0 = worse than the worst). `min_siblings` is the floor of finished peers required before it ranks\n # at all, so it never acts on too little evidence.\n asha_live_quantile: float = Field(default=0.5, ge=0.0, le=1.0)\n asha_live_min_siblings: int = Field(default=3, ge=1)\n timeout: float = Field(default=30.0, gt=0)\n # Hard ceiling for the Researcher's governed per-node `Idea.eval_timeout`. One hour preserves the\n # existing heavy/neural-net authoring example while staying well below the sandbox's defensive\n # 24-hour subprocess ceiling. This is operator-owned run config: agents may request less or more,\n # but the accepted action is always clamped here after the `agent_control.timeout` permission gate.\n max_eval_timeout: float = Field(default=3600.0, gt=0, le=24 * 3600.0)\n # Intra-node sweep: a sweep node runs a whole grid in one process, so it gets this multiple of\n # `timeout` as its wall-clock budget (solution.py path; RepoTasks use their per-profile timeout).\n sweep_timeout_mult: float = Field(default=8.0, ge=1)\n # Eval STALL watchdog CAP (seconds): a stage that is completely SILENT on stdout/stderr for this\n # long — while still alive and BELOW its wall-clock deadline — is tree-killed early with a STALLED\n # marker (a hung dataloader/deadlock dies in minutes instead of burning a multi-hour timeout). The\n # per-stage window is `min(this, the stage's own timeout)`, so a short scorer's deadline fires first.\n # `0` DISABLES the watchdog entirely (only the hard deadline applies) — for a legitimately quiet\n # non-Python stage (block-buffered stdout, a script that logs only to its own file). Threaded into\n # the eval and surfaced to the Developer so its code can emit periodic progress to stay alive.\n eval_stall_timeout_s: float = Field(default=1800.0, ge=0)\n # Sandbox tier (ADR-13): \"trusted_local\" (subprocess, no Docker) for the CLI;\n # \"untrusted\" (Docker --network none, shared-kernel runtime) for hosted/multi-tenant UI;\n # \"hostile\" (untrusted + a true-isolation OCI runtime, gVisor `runsc` by default / Kata) for\n # running untrusted code. See runtime/sandbox.py::make_sandbox for the tier selection.\n trust_mode: str = \"trusted_local\"\n # Image for the untrusted command-eval tier (RepoTask, Phase 4): the framework's deps\n # should be baked in (the container runs --network none, so a pip setup can't fetch).\n docker_image: str = \"python:3.12-slim\"\n # Resource caps for the untrusted/hostile Docker tier (passed to `docker run --memory/--cpus`).\n # The default 4g bound protects other tenants from an OOM, but a model-training MLE-bench eval\n # routinely needs more — raise it (e.g. \"16g\") for such runs. \"\" disables the cap (unbounded).\n # Ignored by the trusted_local (subprocess) tier.\n sandbox_memory: str = \"4g\"\n sandbox_cpus: str = \"\"\n # Best-effort host-OOM guard for the TRUSTED-LOCAL (subprocess) tier. When set (e.g. \"8g\"), each\n # eval child gets an RLIMIT_AS address-space cap so a runaway trainer hits MemoryError instead of\n # OOM-killing the whole host (and the engine). POSIX only; a no-op on Windows. Default \"\" = OFF:\n # RLIMIT_AS bounds VIRTUAL memory, and CUDA/torch reserve tens of GB of virtual without using it,\n # so a default cap would spuriously kill GPU evals — leave it off for those and use the Docker\n # tier's real --memory cgroup bound (sandbox_memory) instead. Ideal for CPU/numpy/sklearn runs.\n sandbox_memory_local: str = \"\"\n # Best-effort disk-fill guard for the TRUSTED-LOCAL tier (P1-5): an RLIMIT_FSIZE cap on the size of\n # any single file an eval child writes (e.g. \"2g\"), so a runaway that writes an unbounded file gets\n # SIGXFSZ instead of filling the host disk. POSIX only. Default \"\" = OFF (large model checkpoints\n # need big files); set it for tasks that write only small artifacts.\n sandbox_fsize_local: str = \"\"\n # Search policy (ADR-2): \"greedy\" | \"evolutionary\" | \"mcts\" | \"asha\".\n policy: str = \"greedy\"\n # Ablation-driven refinement (I7): every N improves, ablate the best to find the\n # highest-impact parameter and refine it. 0 = off. (GreedyTree only.)\n ablate_every: int = 0\n # A0a (MLE-STAR): when ablating, treat each generated pipeline *code block* (not just a\n # numeric param) as an ablation unit — neutralize a block, measure the metric delta, refine\n # the highest-impact block. Off => the classic numeric-param ablation. Config-first knob.\n ablate_code_blocks: bool = False\n # A0b: real merge/ensembling. \"mean\" = legacy mean-param merge; \"ensemble\" = the Developer\n # writes a code-recombination ensemble over the two parents' solutions (verified: agent-proposed\n # ensembling 37.9%->43.9%). \"auto\" (default) = ensemble whenever the Developer actually\n # generates code (LLM/agent backends; declared via `is_code_generating`), mean for the\n # templated/toy developers where a code ensemble is meaningless — the evidence says code\n # recombination is the strongest single merge (removing it costs ~9 pp), so it is the default\n # wherever it can work. Falls back to mean when the Developer can't ensemble.\n merge_mode: str = \"auto\"\n # A0d (AIRA): inject a dynamic complexity hint into the draft/improve prompt keyed on the\n # node's child count (few children -> keep minimal; many -> escalate to ensembling/HPO).\n complexity_cue: bool = False\n # I1 (CAAFE-style) LLM feature-engineering: instruct the proposer/developer to add\n # semantically-meaningful engineered features (ratios, interactions, aggregations) as code. The\n # existing cross-validation eval + best-selection is the CV GATE — a feature set that doesn't\n # improve CV is never selected (feature engineering is non-universal, so this gate is mandatory).\n # Tabular tasks (those exposing column data). Off by default.\n feature_engineering: bool = False\n # A5: surface the REMAINING eval-compute budget into the proposal prompt so the Researcher can\n # reason over how much compute is left (explore broad while flush, exploit/cheapen when low).\n # No-op unless a max_eval_seconds budget is set.\n budget_aware: bool = False\n # A4 (LATS-style): feed a summary of the most recent FAILED branches (operator + error reason)\n # back into the proposal prompt so the proposer reflects on and avoids repeating them. ON by\n # default: it is SELECTIVE by construction (injects only when recent failures exist — the\n # evidenced form of reflection; reflecting at every step shows no gains, arXiv:2506.12928).\n failure_reflection: bool = True\n # Feed the LIVE-WATCHDOG observations (train-monitor health verdicts + ASHA intermediate-rank flags)\n # of recent experiments back into the proposal prompt, so the Researcher avoids re-proposing a config\n # whose TRAINING was already observed to be weak — even when the watchdog kills are off (default) and\n # the node ran to completion (those diagnostics are fold-ignored, so failure_reflection never sees\n # them). ON by default and SELECTIVE like failure_reflection (injects only when a recent flag exists).\n watchdog_reflection: bool = True\n # C3 deep test-driven repair: hand the Developer the failure taxonomy + a structured \"reproduce\n # then fix\" directive on debug, not just the raw stderr tail. ON by default (product surface); the\n # conservative library default in EngineOptions stays off, per that module's contract.\n deep_repair: bool = True\n # === Inline crash repair ==============================================================\n # Hybrid in-node crash repair: when an LLM-generated node CRASHES at runtime (mechanical errors\n # — bad import, removed kwarg, typo), the agent triages it and may repair the code IN PLACE within\n # the same eval (cheap; does NOT consume max_nodes and does NOT add a node to the search tree).\n # Semantic failures still flow to a new idea/debug node via the policy. ON by default; set False\n # to restore the prior behavior (every crash waits for a budgeted inter-node debug node).\n inline_repair: bool = True\n # Max in-place repair attempts per node before it fails normally. Default 0 = UNLIMITED: the same\n # agent keeps fixing + re-evaluating IN THE SAME node until it produces a clean metric — no new\n # node just to retry. Runaway is prevented not by a count but by the anti-stuck guard\n # (`inline_repair_stuck_repeat`): when the SAME error signature repeats with no progress, or the\n # agent's crash-triage says \"abandon\", the node fails. Set a positive N to cap the retries instead.\n inline_repair_attempts: int = Field(default=0, ge=0)\n # Anti-stuck: abandon in-node repair when the SAME error recurs this many times in a row (no\n # progress). Keeps \"unlimited\" repair from looping forever on an unfixable error.\n inline_repair_stuck_repeat: int = Field(default=4, ge=2)\n # Which failure reasons (_failure_reason: crash|timeout|oom|setup|no_metric|drift) are eligible for\n # inline repair. Default: mechanical crashes, timeouts AND OOM-kills — a timeout/OOM means the code\n # was too slow / too memory-hungry for the budget (or the pod's cgroup limit), not that the idea is\n # wrong, so the agent repairs it by reducing compute/memory (fewer estimators/epochs/folds/seeds, a\n # lighter model, a smaller batch, subsampling) instead of letting the node die unrecovered. Env\n # override expects a JSON array.\n inline_repair_reasons: tuple[str, ...] = (\"crash\", \"timeout\", \"oom\")\n # Cost ceiling for in-node repair of a MULTI-STAGE eval: the max number of FULL pipeline re-runs\n # (i.e. re-trains from the first stage) the inline-repair loop may do before abandoning to the\n # inter-node debug operator. A late-stage failure (a broken `score`/eval script that didn't touch\n # `train`) REUSES the completed train checkpoint and re-runs only from the failed stage — that is\n # CHEAP and NOT counted here; only a repair that changes an EARLIER stage's code (train.py/loss.py/…)\n # forces a full re-train, which each costs the whole training time. Without this bound, a repair that\n # keeps changing training code could burn many full trains (the anti-stuck guard is error-signature\n # based, not cost based). 0 = unlimited (legacy behavior). Single-command evals ignore it.\n inline_repair_retrain_cap: int = Field(default=2, ge=0)\n # Environment self-prep: when a solution crashes purely because a KNOWN library isn't installed\n # (ModuleNotFoundError), the engine pip-installs it into the eval interpreter and re-runs — so a\n # torch/XGBoost/CatBoost idea (e.g. a GRU model) actually runs on a fresh box instead of being\n # thrown away as `idea_rejected`. Trusted_local tier only (the untrusted/hostile Docker tiers run\n # --network none). A typo'd or local helper module is NOT installed (that's a code bug to repair).\n auto_install_deps: bool = True\n # Per-package wall-clock budget for an auto-install (seconds). Generous: large wheels like torch\n # can take minutes to download/build.\n dep_install_timeout: float = Field(default=900.0, gt=0)\n # --- Agent governance: who may change a setting at runtime (per-setting allow-list of roles) ---\n # Roles: \"strategist\" (A7 meta-controller; run-wide), \"boss\" (run-chat operator-proxy; run-wide),\n # \"researcher\" (per-experiment proposer; PER-NODE — e.g. set a longer eval timeout for a neural-net\n # idea). A setting ABSENT from this map is LOCKED — no agent may change it, only a human via the\n # snapshot/UI. The sole conditional exception is Strategy-only `card_scoring`: Card mode grants\n # it implicitly without changing the default flag-off snapshot, while an explicit [] revokes it.\n # The gate is ENFORCED at every AGENT seam via `_agent_may` — the strategist's whole\n # control surface (policy/operators/novelty_stance/prefer_sweep/developer/fidelity/timeout/\n # eval_parallel/llm_parallel) is checked in `_apply_strategy`, so removing a role from a knob locks it,\n # not just greys out the UI pill. The boss/max_* grants document boss-relevant knobs, but a\n # `budget_extend` is a HUMAN control intent (the boss action-builder can only emit `add_nodes`), so\n # it is applied as-is — the human always wins via the UI/snapshot. Defaults are conservative: resource/\n # search-shape knobs the agents already reason about; provider infra (llm_model/base_url/api_key,\n # docker_image) stays locked. The UI shows S/B/R pills per setting. `timeout` granted to the\n # researcher IS the \"auto\" per-node mode — the researcher sizes each experiment's wall-clock\n # (Idea.eval_timeout); the config `timeout` is the fallback for nodes it doesn't size. Env override\n # expects a JSON object. (`fidelity`/`novelty_stance`/`prefer_sweep`/`developer` are governance keys\n # for the strategist's per-node dials — not 1:1 Settings fields, but gated the same way.) The default\n # lives in the module-level DEFAULT_AGENT_CONTROL so the Engine's direct-construction default matches.\n agent_control: dict[str, list[str]] = Field(default_factory=default_agent_control)\n # C1 (Agentless localization): for repo tasks, rank the source files most relevant to the most\n # recent failure (traceback + identifiers) and surface them in the proposal/repair prompt so the\n # Developer edits the right place. Off by default; repo tasks only.\n localize_faults: bool = False\n # A2 surrogate-guided proposal (BO-lite): fit a k-NN surrogate over (params->metric) and propose\n # by acquisition instead of random/hill-climb. Numeric-bounds tasks only; bootstraps via the\n # base Researcher. Off by default. `surrogate_explore` = UCB-style exploration weight.\n surrogate_proposer: bool = False\n surrogate_explore: float = 0.1\n # E2 researcher panel: generate K candidate ideas per proposal and keep the one ranked best by a\n # cheap empirical surrogate over the (params->metric) history (NOT an LLM-judge). 1 = off.\n researcher_panel: int = 1\n # A1 ASHA / successive-halving: reduction factor (keep top 1/eta per rung) and rung budget.\n asha_eta: int = Field(default=3, ge=2)\n # Rung-0 width for ASHA/BOHB (the wide base of cheap drafts). 0 = use n_seeds (default,\n # preserving prior behavior); set >0 to seed a wider/narrower base independent of n_seeds.\n asha_rung_nodes: int = Field(default=0, ge=0)\n # A6 proxy/predictive scoring: cheaply rank a candidate's potential from early-stage signals\n # and skip a full eval for the doomed bottom fraction. 0.0 = off (no candidate skipped).\n proxy_scoring: bool = False\n proxy_kill_fraction: float = Field(default=0.0, ge=0.0, le=0.9)\n # === Novelty / dedup ==================================================================\n # E1 novelty/dedup gate: reject near-duplicate proposals (normalized param-space distance below\n # `novelty_epsilon`) by deterministically nudging them off the duplicate — stops the search\n # wasting evals re-trying the same idea. Audit event `novelty_rejected`. Off by default.\n # Novelty/dedup MODE selector: how a fresh proposal is checked against already-tried experiments.\n # \"off\" — no explicit gate; the agentic Researcher's own read-the-history judgment is trusted.\n # \"algo\" — the deterministic gate: numeric param-distance (`novelty_epsilon`) + optional embedding\n # similarity (`novelty_semantic`). Cheap, no LLM call, but can't explain itself.\n # \"llm\" — an LLM adjudicates (reads the real experiments via tools) whether the idea is a\n # near-duplicate and, if so, asks the Researcher once more for something different. One\n # extra LLM call per proposal — highest quality, follows the \"let the LLM decide\" line.\n novelty_mode: str = \"llm\"\n # Legacy sub-toggles, honored by the \"algo\" mode (and back-compat: novelty_gate=True forces \"algo\"):\n novelty_gate: bool = False\n novelty_epsilon: float = 0.05\n # T5 semantic novelty (active whenever the deterministic gate runs — novelty_mode=algo,\n # novelty_gate=true which is the legacy alias for algo, OR the Strategist novelty stance is\n # \"explore\"; a no-op only under novelty_mode=llm/off with a non-explore stance): reject a proposal\n # whose idea TEXT embeds within\n # `novelty_semantic_threshold` cosine of an existing node's. OFF BY DEFAULT: novelty is the agentic\n # Researcher's call, made by READING the actual prior experiments (read_experiment /\n # find_analogous_across_runs / list_experiments), NOT an embedding-similarity auto-reject that can't\n # explain itself and mis-fires on paraphrases. Semantic/param search stays available as TOOLS in the\n # Researcher's context; the LLM decides whether an idea is a true duplicate.\n novelty_semantic: bool = False\n novelty_semantic_threshold: float = Field(default=0.92, ge=0.5, le=1.0)\n # T10: debug-lineage depth bound for every policy — how many error-feedback repairs a failing\n # lineage gets before it is abandoned. The old default (1) abandoned lineages after ONE repair;\n # multi-turn/deeper debugging is a verified lever (AIRA2 ReAct debug +5.5 percentile points).\n debug_depth: int = Field(default=2, ge=1)\n # P4 operator bandit (GreedyTree): replace the fixed merge/ablate cadences with a\n # deterministic UCB over per-operator yield (Δmetric per eval-second, folded from the run).\n # Off by default (the cadences are well-tested and the bandit has no direct published\n # ablation); `thorough` turns it on.\n operator_bandit: bool = False\n # P1 hypothesis ledger: ask the Researcher to state the one-line hypothesis each experiment tests,\n # register deep-research directions as hypotheses, and track them to a verdict on the board. ON by\n # default. The board is shown to the Researcher and can be foresight-ordered, so it can\n # steer later proposals even though it never directly re-ranks already-evaluated nodes. Set False\n # to drop the prompt nudge + registration.\n track_hypotheses: bool = True\n # E4/M2/M3 reflection-memory -> priors (gradient-free cross-run meta-learning): at run end distill\n # the winner into `<memory_dir>/meta_notes.jsonl` AND structured lessons (incl. NEGATIVE results:\n # tested/abandoned hypotheses + failure themes) into `lessons.jsonl` with a task fingerprint; at run\n # start, inject exact-task notes + fingerprint-matched lessons from SIMILAR tasks into the proposal\n # prompt. ON by default — but a NO-OP until `memory_dir` is set (that's where cross-run memory lives).\n reflection_priors: bool = True\n # M6 comparative lessons (MARS \"comparative reflective memory\", doc 13 §7 item 2): distill\n # credit-assigned lessons from PAIRS of solutions — which SPECIFIC change made a child beat\n # (or regress from) its parent, and what fixed a failure — on top of the one-shot run-end\n # reflection. At most one extra LLM call per distillation (all pairs batched); offline a\n # deterministic param-diff credit stands in. Gated on reflection_priors + memory_dir like the\n # rest of cross-run memory — and since memory_dir has a default (~/.looplab/memory), this is\n # ACTIVE out of the box; clear memory_dir to disable cross-run memory wholesale.\n comparative_lessons: bool = True\n # M6 mid-run cadences (doc 13 §7 item 5 — the AgentRxiv live-share pattern): every N created\n # nodes, WRITE comparative lessons to the shared cross-run store during the run (lessons_every\n # — one batched LLM call per firing) and RE-READ the store so lessons distilled by CONCURRENT\n # runs reach this run's proposals (lessons_refresh_every; file re-read, no LLM call, skipped\n # when the store is unchanged). ON by default (every 4 nodes) and, like comparative_lessons\n # above, live out of the box because memory_dir defaults on; 0 = off — run-end write /\n # run-start read only, the pre-M6 behavior.\n lessons_every: int = Field(default=4, ge=0)\n lessons_refresh_every: int = Field(default=4, ge=0)\n # B3 output redaction: mask credentials (known key shapes + high-entropy tokens) in the\n # stdout/stderr tail before it is persisted to the event log / spans / UI — a leaked secret in a\n # print()/traceback must not land in the durable log. Off by default; recommend on for untrusted.\n redact_output: bool = False\n # B5 reward-hacking detector: a host-side monitor that flags suspicious wins (grader/answer-key\n # access, runtime writes to frozen files, suspiciously-perfect metrics) as a `reward_hack_suspected`\n # audit event in the Trust panel. Off by default. Whether a flag CHANGES selection is governed by\n # `trust_gate` below (default: audit-only, never changes selection).\n reward_hack_detect: bool = False\n # Trust enforcement (T2): what a reward-hack / data-leakage flag actually DOES to selection.\n # \"audit\" (default) = surface it in the Trust panel, never change selection (today's behavior);\n # \"gate\" = a flagged node can no longer be selected as BEST **and isn't bred/confirmed from**\n # (breed_excluded — see replay._apply_trust_gate / models.breedable_nodes), but stays\n # FEASIBLE so it still counts for diversity/audit — closes the \"a hacked/leaky node can\n # win OR seed its lineage\" hole;\n # \"block\" = additionally mark it fully INFEASIBLE (removed from feasible_nodes entirely).\n # Deliberately gates only high-precision CHEATING/LEAKAGE signals. Broad `critic:*` findings stay\n # advisory; `critic:hardcoded_metric` is the one narrow high-precision critic exception\n # and gates because it requires a literal metric with no computed assignment anywhere.\n trust_gate: str = \"audit\"\n # I3 data-centric: static code-leakage scan of each evaluated solution (fit-before-split,\n # fit-on-test) surfaced into the Trust panel alongside reward-hack flags. Off by default.\n code_leakage_detect: bool = False\n # 4.4 sandbox instrumentation (needs reward_hack_detect): after each eval, flag a RUNTIME write\n # to a protected/frozen file (an answer key or grader tampered mid-run) — behavioral evidence a\n # static code scan misses. ON by default; only fires when the reward-hack detector is on.\n workdir_audit: bool = True\n # D8 decoupled research Verifier: check every Deep-Research memo's CLAIMS against their cited\n # evidence (node ids / urls) before recording — synthesis is the documented weak link (Kosmos\n # 57.9% accurate). Deterministic layer always; an LLM rubric pass when the DeepResearcher has a\n # client. The verdict cannot change this run's node/champion, but finalize uses aligned supported\n # verdicts as the evidence gate for persisted D8 cross-run claims; unavailable/misaligned stays\n # unverified. ON by default (no-op with no memo/claims).\n research_verify: bool = True\n # C4 independent critic: an execution-free critic of each solution (stub / hardcoded-metric /\n # params-ignored; on host-graded tasks the metric checks become a submission-output check)\n # surfaced in the Trust panel. Broad findings are advisory; `critic:hardcoded_metric` can gate under\n # trust_gate=gate/block. Off by default.\n critic_check: bool = False\n # A7 Strategist (NEW, user-requested): optional LLM/rule meta-controller that picks the search\n # policy/allocator + operator mix + fidelity (+ Developer backend) per situation. Config-first:\n # every choice the Strategist makes is also a direct knob. Defaults to \"agent\" so the agent adapts\n # the search policy/operators/fidelity per situation; set \"off\" for static config-driven search.\n # \"agent\" = a tool-using Strategist that READS the run/data/sibling-runs/KB/memory (B1-guarded)\n # before deciding, instead of the single-shot \"llm\" call over aggregate stats.\n strategist_backend: str = \"agent\" # \"off\" | \"rule\" | \"llm\" | \"agent\" — default AGENTIC: the\n # Strategist READS the run/data/sibling-runs/KB/memory with tools before deciding, not a single-shot\n # call over aggregate stats. \"llm\" = the old non-agentic single-shot; \"rule\"/\"off\" = no LLM.\n strategist_every: int = Field(default=3, ge=1) # consult cadence (created nodes)\n # PART V (F1): concept CLASSIFIER re-tag + consolidation cadence, DECOUPLED from strategist_every. The\n # LLM concept map is heavier and slower-moving than a strategy consult, so it refreshes on its own\n # (sparser) interval. Researcher-authored idea.concepts still fold immediately at node_created — this\n # only paces the classifier-evidence + consolidation refresh (and the concept-coverage pivot snapshot).\n concept_retag_every: int = Field(default=30, ge=1)\n # === Confirmation & holdout ===========================================================\n # Multi-seed confirmation (I12, ADR-15): confirm the top-k under N seeds before\n # finishing. 0 disables (default). Only meaningful when eval has variance.\n confirm_top_k: int = 0\n confirm_seeds: int = 0\n # D1 seed-holdout discipline: the FIRST seed the confirm phase uses. Search evals run with the\n # implicit seed 0 (LOOPLAB_EVAL_SEED unset -> \"0\"), so a base of 1 keeps every confirm split\n # DISJOINT from anything the search optimized against — the confirm metric is then a\n # generalization signal, not a re-measurement (AIRA: selecting on a signal the search saw\n # overfits by 9-13 pp). Set 0 to restore the legacy overlapping seeds 0..N-1.\n confirm_seed_base: int = Field(default=1, ge=0)\n # D1 holdout-gated promotion (B6, Arbor-style): for host-graded tasks, reserve this fraction of\n # the held-out labels as a FINAL holdout partition the search never sees — every search/confirm\n # eval is scored on the remaining rows only, and at finish the val-top-k are re-scored on the\n # holdout partition (free: the predictions already exist). 0.0 = off (legacy full-label scoring).\n holdout_fraction: float = Field(default=0.25, ge=0.0, le=0.9)\n # Champion selection prefers the HOLDOUT metric among the nodes that have one (the val-top-k).\n # Recorded in run_started so replay applies the same rule; old logs fold to False (legacy pick).\n # The per-node val-holdout `generalization_gap` folds into the Trust panel either way.\n holdout_select: bool = True\n # How many val-leaders get a holdout evaluation at finish (host-graded tasks).\n holdout_top_k: int = Field(default=3, ge=1)\n # R1-c / Part IV — calibrated §12-verifier tie-break in best-selection. When a fresh node's robust\n # metric EXACTLY TIES an already-eligible node, the engine runs the §12 verifier (grounded on each\n # node's realized result, `selection_criteria`) and records a soundness score per node. The tie-break\n # then applies across the WHOLE selection path: the mean pick breaks a robust_metric tie by the score,\n # and — when `holdout_select` is on — the holdout-slot ranking prefers the sounder node so it isn't\n # denied a holdout eval, and the final holdout override breaks a holdout_metric tie by it too. Strictly\n # a TIE-BREAK — it can NEVER move a node ahead of a strictly-better metric (§21.7 advisory-never-\n # overrides). Best-effort: verification is LAZY (only fires on an actual tie) and bounded per cadence,\n # so a large/late tie stays on deterministic metric+id order unless one atomic treatment scores every\n # member. Recorded in run_started so\n # replay applies the same rule; old logs fold to False (legacy pick). Opt-in (default off); needs a\n # reachable LLM client. Enable only after validating calibration offline with `verifier.calibrate`.\n # See trust/verifier.py + core/fitness.py.\n select_verifier: bool = False\n # R1-d (§21.19): widen the verifier tie-break from EXACT-metric to a conservative STATISTICAL tie.\n # The band is capped by both the metric leader's SE and the pooled SE-of-difference, so challenger\n # variance cannot widen it and a significant difference is never a tie (§21.7). Off -> exact only.\n verifier_ci_tie: bool = False\n # Verifier sample count for `select_verifier` (the §12 repeated-sampling expectation). 3 tames the\n # single-shot variance; 1 = single-shot (cheaper, noisier).\n select_verifier_samples: int = Field(default=3, ge=1, le=32)\n # Budget (I13): hard wall-clock ceiling; the run aborts cleanly when exceeded.\n max_seconds: float | None = None\n # Eval-compute budget (#2): hard ceiling on cumulative time spent INSIDE evals (training\n # runs), separate from wall-clock. Survives resume (summed from the event log). The real\n # guard against a silent multi-hour sweep when an eval is a minutes-hours training run.\n max_eval_seconds: float | None = None\n # Cross-run memory (I19, ADR-10): if set, the best result of each run is stored as\n # a case here, and the cases become retrievable knowledge for future runs.\n memory_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"memory\"))\n # HITL (I21, ADR-11): pause for human approval of the final best before finishing.\n require_approval: bool = False\n # Diversity archive (I22): niche bucket width in parameter space.\n archive_resolution: float = 1.0\n # Coverage read-model (narrowing signal): at the strategist cadence, compute a breadth summary\n # (themes / param-niches / theme entropy / dominant-theme fraction) from the folded run, record\n # it as a `coverage_snapshot` audit event, and surface it into the Strategist's decision context.\n # Deterministic, cheap, and purely additive context — no search-behavior change on its own. On by\n # default (like the rest of the always-on situational context). See search/coverage.py.\n coverage_context: bool = True\n # PART IV Phase 2a live steering (§21.11/§21.13). When on, the `concept_retag_every` cadence (NOT\n # `strategist_every` — the producer gates on `_should_consult_concepts`, which uses the seed boundary\n # + `concept_retag_every`, default 30) records a\n # concept-graph coverage + uncovered-region snapshot (deterministic heuristic tagger over the\n # task-type skeleton) and, on an `explore` stance, the Researcher's novelty hint names the exact\n # uncovered regions (\"0 coverage in {negatives/external-mining, distillation} — go there\") instead\n # of the vague \"broaden\". ON by default in the product Settings (ce4a379), EngineOptions off: it\n # only enriches an already-mode-gated explore hint, never forces exploration, and no-ops for a task\n # with no curated concept skeleton. The snapshot is auditable, but its prompt cue can change future\n # candidate generation; it does not directly rewrite best-metric selection. See search/concept_graph.py.\n # DESIGN NOTE (2026-07-17 critique): now ON unconditionally, but a toy / non-ML task yields EMPTY concept\n # tags while still paying the per-node LLM concept cost (~10 calls/node with the full Part IV/V bundle).\n # Consider AUTO-QUIESCING: if the heuristic tagger returns empty for the first N nodes (no concept signal),\n # skip the LLM concept work for the run — keeps it on for ML tasks, free on toy/degenerate ones.\n concept_pivot: bool = True\n # PART IV Phase 2b — D3 graded novelty into the LIVE novelty gate (§21.4/§21.13). When on and the\n # task has a curated concept skeleton, the novelty gate first grades a proposal over the concept\n # graph: a level-4 \"same direction, DIFFERENT implementation\" or a level-5 \"re-opens a\n # wrongly-abandoned FAILED direction\" proposal is ALLOWED through unchanged — the flat LLM/semantic\n # dedup gate can't tell \"this DCL tweak\" from \"the whole DCL branch\" and would wrongly reject a\n # legitimate variant or never re-open a sound-but-killed direction (the node_63 archetype). Levels\n # 1/2/3 (identical / near-dup / prior-run) still fall through to the existing gate. Deterministic\n # (heuristic tagger, no LLM), audit event `novelty_graded`, replay-safe. ON by default in the product\n # Settings (ce4a379), EngineOptions off; no-ops for a task with no skeleton. See search/graded_novelty.py.\n graded_novelty: bool = True\n # PART IV Phase 2b — D7 capability-expansion forced-jump DIRECTIVE (§21.8/§21.13, issue #7). When on\n # and the concept-graph cadence detects action-space LOCK-IN (the search has stayed inside one D5\n # branch for a long consecutive streak) on an `explore` stance, the Researcher's novelty hint\n # ESCALATES from \"broaden\" to \"expand the ACTION SPACE — build the missing infra (external mining,\n # a new data pipeline, a different eval), do NOT swap another {locked} lever.\" The same\n # gate also stamps the proposal's authoritative operator as `expand`; that node competes normally\n # under SearchFitness and its separate operator yield is measured. It does not force a winner.\n # Reads the 2a concept-coverage snapshot, so it needs `concept_pivot` to record one. OPT-IN\n # (default off). See engine/proposal_cues.py + search/lock_in.py.\n capability_expansion: bool = False\n # PART IV cross-run Step 0 (§21.20.12/§21.20.13). Universal task-fingerprint tokenization: drop the\n # ASCII-only `[a-z0-9]` allowlist on goal keywords so a non-Latin goal (Russian, CJK, …) is NOT\n # silently dropped from its cross-run fingerprint (today such a goal keys only on kind/dir/metric and\n # never reaches SIMILAR-task priors/lessons/cases). Uses `[^\\W_]+`/`.casefold()` — same splitting as\n # before, any script. ON by default in the product Settings (ce4a379); the bare-library EngineOptions\n # default stays off, so a run pinned to it won't silently re-key a portfolio mid-flight. See engine/memory.py.\n fingerprint_universal: bool = True\n # PART IV cross-run Step 2 (§21.20). Fill graded-novelty's `prior_concepts` from the cross-run\n # ConceptCapsuleStore: at run end write a per-run concept capsule (the shipped `node_concepts` tags +\n # best outcome, keyed by task_fingerprint) to `memory_dir`; when a later SIMILAR run proposes an idea\n # whose concept was tried before, SURFACE the prior outcome as a `cross_run_prior` audit event (never\n # reject — D3 level 3). Audit-only, off the selection path; ON by default in the product Settings\n # (ce4a379), EngineOptions off. Needs a `memory_dir` to share capsules.\n # See engine/memory.py (ConceptCapsuleStore) + engine/novelty.py.\n # PREREQUISITES (this flag is NOT standalone): the WRITE needs per-run concept tags\n # (`node_concepts`, produced by `concept_pivot`/F1 tagging) — no tags => no capsule; the SURFACE path\n # runs inside the graded-novelty precheck, so it also needs `graded_novelty` on. With only this flag +\n # `memory_dir` it persists/surfaces nothing. Enable `concept_pivot` + `graded_novelty` alongside it.\n # A non-Latin (Russian/CJK) portfolio should ALSO set `fingerprint_universal`, else the capsule's goal\n # tokens are dropped and its fingerprint over-matches on kind/dir/metric alone (the novelty direction\n # gate narrows this, but goal-word similarity is still lost).\n cross_run_concepts: bool = True\n # PART V (B): run-base + node-DELTA concept authoring. When on, once the first evaluated node has\n # authored concepts the engine seeds RunState.run_base_concepts from them (a one-shot EV_RUN_CONCEPTS,\n # idempotent because it only fires while the base is empty), and the Researcher's proposal hint tells it\n # to author only `concepts_added`/`concepts_removed` vs the run base + its parent — minimal per-node\n # annotations that inherit down the DAG (a swap propagates). Off -> every node authors its full concept\n # set as before. Product-on; the bare EngineOptions mirror defaults off (test_options_divergence).\n concept_run_base: bool = True\n # PART IV cross-run Step 5 advisory (§21.20.5). Fold the bounded cross-run CONTEXT PACK — evidence-\n # grounded claims with BOTH support and counter-evidence (Step 4) + a portfolio-coverage line (Step 3) —\n # into the Researcher's proposal prompt, exactly like the E4 cross-run prior note. Advisory ONLY: it is\n # prompt-grounding: it can change the Researcher's later candidates, but does not directly rewrite\n # best-metric selection (§21.7). Reads `memory_dir` (lessons.jsonl + concept_capsules.jsonl); \"\" when\n # empty. ON by explicit experimental product choice; no frozen A/B validation is claimed. The\n # bare-library EngineOptions default stays off (engine/options.py, frozen in\n # test_options_divergence). See engine/proposal_cues.py.\n cross_run_advisory: bool = True\n # PART IV cross-run §21.20.13 (full CR of the lean fuzzy claim merge). Switch the claim read-model to the\n # SCOPE+POLARITY-safe STRUCTURED claim key (engine/claim_key.py): claims from different tasks never\n # merge, opposite polarity (\"X helps\" vs \"X never helps\") is surfaced as a CONTRADICTION instead of being\n # collapsed, paraphrase/inflection variants group by exact structured key (no transitive over-merge), and\n # operator governance is scope-precise (a decision in task A cannot reach a same-worded claim in task B).\n # Affects the `cross_run_advisory` context pack only; ON by default in the product Settings (ce4a379);\n # the bare-library EngineOptions default stays off (engine/options.py). See engine/claims.py.\n cross_run_structured_claims: bool = True\n # PART IV cross-run §22.4 (AGENTIC taxonomy steward). At finalize, when an LLM client is available, let\n # the concept steward (engine/concept_steward.py) review the freshly-updated portfolio concept graph and\n # PROPOSE a curation — merge duplicate slugs / split conflated ones / purge noise. Proposals are LOGGED to\n # `concept_curation_log.jsonl` for operator ratification (via the serve/CLI governance surface); the LLM\n # never mutates fold state. The calls run synchronously during finalize, so they add latency and may add\n # paid inference; a caught steward failure does not prevent terminal completion or trigger a retry.\n # ON by explicit experimental product choice when `memory_dir` + an LLM backend are present; the\n # bare-library EngineOptions default stays off (engine/options.py). See engine/lessons.py.\n cross_run_curation: bool = True\n # Deprecated compatibility flag. Steward output is untrusted and finalize is proposal-only regardless\n # of this value; retained so old snapshots still validate and logs can disclose that auto was requested.\n cross_run_curation_auto: bool = False\n # Role backend (ADR-7/14): \"toy\" (offline optimizer) | \"llm\" (live model). Validated in\n # `_check_trust_gate` alongside the other enum-ish strings — every consumer tests `== \"llm\"`\n # exactly (cli/__init__.py, adapters/tasks.py), so an untyped `--set`/file/env typo (or a mis-cased\n # \"LLM\") used to fall through to the OFFLINE toy roles and quietly produce a run that never called\n # the model. Left as `str`, not `Literal`, so old snapshots still validate through the same path.\n backend: str = \"toy\"\n # Developer backend (ADR-7): \"default\" (templated/LLM from the task) or an external\n # CLI coding agent: \"opencode\" | \"aider\" | \"goose\" | \"continue\".\n developer_backend: str = \"default\"\n # C2 best-of-N: generate N candidate implementations per node and keep the best by an\n # execution-free reward (static validity + metric-print). 1 = off. In-house LLM developer only\n # (not external agents — ADR-7 cost rule). The top SWE-bench reliability lever for weak models.\n best_of_n: int = 1\n # D10 list-wise best-of-N selection: when the top static-scorers TIE, break the tie with a\n # comparative LLM selection over the candidates presented together (+~3 pts vs pointwise on\n # GAIA, arXiv:2506.12928). The static score stays the primary filter — the LLM is a weak\n # comparative prior, never the sole oracle. ON by default (no-op when best_of_n==1).\n best_of_n_listwise: bool = True\n # === Foresight / predict-before-execute ===============================================\n # FOREAGENT predict-before-execute (arXiv:2601.05930): use the LLM as an IMPLICIT WORLD MODEL to\n # predict — WITHOUT executing — which candidate / hypothesis will score best, primed with a\n # Verified Data Analysis Report (data profile / brief) + the accumulated experiment memory. Master\n # switch, ON by default; a no-op wherever there is nothing to rank (best_of_n==1 and\n # foresight_panel==1) or no LLM client. See `looplab/search/foresight.py`.\n foresight: bool = True\n # Hypothesis foresight breadth: generate K candidate ideas per proposal and keep the one PREDICTED\n # best pre-execution by the world model — it ranks the STRUCTURAL / text ideas the numeric k-NN\n # surrogate (researcher_panel) is blind to, primed with the data profile + memory (the synergy).\n # >1 enables it (LLM backend only; needs `foresight` on); 2 = on by default at modest cost, 1 = off.\n foresight_panel: int = 2\n # AGENTIC foresight: run the ranking (hypothesis-board prioritization + K-idea pick) as a TOOL-USING\n # loop that can pull actual experiment results / data facts before deciding, instead of a one-shot\n # prediction from a pre-baked report. ON by default (needs foresight + a client; falls back to the\n # one-shot predictor on any hiccup). A few extra LLM calls per proposal; set False for the cheaper\n # single-call ranker.\n foresight_agentic: bool = True\n # §1 foresight confidence gate: the minimum predicted confidence at which a predict-before-execute\n # pick is ACTED ON. Below it the ranker abstains — the K-idea panel falls back to the first\n # proposal, best-of-N falls through to the D10 tie-break — instead of committing a low-confidence\n # choice. 0.0 (default) = OFF (act on every pick, the historical behavior); raise toward ~0.5 to\n # make the world model defer when it isn't sure. Pairs with the foresight track record (§1) the\n # predictor is now primed with. Range 0.0-1.0.\n # CLAUDE REVIEW: [EDGE-CASE] the field comment says \"Range 0.0-1.0\" but unlike the sibling\n # confidence/fraction knobs (train_monitor_kill_confidence, asha_live_quantile, ...) there is no\n # Field(ge=0.0, le=1.0) bound, so e.g. 5.0 validates silently — a gate that can never pass, so\n # every foresight pick permanently abstains with nothing in the snapshot to show why.\n foresight_min_confidence: float = 0.0\n # PART IV Phase 2c — replace the world model's SELF-REPORTED confidence (measured Pearson≈0 with the\n # realized outcome, §21.12) with a CALIBRATED §12-verifier score. When on and a client is available,\n # after the K-idea ranker picks the predicted-best candidate the §12 verifier scores it (grounded +\n # repeated + criteria-decomposed: `foresight_criteria` — likely to improve the objective, and\n # sound/feasible) and THAT calibrated score becomes the confidence the `foresight_min_confidence` gate\n # and the telemetry use. Degrades to the self-reported confidence without a client or on any verifier\n # error (the telemetry records which source was used). ON by default in the product Settings (ce4a379),\n # EngineOptions off; a few extra LLM calls per acted-on proposal. See looplab/trust/verifier.py + search/foresight.py.\n # REVISIT (cost/quality, not yet resolved): a live classification run measured ~10 LLM calls & ~3.8 min/node\n # on deepseek-v4-flash with the full Part IV/V default bundle; the calibrated verifier is part of that per-node\n # tax. Kept ON deliberately — it raises decision quality (self-reported foresight confidence has ≈0 Pearson\n # with realized outcome), and lowering quality to save calls is the wrong trade. TODO: run a frozen quality/cost\n # A/B (verifier-on vs -off) to quantify the recall@objective gain per extra call before considering a default flip.\n foresight_verify: bool = True\n # Verifier sample count for `foresight_verify` (the §12 repeated-sampling expectation on a no-logprob\n # backend). 3 tames the single-shot variance §21.12 measured; 1 = single-shot (cheaper, noisier).\n # RESUME-critical: this value is persisted in config.snapshot.json, so a hard `le` schema bound would\n # make an old run started with a larger LOOPLAB_FORESIGHT_VERIFY_SAMPLES un-resumable (Settings(**snapshot)\n # would raise on `looplab resume` / finalize-recovery / serve replay). CLAMP into [1, MAX] via a validator\n # instead of rejecting — the runtime cap is preserved without breaking the snapshots/env-compat rule.\n foresight_verify_samples: int = 3\n\n @field_validator(\"foresight_verify_samples\")\n @classmethod\n def _clamp_foresight_verify_samples(cls, v: int) -> int:\n # Coerce a persisted/env value into [1, MAX] rather than reject (see field note above).\n try:\n v = int(v)\n except (TypeError, ValueError):\n return 3\n return max(1, min(v, MAX_FORESIGHT_VERIFY_SAMPLES))\n\n # T7 LLM response cache: serve an identical DETERMINISTIC (temperature 0) request from an\n # in-process content-addressed cache instead of re-hitting the model — cuts cost on\n # retry/panel/verify flows. NEVER caches sampling calls (temp>0: best-of-N / panel / novelty\n # re-propose must vary). Off by default (most role calls run at temperature>0 anyway).\n llm_cache: bool = False\n agent_cmd: str | None = None # override the agent's launcher (path / wrapper)\n # External-agent validation (ADR-7): wrap a CLI-agent Developer with a validator that\n # audits each output (no-op/syntax/crash/timeout), retries with feedback, and falls\n # back to the in-house LLM Developer. Off -> use the raw agent output unchecked.\n validate_agent: bool = True\n agent_max_retries: int = 1 # re-prompts of the agent on an invalid result\n # Patch-gated multi-file agent (ADR-7 Rule 3): run the CLI agent in a git worktree and\n # accept only edits whose paths match `agent_surface` (reject-not-strip out-of-surface\n # touches). Lets the agent create helper modules, not just solution.py. Degrades to\n # whole-file readback if git is unavailable.\n agent_patch_gate: bool = True\n agent_surface: list[str] = [\"*.py\"] # edit-surface allow-list (globs)\n # Trust policy for an agent-authored eval/metric adapter (RepoTask onboarding, Phase 3):\n # \"ratify_freeze\" waits for human approval, \"autonomous\" accepts the proposed spec without that\n # gate, and \"ratify_freeze_drift\" also enforces the configured independent drift cross-check.\n eval_trust_mode: str = \"ratify_freeze\"\n # RepoTask node seeding policy (run-wide fallback; a task/editable `seed_mode` overrides):\n # \"auto\" (default) copies git-tracked files when the editable is a git repo (so a tree bloated\n # with untracked artifacts isn't deep-copied per node), else a full copy; \"tracked\" forces\n # code-only; \"all\" forces a full recursive copy (legacy). Genesis authors per-task from the\n # user's words; this is the global default when unspecified.\n seed_mode: str = \"auto\"\n llm_model: str = \"qwen3:8b\"\n # === LLM / transport ==================================================================\n llm_base_url: str = \"http://localhost:11434/v1\" # Ollama OpenAI-compatible endpoint\n llm_temperature: float = 0.6\n # H3 per-role model presets (5090 recipe): optionally run the Researcher and Developer on\n # DIFFERENT models/endpoints (e.g. Developer=Qwen3-Coder-30B for code, a fast model for breadth).\n # Blank = use the shared llm_model/llm_base_url. Generalizes the role-backend swap to per-role.\n researcher_model: str | None = None\n developer_model: str | None = None\n researcher_base_url: str | None = None\n developer_base_url: str | None = None\n # Per-role sampling temperature (§4.1): the Researcher wants breadth (higher temp = more diverse\n # ideas), the Developer wants determinism (lower temp = fewer syntax slips), the Strategist a\n # steady hand. Each overrides the shared `llm_temperature` for that role only; None = use the\n # shared value (byte-identical to before). Flat + nullable, mirroring the per-role model fields.\n researcher_temperature: float | None = None\n developer_temperature: float | None = None\n strategist_temperature: float | None = None\n # The Strategist had per-role TEMPERATURE but no model/endpoint of its own, so in split mode it\n # always ran on the shared llm_model however the other roles were pointed. These close that gap\n # with the same names/shape as the pair above; blank = its profile, else the shared value.\n strategist_model: str | None = None\n strategist_base_url: str | None = None\n # === Connection profiles (multi-provider) =============================================\n # A \"profile\" is a NAMED connection: {model, base_url, temperature, api_key_env, provider}. Roles\n # point at one by name through `role_profiles`; `llm_profile` is the default for every role that\n # doesn't. All three are EMPTY by default and the resolver short-circuits to the shared\n # llm_model/llm_base_url/llm_api_key path when they are — an operator running one model never\n # needs to know the word \"profile\".\n #\n # A profile stores `api_key_env`, the NAME of an environment variable, never the key itself. That\n # is ADR-11's \"a secret is a reference, not a value\" turned from a convention into a data type:\n # the whole profile map is then safe to write into config.snapshot.json, hand back over HTTP and\n # render into LOOPLAB_* — there is nothing in it to mask. It is also what makes per-role\n # CREDENTIALS expressible at all: two roles on one provider can carry different keys, which a\n # key-per-endpoint map could not say.\n llm_profiles: dict[str, dict] = Field(default_factory=dict)\n llm_profile: str | None = None\n role_profiles: dict[str, str] = Field(default_factory=dict)\n # Unified self-driving agent: one LLM identity that plays Researcher + Developer (+ Strategist)\n # across pipeline stages, choosing its own model/toolset per stage. ON by default — the agent\n # drives the loop (incl. crash triage: repair/abandon/reject_idea) and, via\n # `agent_drives_actions`, picks the next macro action within a pure legal-action gate. Set both\n # to False for the legacy byte-identical split-role behavior. (No-op unless backend=\"llm\".)\n unified_agent: bool = True\n agent_drives_actions: bool = True\n # Layer 3 Card queue ownership. False preserves the existing policy/unified-pilot action path.\n # True is pinned in run_started because changing the selector on resume would mix two search\n # treatments in one run. When both this and agent_drives_actions are true, Card selection wins.\n card_driven_selection: bool = False\n # Layer 5 bounded speculative Card buffer. Zero is a hard OFF switch and preserves the\n # historical alternating build/eval spine; positive values are pinned by run_started so a\n # resume cannot silently mix search treatments after a snapshot/default edit.\n speculation_depth: int = Field(default=0, ge=0, le=64)\n # Local evidence receipt produced by ``looplab speculation-gate``. This is deliberately absent\n # from the curated Settings UI: positive-depth execution is a calibrated rollout decision, not a\n # casual tuning knob. The Engine validates the receipt against the current scorer,\n # implementation, GPU inventory and raw paired-run evidence before it enables speculation.\n speculation_gate_receipt: str | None = Field(default=None, max_length=4096)\n # Per-stage model/endpoint overrides for the unified agent. Recognized keys (see\n # tasks.build_unified_agent): `propose` (researcher), `implement`/`repair` (developer),\n # `strategy`, `pilot`. Unlisted keys are ignored. Empty = the per-role researcher_*/developer_*\n # models, then the shared llm_model. Explicit map wins. No-op unless `unified_agent` is true.\n agent_stage_models: dict = {}\n agent_stage_base_urls: dict = {}\n llm_parser: str = \"tool_call\"\n # Reasoning/thinking toggle sent IN the request (providers differ — see `llm.reasoning_body`):\n # Qwen3 on vLLM/SGLang -> chat_template_kwargs.enable_thinking (bool)\n # OpenAI/Ollama-v1/DeepSeek -> reasoning_effort (low|medium|high|none)\n # `llm_reasoning`: \"\" = send nothing (server default — current behavior); off = actively disable;\n # on = enable at default depth; low|medium|high = enable at that effort.\n # Defaults to \"high\" so the agent reasons before proposing/repairing/triaging (better code, fewer\n # crashes); set \"\" to send nothing (server default) or \"off\" to disable. The model's chain-of-thought\n # is CAPTURED either way (reasoning_content field or inline <think>). NOTE: to get a SEPARATE\n # reasoning_content field from SGLang/vLLM, the server also needs `--reasoning-parser qwen3`.\n llm_reasoning: str = \"high\"\n llm_reasoning_style: str = \"auto\" # auto | qwen | effort | none — how to shape the request param\n llm_reasoning_extra: dict = {} # raw fields merged into the body (escape hatch, e.g. Anthropic thinking)\n # H1: drive structured calls with the endpoint's constrained decoding (vLLM/SGLang guided_json +\n # response_format json_schema) so weak models can't emit invalid JSON. Off for Ollama (default).\n llm_guided_json: bool = False\n # Stream every LLM request (SSE) and reassemble it, so the request `timeout` is an INTER-TOKEN\n # idle timeout rather than a whole-request deadline: a long-but-alive generation is never cut off\n # (tokens keep resetting the timer), while a genuinely STALLED endpoint (no token for `timeout` s)\n # is detected and retried on a fresh connection — the fix for silent multi-minute hangs. Set False\n # to use one blocking read (old per-op timeout semantics) if an endpoint streams badly.\n llm_stream: bool = True\n # LLM IDLE timeout (seconds). Stream mode: the INTER-TOKEN stall limit — a steady generation is\n # never cut off, only a silent endpoint. Non-stream: bounds the whole read. Raise for endpoints\n # with a long prefill on huge prompts; lower to fail fast on a flaky shared endpoint.\n llm_timeout: float = Field(default=180.0, gt=0)\n # First-byte (response-headers) window for STREAM attempts, seconds: an SSE response sends its\n # headers on admission, so no-headers ≈ a black-holed request — fail over to a fresh connection\n # fast instead of waiting the full idle timeout. Clamped to llm_timeout. Non-stream attempts are\n # NOT bounded by this (their headers arrive only after the whole generation). The default mirrors\n # `DEFAULT_HEADER_TIMEOUT_S` in looplab/core/llm.py is the single source (imported above).\n llm_header_timeout: float = Field(default=DEFAULT_HEADER_TIMEOUT_S, gt=0)\n # Honour HTTP(S)_PROXY / NO_PROXY / SSL_CERT_FILE env vars for LLM calls. Default False: the\n # transport connects DIRECTLY (many internal/OLAP endpoints are reachable only by bypassing an\n # ambient proxy — a picked-up proxy yields \"connection refused\"). Set true when the endpoint is\n # reachable ONLY via a corporate proxy or needs a custom CA bundle from the environment.\n llm_trust_env: bool = False\n # H4/C2: compact the growing agentic tool-call history once it exceeds this many chars (auto_summary\n # LLM-summarizes the stale middle; else middle-truncate). Sized to the model's real context window so\n # files read earlier STAY in context instead of being compacted away and re-read: ~1,000,000 chars ≈\n # 250k tokens (≈4 chars/token), matching the configured deepseek-v4-flash (≥200k tokens confirmed).\n # The old 120k-char (~30k-token) default was ~8× too aggressive and drove a read-thrash loop. 0 = off.\n context_budget_chars: int = 1_000_000\n # M5: the Researcher's always-on experiments digest budget (chars). 0 = AUTO — scales with the\n # run size (60 chars/node, bounded [1200, 6000]) instead of one flat cap for an 8-node toy run\n # and a 200-node benchmark run alike. Set a positive value to pin it.\n digest_char_cap: int = Field(default=0, ge=0)\n # D11 compression model slot (open_deep_research's four-slot pattern): a dedicated CHEAP model\n # for agent-history summarization (C2 auto-summary), so compression doesn't pay the main\n # model's price. Blank = use each loop's own model (legacy). `compressor_base_url` blank =\n # the shared llm_base_url.\n compressor_model: str | None = None\n compressor_base_url: str | None = None\n # === Agentic tool-loop ================================================================\n # Agentic tool-loop limits — apply to EVERY tool-using agent (the LLM Researcher, the unified\n # agent's pilot + crash-triage, Deep-Research, the run-chat Boss, the genesis repo-scout, and the\n # cross-run report synthesizer). The loop lets the model call read-only tools across turns before\n # emitting its structured result. Both default to UNLIMITED so the agent takes as many turns / as\n # long as it needs — it is never cut off mid-reasoning; set a positive value here (or per-run, or\n # in the UI settings) to bound latency/cost. These used to be hardcoded per call site (e.g. the\n # boss at max_turns=3 / 45s), which silently dropped a slow reasoning model to a no-op reply.\n agent_max_turns: int = 0 # max tool turns before the emit is forced (0 = unlimited)\n agent_time_budget_s: float = 0.0 # wall-clock ceiling across the loop's turns (0 = no cap)\n # G · Soft convergence SAFETY NET (not a research cap). Two thresholds on the agent's tool-turn count:\n # at `agent_emit_after` the loop NUDGES the agent once (\"you've investigated enough — emit now\"); at\n # `agent_emit_force` it FORCES the emit from what was gathered. Set GENEROUSLY — a focused researcher\n # may legitimately read many files/experiments before it can propose a grounded idea; these only catch\n # a genuine runaway that keeps issuing DIFFERENT calls forever (which stuck-detection, keyed on\n # repeats, misses). NB: the old 499-read runaway (node 63) was 79% REDUNDANT re-reads caused by a\n # broken read_file — fixing read_file (pagination) is the real cure; this is just insurance. 0 = off.\n agent_emit_after: int = 300\n agent_emit_force: int = 500\n # B1 · No-progress / stuck detection — the safety net that makes \"unlimited turns\" safe. The\n # turn/time ceilings above are only BACKSTOPS; this is what actually stops a runaway loop, on the\n # cheapest signal: when the model repeats the SAME tool call (or ping-pongs between two, or keeps\n # hitting the SAME error) with no progress, the loop forces the final emit and finishes. ON by\n # default; reading DIFFERENT files or running ONE long command never trips it. (OpenHands-style.)\n agent_stuck_detection: bool = True\n agent_stuck_repeat: int = 4 # identical calls in a row that count as \"stuck\" (>=2)\n agent_stuck_alternate: int = 4 # ping-pong cycles between two calls that count as \"stuck\" (>=2)\n # C1 · Self-plan (TodoWrite-style): expose an `update_plan` tool so a long-running agent keeps its\n # OWN working TODO and re-surfaces it every `agent_plan_reinject_every` turns — keeps the goal in\n # view across a long loop so the agent \"writes its own context\" (Claude Code style). ON by default.\n agent_self_plan: bool = True\n agent_plan_reinject_every: int = 5\n # C2 · Auto-summary (ON by default): when the tool-loop history grows long, LLM-summarize the\n # stale middle instead of only middle-truncating it. The trigger is `context_budget_chars` if set,\n # else a built-in high-water mark (context_budget.DEFAULT_SUMMARY_CHARS ≈ 120k chars) so short\n # loops are untouched. Falls back to truncation on any summarizer error; one extra model call per\n # over-budget turn.\n agent_auto_summary: bool = True\n # C4 · Repo-developer plan decomposition. A big feature attempted in ONE developer session made a\n # non-converging reasoning model loop indefinitely — it kept writing + exploring without ever\n # emitting `done` (10k+ LLM calls / hours; agentic stuck-detection can't catch VARIED exploration,\n # only literal repeats). Fix: the developer first proposes an ordered plan of ATOMIC steps; a\n # multi-step plan is executed step-by-step, each in a FRESH bounded session with only that step's\n # scope + the files accumulated so far, syntax-validated per write. Every session stays\n # small-context and convergent. ON by default for repo tasks (a 1-step plan == the old single pass).\n developer_plan_decompose: bool = True\n developer_plan_min_steps: int = 2 # a proposed plan with >= this many steps runs step-by-step\n developer_plan_max_steps: int = 8 # cap on plan length (a runaway planner can't spawn 100 steps)\n # Hard per-session backstop for the repo developer — the ONE write-heavy agent that can run away\n # even with stuck-detection on (varied writes/reads never trip the repeat/ping-pong signal). Bounds\n # the plan phase, EACH step, and the single-session fallback. Unlike the global agent_* limits\n # (0 = unlimited, meant for read-only agents), the developer always gets a FINITE ceiling so a\n # model that never emits `done` fails cleanly with the code it wrote so far, instead of looping.\n developer_session_max_turns: int = 500\n developer_session_time_budget_s: float = 1200.0 # 20 min wall-clock per developer session\n # Phase-handoff summaries. Each LLM phase in a node build (Researcher·propose → Developer·stages →\n # plan → implement) ends with ONE extra LLM call that distills its transcript — the repo structure\n # it mapped, files/data confirmed, decisions made — into a brief injected into the NEXT phase (even\n # across the role boundary). The next phase TRUSTS that instead of re-reading the same files, which\n # cuts the biggest source of tool-call thrash (stages/plan/implement each re-exploring the repo).\n # One summary call per phase in exchange for many fewer read calls downstream. ON by default.\n phase_handoff_summary: bool = True\n # Observability (ADR-17): capture each LLM call's full prompt + completion into the active\n # span (spans.jsonl) so the UI can show exactly what the model read and wrote per node.\n # Diagnostics only — `replay.fold` never reads spans. Default on for local single-user; set\n # LOOPLAB_TRACE_LLM_IO=0 to suppress (e.g. to keep spans.jsonl small or avoid storing prompts).\n trace_llm_io: bool = True\n # Run-introspection tools (ON by default): make the LLM Researcher (and DeepResearcher) a\n # tool-using agent that can read its OWN experiments (list/read/find-analogous/themes/code) and\n # the task data (schema/profile/asset) mid-loop, instead of seeing only best+parent. Advisory —\n # never changes best-selection. Off = the legacy single-shot Researcher (richer digest still added).\n researcher_tools: bool = True\n # Cross-run introspection: give the agentic Researcher / pilot read-only tools to look at\n # SIBLING runs (same task_id, same run-root) — list them, read an experiment / its code, and\n # find analogous configs across runs — so a run can build on what neighbouring runs already\n # learned instead of rediscovering it. Advisory; never changes best-selection. Needs the run's\n # own dir wired through (no-op for unit-built roles). Off = the legacy single-run view only.\n cross_run_tools: bool = True\n # Read-only access to EVERY run on this machine ACROSS ALL TASKS (not just same-task siblings):\n # the Developer/Researcher get list_all_runs + read_run_code + read_run_experiment so they can read\n # the code + result of ANY past experiment anywhere and reuse an approach. Broader than\n # `cross_run_tools` (same-task only); the agent decides when a foreign run is relevant. Advisory;\n # never changes best-selection. Needs the run's own dir wired through (no-op for unit-built roles).\n all_runs_tools: bool = True\n # PART V §22 — read-only CROSS-RUN KNOWLEDGE tool for the reasoning roles (Researcher, Strategist,\n # deep-research, and a Developer-scoped variant). Adds `cross_run_prior_attempts` / `cross_run_claims`\n # / `cross_run_atlas` over the §21.20 read-models (concept overview + claim assessments + atlas), read\n # from `memory_dir` (lessons.jsonl + concept_capsules.jsonl). Read-only is a storage-authority\n # boundary, not a behavioral no-op: retrieved content can steer later proposals, and a tool-using\n # reasoning loop can consume additional model calls. The tools never mutate cross-run truth (facts\n # are engine-written, verdicts operator-ratified, §22.4) or directly select a winner. ON by explicit\n # experimental product choice, EngineOptions off; stores only exist once Part-IV features have run.\n cross_run_read_tools: bool = True\n # Agentic retrieval (ADR-16): if set, the LLM Researcher gets grep/kb_search/read\n # tools over this directory of markdown notes and chooses when to use them.\n knowledge_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"knowledge\"))\n # T4 real embeddings: model id for an OpenAI-compatible `/embeddings` endpoint used for\n # kb_search / case retrieval. Blank (default) = the dependency-free lexical `hash_embed` (today's\n # behavior). Set e.g. \"nomic-embed-text\" (Ollama) or \"text-embedding-3-small\" for SEMANTIC\n # retrieval; a misconfigured/offline endpoint degrades back to hash_embed at call time (never\n # crashes a run). `embed_base_url` blank = reuse `llm_base_url`; the shared `llm_api_key` is used.\n embed_model: str | None = None\n embed_base_url: str | None = None\n # Harmonic memory (Memora, ICML'26 — idea import). When ON, cross-run cases and knowledge notes are\n # indexed by a short ABSTRACTION + cue ANCHORS instead of their raw text, near-duplicate memories are\n # CONSOLIDATED into one entry under a matching abstraction, and `kb_search`/case retrieval EXPAND\n # through the top hits' anchors to surface related-but-not-similar memories. ON by default. Set\n # `memora=false` to restore the pre-Memora raw-text index. Never a source of truth — abstractions\n # live only in the derived, rebuildable retrieval index (never the event log or canonical cases.jsonl).\n memora: bool = True\n # Write abstractions with the wired chat model (richer than the deterministic lexical fallback). ON\n # by default; results are CACHED by content hash (see `memora_cache`) so a re-built index doesn't\n # re-call the model on unchanged notes/cases, and any endpoint failure degrades to lexical at call\n # time — so an offline box just gets lexical abstractions, never a crash. Set false to force lexical.\n memora_llm: bool = True\n # === Cross-run memory / Memora ========================================================\n memora_anchors: int = 6 # max cue anchors kept per memory\n # Cosine similarity at/above which a new case is CONSOLIDATED into an existing one (same evolving\n # topic) rather than stored as a separate entry. Only used when `memora` is on.\n memora_consolidate_threshold: float = 0.86\n # Where the LLM-abstraction cache lives (JSON). Blank (default) derives it from `memory_dir`\n # (`<memory_dir>/memora_cache.json`) or else `knowledge_dir` (`<knowledge_dir>/.memora_cache.json`);\n # with neither set the cache is in-memory only (per process). No-op unless `memora_llm` is on.\n memora_cache: str | None = None\n # E3 literature-grounded ideation (network-OPTIONAL): give the agentic Researcher an arXiv search\n # tool to ground ideas in real techniques. Off by default (egress is unreliable on some boxes);\n # fails gracefully if the network is blocked.\n literature_search: bool = False\n # Deep-Research stage (network-OPTIONAL): give the DeepResearcher a general web search/fetch\n # tool (DuckDuckGo) on top of arXiv to read across results + the web before steering the next\n # batch. Off by default (egress is unreliable on some boxes); fails gracefully when blocked.\n web_search: bool = False\n # Cadence for the Deep-Research stage: run it automatically every N created nodes. Zero disables\n # only the cadence; manual `deep_research` and Strategist `request_research` triggers still fire.\n # Default 3: deep research analyzes the accumulating results and steers the next batch (its\n # directions become hints + open hypotheses); paired with `concurrent_research` it overlaps the\n # think with the GPU-bound eval, so the cadence costs LLM tokens but no wall-clock on the search.\n deep_research_every: int = 3\n # Overlap a DUE deep-research \"think\" with the GPU-bound eval instead of running it in its own\n # serial step (the agent is otherwise idle while a node trains). research() computes from a state\n # snapshot; the joined background task records only allowlisted, order-tolerant\n # research/hint/hypothesis events as soon as the memo finishes. ON by default: the LLM is typically\n # remote (no GPU contention with eval), so overlapping hides latency behind long training.\n concurrent_research: bool = True\n # Concurrent-research REPEAT — don't idle a multi-DAY eval. The overlapped \"think\" fires ONCE per\n # window by default; ON here, it RE-RUNS on an adaptive time cadence for the WHOLE eval window\n # (a two-day training must not leave the reasoning agents idle after one memo). Self-paced: it\n # only records a memo whose CONTENT is new (identical re-runs are skipped, so the log/board don't\n # bloat) and backs off geometrically as the analysis converges, capped so it always re-checks.\n # The appended records never rewrite the current metric champion, but their hints/open hypotheses\n # deliberately steer later proposals and replay reconstructs that advice. The library default is\n # one-shot (== today, byte-identical); this product default turns the repetition on.\n concurrent_research_repeat: bool = True\n # Base cadence (seconds) between repeated concurrent-research passes. Research is EXPENSIVE\n # (multi-turn LLM + web/arXiv), so this is a FLOOR, not a ceiling: the effective cadence is\n # max(this, ~5% of the per-experiment time budget) — a two-day eval is re-researched about hourly,\n # a short eval not at all (the first tick outlasts it). Only matters when the repeat is on.\n concurrent_research_interval_s: float = Field(default=1800.0, gt=0)\n # Per-eval-window backstop on repeated-research LLM calls (0 = cadence-only, no cap). The adaptive\n # cadence + convergence backoff are the primary budget control; this bounds a pathological\n # never-converging window. Past it the loop stops calling the LLM (the health monitor still runs).\n concurrent_research_max_calls: int = Field(default=40, ge=0)\n # Concurrent hypothesis-board CONSOLIDATION during the eval window. Repeated research keeps ADDING\n # near-duplicate directions to the open board (each memo registers up to 5 as open hypotheses), so a\n # long eval's board bloats before the between-nodes merge runs. This dedups/merges it on the SAME\n # background loop so the board stays clean for the next proposal. In Card-driven mode the same merge\n # changes ownership/readiness, so it is deferred to the joined main-task cadence instead. Needs the\n # reflect client + `track_hypotheses`.\n # Only active while `concurrent_research` runs the overlap loop. Library default off (== today).\n concurrent_consolidate: bool = True\n # Cadence for the agent-authored run report: regenerate the conclusion-first narrative every N\n # created nodes (0 = off; it still regenerates on a manual `report_refresh` from the UI). The\n # deterministic report always renders from the node set regardless of this knob.\n report_every: int = 3\n # Agent Skills (I18, ADR-9): dir of SKILL.md the Researcher can list/load as tools.\n skills_dir: str | None = None\n # Prompt store (I18, ADR-8): dir of editable, hot-reloaded role prompt .md files.\n prompt_dir: str | None = None\n # API key as a reference, never serialized as a value (ADR-11). Local servers\n # ignore it; default to a placeholder.\n llm_api_key: SecretStr | None = None\n\n @model_validator(mode=\"before\")\n @classmethod\n def _reject_bool_for_numeric_fields(cls, data):\n \"\"\"A JSON boolean is a TYPE ERROR for a numeric setting, not the number 1.\n\n Pydantic runs in lax mode here (`model_config` sets no `strict`), and `isinstance(True, int)`\n is True — so `{\"max_nodes\": true}` from a UI/API type slip validated cleanly as a budget of\n ONE node, and `{\"eval_parallel\": true}` collapsed the width to one. The run then looked\n configured and simply did almost nothing, with no 422 and nothing in the snapshot to show why.\n Rejecting at the boundary covers every numeric field at once, including the many that carry no\n `Field(ge=...)` bound of their own to trip over.\n \"\"\"\n if not isinstance(data, dict):\n return data\n for key, val in data.items():\n if type(val) is bool and _is_numeric_annotation(\n getattr(cls.model_fields.get(key), \"annotation\", None)):\n raise ValueError(f\"{key} must be a number, not a boolean (got {val!r})\")\n return data\n\n @model_validator(mode=\"before\")\n @classmethod\n def _apply_profile(cls, data):\n \"\"\"Expand `profile` into a bundle of setting defaults BEFORE validation.\n\n pydantic-settings hands this validator the fully-merged input from ALL explicit sources\n (init kwargs + LOOPLAB_* env + .env), with fields the user left unset simply ABSENT (they\n fall to field defaults afterward). So a profile value is applied only when its key is not\n already present — i.e. any explicit file/CLI/env setting wins over the profile, and the\n profile wins over the bare field default. This makes `thorough` a true convenience preset,\n never an override of what the user asked for.\"\"\"\n if not isinstance(data, dict):\n return data\n prof = data.get(\"profile\", \"default\")\n # env may deliver the key as \"profile\" (env_prefix stripped) — normalize to a str\n prof = str(prof).strip().lower() if prof is not None else \"default\"\n overrides = PROFILES.get(prof)\n if overrides is None:\n raise ValueError(f\"unknown profile {prof!r}; known: {sorted(PROFILES)}\")\n for key, val in overrides.items():\n if key not in data: # explicit source (file/CLI/env) always wins\n data[key] = val\n return data\n\n @model_validator(mode=\"after\")\n def _check_trust_gate(self):\n if self.trust_gate not in (\"audit\", \"gate\", \"block\"):\n raise ValueError(\n f\"trust_gate must be audit|gate|block, got {self.trust_gate!r}\")\n if self.merge_mode not in (\"auto\", \"mean\", \"ensemble\"):\n raise ValueError(\n f\"merge_mode must be auto|mean|ensemble, got {self.merge_mode!r}\")\n # novelty_mode drives the dedup/novelty gate; an out-of-set value (e.g. a mis-cased\n # LOOPLAB_NOVELTY_MODE=LLM, or \"on\") used to fall through _apply_novelty_gate silently as a\n # NO-OP — turning the gate off with no diagnostic. Fail loudly at config time like trust_gate/\n # merge_mode do. (Other enum-ish string fields — seed_mode/eval_trust_mode/strategist_backend —\n # share the pattern; left unvalidated here only because their full value sets are less settled.)\n if self.novelty_mode not in (\"off\", \"algo\", \"llm\"):\n raise ValueError(\n f\"novelty_mode must be off|algo|llm, got {self.novelty_mode!r}\")\n # The remaining enum-ish string fields (arch-review §5 P3): a typo used to be accepted at\n # construction and only fail-safe/later-loud downstream (e.g. a mis-cased strategist_backend\n # silently ran the default). Fail loudly here like the fields above.\n if self.strategist_backend not in (\"off\", \"rule\", \"llm\", \"agent\"):\n raise ValueError(\n f\"strategist_backend must be off|rule|llm|agent, got {self.strategist_backend!r}\")\n if self.eval_trust_mode not in (\"ratify_freeze\", \"autonomous\", \"ratify_freeze_drift\"):\n raise ValueError(\n \"eval_trust_mode must be ratify_freeze|autonomous|ratify_freeze_drift, \"\n f\"got {self.eval_trust_mode!r}\")\n if self.seed_mode not in (\"auto\", \"tracked\", \"all\"):\n raise ValueError(f\"seed_mode must be auto|tracked|all, got {self.seed_mode!r}\")\n if self.backend not in (\"toy\", \"llm\"):\n raise ValueError(f\"backend must be toy|llm, got {self.backend!r}\")\n self._check_llm_profiles()\n return self\n\n def _check_llm_profiles(self) -> None:\n \"\"\"Validate the connection-profile maps LOUDLY, at construction.\n\n Assignment is not validated anywhere in this project, and these two maps are the kind of\n thing a typo makes into a silent no-op: an unknown role name would simply never be read, and\n a reference to a missing profile would fall back to the shared model while the operator\n believed a second provider was in play. Both are errors here instead.\n\n The `api_key_env` rules exist for a specific reason. It must LOOK like a secret to\n `runtime/sandbox.py::SECRET_ENV`, because that is the filter that strips the operator's keys\n out of the environment handed to generated candidate code — a key parked in a variable named\n `MY_HANDLE` would be invisible to it and ride straight into the sandbox. This pattern is\n deliberately the STRICTER of the two (the sandbox has to recognize whatever already exists;\n this names a variable the operator is about to create), and it is duplicated below rather than\n imported because layering forbids `core` from importing `runtime`.\n `tests/test_secret_env_pattern.py` pins the two together — including the invariant that every\n name accepted here is one the sandbox strips. And a literal key inside a profile is refused\n outright: the profile map is written verbatim into config.snapshot.json and served over HTTP\n precisely because it is supposed to contain no secrets.\"\"\"\n from looplab.core.llm import LLM_ROLE_KEYS\n if not isinstance(self.llm_profiles, dict):\n raise ValueError(\"llm_profiles must be a map of profile name -> profile\")\n for name, entry in self.llm_profiles.items():\n if not isinstance(entry, dict):\n raise ValueError(f\"llm_profiles[{name!r}] must be an object\")\n # BEFORE the unknown-field check: none of these is a known field, so checking them\n # afterwards could never fire and someone who pasted a live key was told it was a typo.\n for banned in (\"api_key\", \"key\", \"token\", \"secret\"):\n if banned in entry:\n raise ValueError(\n f\"llm_profiles[{name!r}] must not contain {banned!r}: a profile stores \"\n \"api_key_env (the NAME of an environment variable), never a key value — \"\n \"the whole map is written to config.snapshot.json and served over HTTP\")\n unknown = set(entry) - _PROFILE_FIELDS\n if unknown:\n raise ValueError(\n f\"llm_profiles[{name!r}] has unknown field(s) {sorted(unknown)}; \"\n f\"known: {sorted(_PROFILE_FIELDS)}\")\n env = entry.get(\"api_key_env\")\n if env is not None and not _SECRET_ENV_NAME.match(str(env)):\n raise ValueError(\n f\"llm_profiles[{name!r}].api_key_env={env!r} is not a usable variable name: it \"\n \"must be UPPER_SNAKE and contain KEY/SECRET/TOKEN/PASSWORD/PASSWD/CREDENTIAL, so \"\n \"the sandbox's secret-name filter strips it from generated code's environment\")\n temp = entry.get(\"temperature\")\n if temp is not None and (isinstance(temp, bool) or not isinstance(temp, (int, float))):\n raise ValueError(f\"llm_profiles[{name!r}].temperature must be a number\")\n if not isinstance(self.role_profiles, dict):\n raise ValueError(\"role_profiles must be a map of role name -> profile name\")\n for role, profile in self.role_profiles.items():\n if role not in LLM_ROLE_KEYS:\n raise ValueError(\n f\"role_profiles has unknown role {role!r}; known: {sorted(LLM_ROLE_KEYS)}\")\n if profile not in self.llm_profiles:\n raise ValueError(\n f\"role_profiles[{role!r}] names profile {profile!r}, which is not in llm_profiles\")\n if self.llm_profile is not None and self.llm_profile not in self.llm_profiles:\n raise ValueError(\n f\"llm_profile={self.llm_profile!r} is not in llm_profiles\")\n\n def masked_snapshot(self) -> dict:\n # Snapshots are a JSON contract (they are written verbatim to config.snapshot.json), not a\n # Python-object dump. In particular tuple-valued schema fields such as\n # ``inline_repair_reasons`` must become JSON arrays before callers hash the runtime envelope;\n # otherwise the strict speculation-scope canonicalizer sees a different/non-JSON preimage\n # from the one that ``json.dumps`` eventually persists.\n d = self.model_dump(mode=\"json\")\n if self.llm_api_key is not None:\n d[\"llm_api_key\"] = \"***\"\n else:\n d[\"llm_api_key\"] = None\n # The snapshot's own FORMAT version — not a Settings field (it is never settable by env or\n # file, and it describes the document, not the run). `settings_from_snapshot` fails closed on\n # a version it does not understand; see CONFIG_SNAPSHOT_SCHEMA.\n d[CONFIG_SNAPSHOT_SCHEMA_KEY] = CONFIG_SNAPSHOT_SCHEMA\n return d\n\n\n# The config snapshot's document-format version, written by `masked_snapshot` and enforced by\n# `settings_from_snapshot`. Bump it when a snapshot written by this binary would be MISREAD by an\n# older one — i.e. when a new field changes paid, concurrency or selection behaviour and silently\n# defaulting it would resume the same event history under different semantics. `Settings` is\n# `extra=\"ignore\"`, so without this an older binary drops what it does not recognize and resumes\n# anyway, with no diagnostic; a version it does not know is now a loud refusal instead.\n# Version 1 is the first VERSIONED format. An absent key means a pre-versioning snapshot, which stays\n# readable through `LEGACY_CONFIG_SNAPSHOT_DEFAULTS` — that historical contract is unchanged.\nCONFIG_SNAPSHOT_SCHEMA = 1\nCONFIG_SNAPSHOT_SCHEMA_KEY = \"config_snapshot_schema\"\n\n\nclass ConfigSnapshotVersionError(ValueError):\n \"\"\"A config snapshot was written by a NEWER binary than the one reading it.\"\"\"\n\n\n# Pre-versioned snapshots are full Settings dumps, so absence is a reliable per-field version marker.\n# Keep their historical effective behavior when newer product defaults become active: re-entry must not\n# silently add paid calls, interventions, concurrency or a different selection policy to an old run.\nLEGACY_CONFIG_SNAPSHOT_DEFAULTS: dict[str, object] = {\n \"parallel_build\": 1,\n \"eval_parallel\": None,\n \"llm_parallel\": None,\n \"train_monitor\": False,\n \"train_monitor_interval_s\": 600.0,\n \"train_monitor_kill\": False,\n \"train_monitor_kill_confidence\": 0.8,\n \"asha_live\": False,\n \"asha_live_kill\": False,\n \"asha_live_quantile\": 0.5,\n \"asha_live_min_siblings\": 3,\n # Before this operator ceiling existed, the sandbox's 24-hour process limit was authoritative.\n \"max_eval_timeout\": 24 * 3600.0,\n \"watchdog_reflection\": False,\n \"card_driven_selection\": False,\n \"speculation_depth\": 0,\n \"speculation_gate_receipt\": None,\n \"concurrent_research_repeat\": False,\n \"concurrent_research_interval_s\": 1800.0,\n # Zero means unlimited; 40 was the first bounded default, and is latent while repeat is disabled.\n \"concurrent_research_max_calls\": 40,\n \"concurrent_consolidate\": False,\n # The map originally covered only the flags added in the days just before it was written\n # (the entries above are all 2026-07-19..07-21; the map itself landed 07-22). Everything below was\n # added 2026-06-24..07-17 — AFTER `config.snapshot.json` existed (it ships in the initial commit,\n # 2026-06-23) and BEFORE this map did — so a snapshot from that window carries none of these keys\n # and, without an entry here, resumed with today's paid product default ACTIVE. That is precisely\n # the \"silently add paid calls ... to an old run\" the paragraph above forbids: the resumed half of\n # the run would make predict-before-execute and cross-run LLM calls the original never made, and\n # admit candidates under a different novelty policy.\n # `setdefault` makes every one of these a no-op for a snapshot that DOES carry the key, so only\n # genuinely older runs are affected. Each value is the behaviour before the field existed: absent\n # feature = off, absent cadence = 0 (the same \"conservative library\" value the frozen\n # Settings-vs-EngineOptions divergence table records for these fields). Each was dated against the\n # tree before being listed — e.g. `report_every` (2026-06-25) is 0 because the run-report writer\n # itself did not exist before that commit, so an older run wrote no report to preserve.\n \"foresight\": False,\n \"foresight_agentic\": False,\n \"foresight_verify\": False,\n \"concept_pivot\": False,\n \"graded_novelty\": False,\n \"cross_run_concepts\": False,\n \"cross_run_advisory\": False,\n \"cross_run_structured_claims\": False,\n \"cross_run_curation\": False,\n \"cross_run_read_tools\": False,\n \"concept_run_base\": False,\n \"fingerprint_universal\": False,\n \"memora_llm\": False,\n \"comparative_lessons\": False,\n \"lessons_every\": 0,\n \"lessons_refresh_every\": 0,\n \"unified_agent\": False,\n \"deep_research_every\": 0,\n \"report_every\": 0,\n \"failure_reflection\": False,\n \"reflection_priors\": False,\n \"agent_drives_actions\": False,\n \"concurrent_research\": False,\n \"deep_repair\": False,\n # WHAT THIS MAP IS NOT. It is a hand-maintained list of FEATURE switches whose before-the-field\n # value is unambiguous, not a complete partition of `Settings`. Two classes stay out on purpose,\n # because for them a wrong entry is worse than a missing one — it would silently REMOVE behaviour\n # the original run really had:\n # * knobs that are latent while their parent feature is off — `foresight_panel`,\n # `foresight_verify_samples`, `memora_anchors`, `holdout_top_k`, `asha_eta`: the parent above\n # already pins them out of play, and their own before-the-field value is not recoverable;\n # * magnitudes and depths for behaviour that ALREADY existed — `debug_depth`,\n # `context_budget_chars`, `developer_plan_max_steps`, `agent_emit_after`: guessing 0/off there\n # disables working machinery rather than preserving history.\n # A new field belongs here only when it (a) postdates 2026-06-23, (b) defaults to adding paid\n # calls / interventions / concurrency / a different selection policy, and (c) has a historical\n # value you can point at a commit for. When (c) fails, leave it out and say so here.\n}\n\n\ndef migrate_config_snapshot(data: dict) -> dict:\n \"\"\"Return an effective copy of one legacy/current snapshot without rewriting evidence bytes.\"\"\"\n if not isinstance(data, dict):\n raise TypeError(\"config snapshot must be a JSON object\")\n migrated = dict(data)\n for key, value in LEGACY_CONFIG_SNAPSHOT_DEFAULTS.items():\n migrated.setdefault(key, value)\n return migrated\n\n\ndef config_snapshot_schema(data: dict) -> int:\n \"\"\"The snapshot's declared format version. 0 = pre-versioning (the key did not exist yet).\n\n A malformed value is treated as UNREADABLE rather than as 0: a snapshot whose own version marker\n is corrupt cannot be assumed to be the oldest format.\"\"\"\n raw = data.get(CONFIG_SNAPSHOT_SCHEMA_KEY, 0) if isinstance(data, dict) else 0\n if isinstance(raw, bool) or not isinstance(raw, int) or raw < 0:\n raise ConfigSnapshotVersionError(\n f\"config snapshot has a malformed {CONFIG_SNAPSHOT_SCHEMA_KEY}: {raw!r}\")\n return raw\n\n\ndef settings_from_snapshot(data: dict) -> Settings:\n \"\"\"Build re-entry Settings from a masked snapshot under its historical missing-field contract.\n\n Fails closed on a snapshot written by a NEWER binary. `Settings` is `extra=\"ignore\"`, so such a\n snapshot would otherwise load with every unrecognized control silently dropped — the run would\n resume on the same event history under different paid/concurrency/selection semantics, with\n nothing to show why. Refusing is the only honest answer: this binary cannot know what it is\n dropping. Older and pre-versioning snapshots keep loading exactly as before.\"\"\"\n found = config_snapshot_schema(data)\n if found > CONFIG_SNAPSHOT_SCHEMA:\n raise ConfigSnapshotVersionError(\n f\"this run's config.snapshot.json declares format v{found}, but this build understands \"\n f\"at most v{CONFIG_SNAPSHOT_SCHEMA}. It was written by a newer LoopLab; resuming here \"\n \"would silently drop settings this build does not know. Upgrade LoopLab to resume it.\")\n migrated = migrate_config_snapshot(data)\n migrated.pop(\"llm_api_key\", None)\n migrated.pop(CONFIG_SNAPSHOT_SCHEMA_KEY, None) # a document marker, never a Settings field\n return Settings(**migrated)\n"}, "6e2f4400a7439281089267bed269b7d5e970d37e": {"t": 1785768745.0854666, "n": 115918, "h": "0802742c9f477e1c", "c": "\"\"\"Engine config: the schema (what settings exist). (I0, ADR-11)\n\nOne pydantic-settings class declaring every engine knob, its default, and its docs. The loader —\nhow a run file / CLI / env becomes a `Settings` — is `looplab.core.appconfig`, which also owns\nthe config-precedence order. A resolved, secret-masked snapshot is written next to each run for\nreproducibility. (No real secrets in P0, but the masking discipline is in place.)\n\"\"\"\nfrom __future__ import annotations\n\nimport re\nimport types\nimport typing\nfrom pathlib import Path\n\nfrom pydantic import Field, SecretStr, field_validator, model_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n# Single source of the LLM first-byte (response-headers) default — see core/llm.py.\nfrom looplab.core.llm import DEFAULT_HEADER_TIMEOUT_S\n\n# Default home for cross-run memory + the knowledge base (shared across every run). Both are ON by\n# default now (a user asked for it) so a fresh install accumulates + consults knowledge with no setup;\n# set the env var to \"\" to disable, or to a path to relocate. `~/.looplab/` keeps them out of any one\n# project + shared across projects.\n_LL_HOME = Path.home() / \".looplab\"\n\n# What one `llm_profiles` entry may contain. Deliberately closed: an unrecognized field is a typo the\n# operator would otherwise never see fail, and the map is a public artifact (config.snapshot.json,\n# HTTP, LOOPLAB_*), so it must never accrete free-form keys.\n_PROFILE_FIELDS = frozenset({\"model\", \"base_url\", \"temperature\", \"api_key_env\"})\n# A variable name that `runtime/sandbox.py::SECRET_ENV` will recognize as holding a secret and strip\n# from generated code's environment. Duplicated here, not imported: layering forbids `core` from\n# importing `runtime`. `tests/test_secret_env_pattern.py` holds the two in agreement.\n_SECRET_ENV_NAME = re.compile(\n r\"\\A(?=[A-Z][A-Z0-9_]{0,63}\\Z)(?=.*(KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL)).*\\Z\")\n\n\ndef _is_numeric_annotation(ann) -> bool:\n \"\"\"True when a field's annotation admits int/float but NOT bool.\n\n Used by `Settings._reject_bool_for_numeric_fields`: pydantic resolves annotations at class build,\n so `model_fields[...].annotation` is a real type object here even under\n `from __future__ import annotations`. Optionals are numeric when every non-None member is\n (`int | None` yes, `int | str` no), so a boolean can never slip in through a union arm.\"\"\"\n if ann is bool:\n return False # a bool field legitimately takes a bool\n if ann is int or ann is float:\n return True\n args = [a for a in typing.get_args(ann) if a is not type(None)]\n origin = typing.get_origin(ann)\n if args and origin in (typing.Union, types.UnionType):\n return all(_is_numeric_annotation(a) for a in args)\n return False\n\n# Autonomous roles that may be granted per-setting write access (see Settings.agent_control).\n# strategist = A7 meta-controller (run-wide); boss = run-chat operator-proxy (run-wide);\n# researcher = per-experiment proposer (per-node sizing, e.g. eval timeout for a heavy model).\nAGENT_ROLES = (\"strategist\", \"boss\", \"researcher\")\n\n# A foresight score is computed for every acted-on proposal. Keep this lower than the generic\n# verifier's emergency ceiling so a typo cannot multiply paid synchronous calls across a run.\nMAX_FORESIGHT_VERIFY_SAMPLES = 8\n\n\n# Layer-2 parallelism renamed two public settings without removing the durable legacy fields. Keep\n# the equivalence in one place: config-source precedence, governance migration, and live strategy\n# application must all agree which names describe the same authority/value.\nPARALLELISM_ALIASES: dict[str, str] = {\n \"max_parallel\": \"eval_parallel\",\n \"parallel_build\": \"llm_parallel\",\n}\n\n\ndef parallelism_aliases(setting: str) -> tuple[str, ...]:\n \"\"\"Return every spelling in ``setting``'s alias family, canonical name first.\"\"\"\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if setting in (legacy, canonical):\n return canonical, legacy\n return (setting,)\n\n\ndef canonicalize_parallelism_source(\n values: dict, *, promote_build_to_llm_parallel: bool = False,\n) -> dict:\n \"\"\"Promote legacy parallel keys *within one config-precedence source*.\n\n Applying this only after file/CLI/env were flattened would let a lower-priority canonical env\n value shadow a higher-priority legacy file value (or a file canonical value shadow a CLI legacy\n override), so callers with sub-layers normalize each layer independently.\n\n ``max_parallel``/``eval_parallel`` are true aliases (pure eval width, no extra semantics) and are\n always promoted. ``parallel_build``/``llm_parallel`` are NOT: a positive ``llm_parallel`` also flips\n on the finite shared LLM broker (``orchestrator._startup_llm_total``), while legacy ``parallel_build``\n means \"legacy build width, unbounded overlap, no shared total\". So promoting a legacy-only\n ``parallel_build`` layer (e.g. just ``LOOPLAB_PARALLEL_BUILD=1`` in env) would silently serialize\n every provider call — contradicting llm_broker.py's docstring, orchestrator's \"legacy-only preserves\n unbounded overlap\" note, the broker-neutral live-control path, and the configuration.md ``llm_parallel``\n row (\"falls back ... without enabling a shared total\"). Config/startup loads therefore keep broker\n opt-in tied to an EXPLICITLY-spelled canonical ``llm_parallel``; the orchestrator's own width fallback\n (``llm_parallel if not None else parallel_build``) still carries the legacy build width. Only the\n Strategist live-control path (``canonicalize_strategy_parallelism``) opts into\n ``promote_build_to_llm_parallel``, where ``parallel_build`` is a deliberate full alias of the\n canonical knob (see strategy.py ``_apply_strategy``).\n \"\"\"\n if not isinstance(values, dict):\n return values\n out = dict(values)\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if canonical == \"llm_parallel\" and not promote_build_to_llm_parallel:\n continue\n # None is the durable legacy mode for llm_parallel, not a missing value. A normal snapshot\n # contains llm_parallel:null plus parallel_build:1; promoting null to 1 would unexpectedly enable\n # the global one-call broker. Promote aliases only when the canonical key is absent, and pin\n # snapshot round-trip parity. Canonicalize inside each precedence layer, never across the\n # flattened result, so the alias migration cannot invert CLI > file > env precedence.\n if canonical not in out and out.get(legacy) is not None:\n out[canonical] = out[legacy]\n return out\n\n\ndef flatten_parallelism_layers(layers) -> dict:\n \"\"\"Flatten same-tier precedence layers (LOWEST priority first) and resolve the legacy pair.\n\n PRECEDENCE REPAIR for the non-promoted `parallel_build`/`llm_parallel` pair. Declining to promote\n in `canonicalize_parallelism_source` is right (a legacy-only layer must not flip on the shared\n broker), but it let a LOWER-priority canonical value survive a HIGHER-priority legacy override:\n file `llm_parallel: 2` + CLI `-s parallel_build=8` flattened to BOTH keys, and the orchestrator\n prefers `llm_parallel`, so the operator's explicit CLI width was silently discarded. When a higher\n layer names the legacy width over a lower layer's canonical one, mask with the LEGACY SENTINEL\n (`llm_parallel=None` is the durable \"legacy mode — build width from parallel_build, no shared\n total\"), so the higher layer wins with the semantics it asked for.\n\n The mask lives HERE, not in the per-layer canonicalizer, because only a flatten knows the layer\n ORDER. Stamping it per layer meant any layer naming `parallel_build` emitted `llm_parallel=None`\n even when NO layer in the tier set the canonical key — and since pydantic-settings ranks init\n kwargs above env, that sentinel then silently overrode a `LOOPLAB_LLM_PARALLEL` the operator had\n exported as a deliberate provider rate/cost ceiling. Precedence is per FIELD: a tier that never\n spelled `llm_parallel` has not set it, and it must fall through to env/.env/defaults — which is\n exactly what `build_settings`' docstring promises (\"the merged dict wins only where it actually\n sets a value\").\n \"\"\"\n merged: dict = {}\n legacy_at = canonical_at = -1\n for index, layer in enumerate(layers):\n if not isinstance(layer, dict):\n continue\n merged.update(layer)\n if layer.get(\"parallel_build\") is not None:\n legacy_at = index\n if \"llm_parallel\" in layer:\n canonical_at = index\n # Only when this tier DID set the canonical key, at a strictly lower layer than the legacy one.\n if canonical_at >= 0 and legacy_at > canonical_at:\n merged[\"llm_parallel\"] = None\n return merged\n\n# Settings whose semantics are committed by ``run_started`` and restored from the folded event log\n# on every re-entry. Changing one only in ``config.snapshot.json`` would mix incompatible evidence\n# (a different holdout split) or make the live producer disagree with replay's selection policy.\n# The HTTP config editor and the engine both consume this one contract; keep deliberately mutable\n# controls such as ``trust_gate`` out (that field has its own durable change event).\nRUN_START_PINNED_FIELDS = frozenset({\n \"card_driven_selection\",\n \"speculation_depth\",\n \"holdout_fraction\",\n \"holdout_select\",\n \"select_verifier\",\n \"select_verifier_samples\",\n \"verifier_ci_tie\",\n})\n\n\ndef run_start_pinned_settings(state) -> dict:\n \"\"\"Return the effective Settings names committed by a folded run-start record.\n\n Pre-pin legacy logs have no recorded holdout fraction, so their snapshot remains authoritative\n for both holdout knobs. Verifier fields, however, have always been re-pinned from the legacy-safe\n fold defaults whenever a valid ``run_started`` identity exists.\n \"\"\"\n if not getattr(state, \"run_id\", \"\"):\n return {}\n values = {\n # Layer 3 changes which queue owns macro-action selection. Once a run starts, replay and\n # resume must keep the same owner even if the snapshot or ambient default changes later.\n \"card_driven_selection\": bool(getattr(state, \"card_driven_selection\", False)),\n # Layer 5 overlaps Card production with evaluation only when this pinned treatment is\n # positive. Old logs omit it and therefore retain the exact serial build/eval spine.\n \"speculation_depth\": int(getattr(state, \"speculation_depth\", 0)),\n \"select_verifier\": bool(getattr(state, \"select_verifier_tiebreak\", False)),\n \"select_verifier_samples\": int(getattr(state, \"select_verifier_samples\", 3)),\n \"verifier_ci_tie\": bool(getattr(state, \"verifier_ci_tie\", False)),\n }\n holdout_fraction = getattr(state, \"holdout_fraction\", None)\n if holdout_fraction is not None:\n values.update({\n \"holdout_fraction\": holdout_fraction,\n \"holdout_select\": bool(getattr(state, \"holdout_select\", False)),\n })\n if not values.keys() <= RUN_START_PINNED_FIELDS:\n raise RuntimeError(\"folded run-start pinned settings contract drifted\")\n return values\n\n# --- Run profiles (config-first presets) -------------------------------------------------------\n# A `profile` is a NAMED BUNDLE of setting defaults — nothing more. It only fills fields the user\n# did NOT set explicitly (via file / --set / LOOPLAB_* env / init-kwargs), so any explicit knob\n# always wins (see `_apply_profile`). Most engine intelligence lives behind individual flags. The\n# PART IV/V cross-run + concept-graph machinery (concept_pivot, graded_novelty, cross_run_concepts /\n# _advisory / _structured_claims / _curation / _read_tools, fingerprint_universal, foresight_verify)\n# now ships ON in the product `Settings` default. That is an explicit experimental product choice,\n# not a claim that a frozen A/B or workload-wide cost/quality gate has cleared: the bundle can steer\n# prompts and candidate generation, change graded-novelty admission / pre-execution choices, and add\n# paid LLM work when its client and memory prerequisites are present. The bare-library `EngineOptions`\n# default stays lean (see tests/test_options_divergence.py — product side is the aggressive one) so a\n# toy `Engine(...)` in a test doesn't fire cross-run LLM work unasked. The trust/confirm/ablate QUALITY\n# bundle still ships OFF and is turned on by `thorough`. Every value here is reachable by hand — the\n# profile is a convenience, never a hidden mode.\n# keep this rationale behavioral and evidence-based; do not relabel read-only storage\n# access or proposal-only governance as a no-op for agent behavior, cost, or proposal admission.\n#\n# `thorough` deliberately touches only QUALITY/TRUST machinery, not spend: it does NOT raise\n# max_nodes / eval_parallel (those are cost knobs the user owns). It enables multi-seed confirmation\n# (demote seed-lucky leaders), the reward-hack / leakage / critic monitors AND flips them from\n# advisory to *gating* (`trust_gate=\"gate\"` — a\n# flagged win can no longer be selected as best), ablation-driven refinement, and the prompt cues\n# (complexity / budget / failure-reflection / watchdog-reflection). Agentic novelty and the experimental\n# Part IV/V bundle are already product defaults, not profile changes. Effect is large; marginal cost is a\n# few extra evals.\nPROFILES: dict[str, dict] = {\n \"default\": {}, # explicit no-op alias for \"ship-as-is\" (== omitting profile)\n \"fast\": {}, # today's lean defaults, named for symmetry with `thorough`\n \"thorough\": {\n \"confirm_top_k\": 3,\n \"confirm_seeds\": 3,\n # novelty_gate intentionally NOT enabled here: duplicate-detection is the agentic Researcher's\n # job (it reads past experiments + find_analogous_across_runs and DECIDES), not an algorithmic\n # param-distance / embedding auto-reject that mis-fired (it nudged a good fresh idea onto a\n # twice-dead lineage — live node 61). Search stays as tools; the LLM judges novelty.\n \"operator_bandit\": True, # P4: adaptive operator mix over folded yields (GreedyTree)\n \"reward_hack_detect\": True,\n \"code_leakage_detect\": True,\n \"critic_check\": True,\n \"trust_gate\": \"gate\", # detectors stop gating a flagged node from WINNING (not just audit)\n \"ablate_every\": 3,\n \"complexity_cue\": True,\n \"budget_aware\": True,\n \"failure_reflection\": True,\n \"watchdog_reflection\": True, # feed recent live-watchdog (train-monitor/ASHA) flags to proposals\n \"reflection_priors\": True, # no-op unless memory_dir is set (cross-run priors)\n },\n}\n\n\n# The shipped agent-governance matrix (who may change which setting at runtime). Defined as a module\n# constant so the Engine's direct-construction default matches this shipped default exactly (the\n# EngineOptions \"Engine() == shipped defaults\" invariant) instead of diverging to an all-locked map.\nDEFAULT_AGENT_CONTROL: dict[str, list[str]] = {\n \"timeout\": [\"researcher\", \"strategist\"],\n # Layer-2 canonical names are the presented/default governance surface. `_agent_may` falls back\n # to an old snapshot's legacy grant only when its canonical counterpart is absent, so removing\n # duplicate defaults preserves resume authority while an explicit canonical revocation still wins.\n \"eval_parallel\": [\"strategist\"],\n \"llm_parallel\": [\"strategist\"],\n # Strategy-only pseudo-setting: the closed per-lane allocation inside the canonical LLM total.\n # It is not a launch Settings field; operators pin it through `set_strategy` and the Strategist\n # may reallocate it only while this grant is present.\n \"llm_lane_limits\": [\"strategist\"],\n \"max_nodes\": [\"strategist\", \"boss\"],\n \"max_eval_seconds\": [\"strategist\", \"boss\"],\n \"policy\": [\"strategist\"],\n # The policy NAME and its PARAMS are gated independently in `_apply_strategy`; grant both by default\n # so the Strategist that may switch to (say) MCTS may also apply the `c`/`eta` it decided — else its\n # tuned params are silently dropped and the recorded `active_strategy` diverges from the live engine.\n \"policy_params\": [\"strategist\"],\n \"n_seeds\": [\"strategist\"],\n \"ablate_every\": [\"strategist\"],\n \"merge_mode\": [\"strategist\"],\n \"complexity_cue\": [\"strategist\"],\n \"ablate_code_blocks\": [\"strategist\"],\n \"prefer_sweep\": [\"strategist\"],\n \"novelty_stance\": [\"strategist\"],\n \"developer\": [\"strategist\"],\n \"fidelity\": [\"strategist\", \"researcher\"],\n}\n\n\ndef default_agent_control() -> dict[str, list[str]]:\n \"\"\"A FRESH deep-ish copy of the shipped governance matrix (each role list copied so a caller can\n mutate its own map without touching the module constant). The SINGLE constructor of the default,\n called by both `Settings.agent_control`'s default_factory AND the Engine's direct-construction\n default — so \"Engine() == shipped defaults\" can't drift if the copy depth ever needs to change\n (e.g. nested values requiring copy.deepcopy), instead of two verbatim copy expressions.\"\"\"\n return {k: list(v) for k, v in DEFAULT_AGENT_CONTROL.items()}\n\n\nclass Settings(BaseSettings):\n \"\"\"The engine settings schema (every knob a run accepts).\n\n Timeout family — six distinct knobs, each owned by a different subsystem:\n - `timeout`: per-eval wall-clock budget for ONE experiment's evaluation (engine/eval).\n - `max_eval_timeout`: hard ceiling for a governed per-node Researcher timeout override.\n - `llm_timeout`: LLM request idle timeout — inter-token stall limit in stream mode\n (core.llm OpenAICompatibleClient).\n - `llm_header_timeout`: LLM first-byte (response-headers) window for stream attempts (core.llm).\n - `agent_time_budget_s`: wall-clock ceiling across a tool-using agent loop's turns (agents).\n - `dep_install_timeout`: per-package budget for auto-installing a missing dep (env self-prep).\n\n Config-source precedence is owned by `looplab.core.appconfig` (the loader) — see its module\n docstring for the one canonical order.\n \"\"\"\n # Config precedence: see appconfig.py — the loader owns the full order. The `.env` is read\n # so `looplab run`/`looplab ui` pick up LOOPLAB_LLM_BASE_URL etc. without exporting them by hand\n # (keys MUST carry the LOOPLAB_ prefix, same as the env vars). utf-8-sig tolerates a BOM from\n # Windows editors. The test suite disables this (tests/conftest.py) so a dev's real .env in the\n # repo root can't leak into assertions.\n model_config = SettingsConfigDict(\n env_prefix=\"LOOPLAB_\",\n env_file=\".env\",\n env_file_encoding=\"utf-8-sig\",\n extra=\"ignore\",\n allow_inf_nan=False,\n )\n\n @classmethod\n def settings_customise_sources(\n cls, settings_cls, init_settings, env_settings, dotenv_settings,\n file_secret_settings):\n \"\"\"Preserve source precedence across canonical/legacy parallelism names.\n\n Pydantic ranks init > env > dotenv correctly, but it ranks field names rather than semantic\n alias families. Promoting a legacy value inside each source before the merge makes an old\n explicit snapshot/file value beat a lower-priority canonical env value. ``appconfig`` also\n promotes its file/typed/--set sub-layers before combining them into the init source.\n \"\"\"\n def _with_parallel_aliases(source):\n return lambda: canonicalize_parallelism_source(source())\n\n return (\n _with_parallel_aliases(init_settings),\n _with_parallel_aliases(env_settings),\n _with_parallel_aliases(dotenv_settings),\n _with_parallel_aliases(file_secret_settings),\n )\n\n # Run profile (config-first preset, see PROFILES above). \"default\"/\"fast\" = today's lean\n # defaults; \"thorough\" turns the built quality/trust machinery ON in one word. A profile only\n # fills fields the user did NOT set — every knob it touches stays individually overridable.\n profile: str = \"default\"\n\n # Bounds (review C/⚪): a non-positive node/seed budget or timeout is never valid. Upper bounds are\n # equally load-bearing — the UI /start path writes these straight into the engine's env, and an\n # unbounded `eval_parallel` (or legacy `max_parallel`; e.g. 100000 from a crafted preflight) would\n # fan out that many sandboxes → resource exhaustion. Reject at config time, not mid-run. Ceilings\n # are generous; tests top out near 50.\n # Pydantic's lax mode coerces a JSON boolean into any numeric field (`true` -> 1), so a UI/API type\n # error used to silently collapse a run budget to ONE node instead of returning 422. Rejected for\n # EVERY numeric field by `_reject_bool_for_numeric_fields` below, not just the ones bounded here.\n n_seeds: int = Field(default=3, ge=1, le=1024)\n max_nodes: int = Field(default=8, ge=1, le=1_000_000)\n # legacy evaluation-width input. `eval_parallel` below is canonical and wins when set;\n # keep this field for old env/config/snapshots and direct Engine call sites. A width > 1 admits\n # concurrent evals, whose resource reservations may be CPU-only or span one/more GPUs — there is no\n # one-node/one-GPU promise. `max_parallel=0` is launch-time AUTO (detected GPU count, at least 1).\n max_parallel: int = Field(default=1, ge=0, le=1024)\n # legacy build-width input. `llm_parallel` below is canonical and also controls the\n # shared provider-call total when positive. A width > 1 uses fresh pooled researcher/developer pairs;\n # it says nothing about the later eval's CPU/GPU reservation. Startup `0` derives build fan-out from\n # resolved eval width; a live Strategist/operator zero settles to 1. Without role_factory it clamps to 1.\n parallel_build: int = Field(default=1, ge=0, le=64)\n # Hypothesis-card Kanban re-architecture (docs/23, Layer 2). Two INDEPENDENT concurrency axes,\n # decoupled from each other so cost (LLM work) can be tuned separately from throughput (GPU evals).\n # These are the CANONICAL names going forward; `max_parallel`/`parallel_build` above are kept as thin\n # back-compat aliases (env vars / snapshots / ~100 Engine(...) call sites depend on the exact names,\n # so they cannot be renamed — CLAUDE.md). Resolution (orchestrator.__init__): a NEW name, when set,\n # WINS over its legacy alias; None => fall back to the legacy field => byte-identical to today.\n # `eval_parallel`: concurrent EVALS (the GPU/experiment consumer). None -> max_parallel; 0 = AUTO\n # (one experiment per detected GPU). This is the in-Run eval-concurrency ceiling; the resource\n # scheduler bin-packs declared footprints separately. Sibling local GPU-owning Runs serialize\n # through the conservative host-pool lease; external execution needs its own admission boundary.\n # `llm_parallel`: total concurrent LLM provider calls + BUILD fan-out. None -> legacy\n # parallel_build semantics without a shared total; 0 = startup AUTO (= resolved eval_parallel\n # for build fan-out, historical research overlap unbounded). A positive canonical value enables\n # the named-lane broker the Strategist can reallocate at cadence.\n eval_parallel: int | None = Field(default=None, ge=0, le=1024)\n llm_parallel: int | None = Field(default=None, ge=0, le=64)\n # Training-log monitor (I-series watchdog family): a per-eval background observer that tails the live\n # training log while a (often multi-hour) declared stage runs in a worker thread. ON in the product\n # surface (Settings). Its alert event is fold-ignored and cannot directly change lifecycle/champion;\n # with watchdog_reflection on, that alert is nevertheless advice in a later Researcher\n # prompt and can change future proposals. It only fires on the command-eval path with an LLM client;\n # bare-library EngineOptions stays OFF. See test_options_divergence.\n # `_interval_s` is the BASE tick cadence (the effective one adapts to the per-experiment budget).\n train_monitor: bool = True\n train_monitor_interval_s: float = Field(default=600.0, gt=0)\n # Phase 3 — INTERVENTION (opt-in, separate from observation): let the monitor tree-kill a training the\n # LLM judges 'broken' (diverged / silent CPU fallback / not learning) EARLY, instead of burning the\n # whole budget. Off by default — the observer only WATCHES unless this is on. A kill only fires on a\n # 'broken' verdict (a plateau is 'watch', never killed) with confidence >= the threshold; the node then\n # fails normally (reason='monitor_broken'), so replay reconstructs it from that one terminal event.\n train_monitor_kill: bool = False\n train_monitor_kill_confidence: float = Field(default=0.8, ge=0.0, le=1.0)\n # ASHA live-curve watchdog (sibling of the training monitor): reads the latest INTERMEDIATE value of\n # the objective metric off the live log (reusing the eval's OWN metric reader). Finished-endpoint rank\n # remains advisory. An opt-in KILL additionally requires an operator-declared metric.resource_key and\n # enough sibling observations at exactly that resource; absent comparable evidence cannot stop a run.\n # Records a fold-ignored `asha_rank` diagnostic + trace span; the library default is OFF (off == today).\n asha_live: bool = True\n # Opt-in INTERVENTION (separate from the advisory signal): let the watchdog tree-kill a node whose\n # intermediate metric stays below the SAME-RESOURCE bar past a short grace window. Off by default —\n # it only surfaces endpoint rank unless this is on. A kill fails normally (asha_underperforming).\n asha_live_kill: bool = False\n # The bar sits at `asha_live_quantile` along a WORST->BEST ordering of the finished siblings' finals:\n # 0.5 = the median (a node worse than the median peer flags); a SMALLER value is more conservative —\n # it lowers the bar toward the WORST finished peer, so only a node worse than nearly all peers flags\n # (0.0 = worse than the worst). `min_siblings` is the floor of finished peers required before it ranks\n # at all, so it never acts on too little evidence.\n asha_live_quantile: float = Field(default=0.5, ge=0.0, le=1.0)\n asha_live_min_siblings: int = Field(default=3, ge=1)\n timeout: float = Field(default=30.0, gt=0)\n # Hard ceiling for the Researcher's governed per-node `Idea.eval_timeout`. One hour preserves the\n # existing heavy/neural-net authoring example while staying well below the sandbox's defensive\n # 24-hour subprocess ceiling. This is operator-owned run config: agents may request less or more,\n # but the accepted action is always clamped here after the `agent_control.timeout` permission gate.\n max_eval_timeout: float = Field(default=3600.0, gt=0, le=24 * 3600.0)\n # Intra-node sweep: a sweep node runs a whole grid in one process, so it gets this multiple of\n # `timeout` as its wall-clock budget (solution.py path; RepoTasks use their per-profile timeout).\n sweep_timeout_mult: float = Field(default=8.0, ge=1)\n # Eval STALL watchdog CAP (seconds): a stage that is completely SILENT on stdout/stderr for this\n # long — while still alive and BELOW its wall-clock deadline — is tree-killed early with a STALLED\n # marker (a hung dataloader/deadlock dies in minutes instead of burning a multi-hour timeout). The\n # per-stage window is `min(this, the stage's own timeout)`, so a short scorer's deadline fires first.\n # `0` DISABLES the watchdog entirely (only the hard deadline applies) — for a legitimately quiet\n # non-Python stage (block-buffered stdout, a script that logs only to its own file). Threaded into\n # the eval and surfaced to the Developer so its code can emit periodic progress to stay alive.\n eval_stall_timeout_s: float = Field(default=1800.0, ge=0)\n # Sandbox tier (ADR-13): \"trusted_local\" (subprocess, no Docker) for the CLI;\n # \"untrusted\" (Docker --network none, shared-kernel runtime) for hosted/multi-tenant UI;\n # \"hostile\" (untrusted + a true-isolation OCI runtime, gVisor `runsc` by default / Kata) for\n # running untrusted code. See runtime/sandbox.py::make_sandbox for the tier selection.\n trust_mode: str = \"trusted_local\"\n # Image for the untrusted command-eval tier (RepoTask, Phase 4): the framework's deps\n # should be baked in (the container runs --network none, so a pip setup can't fetch).\n docker_image: str = \"python:3.12-slim\"\n # Resource caps for the untrusted/hostile Docker tier (passed to `docker run --memory/--cpus`).\n # The default 4g bound protects other tenants from an OOM, but a model-training MLE-bench eval\n # routinely needs more — raise it (e.g. \"16g\") for such runs. \"\" disables the cap (unbounded).\n # Ignored by the trusted_local (subprocess) tier.\n sandbox_memory: str = \"4g\"\n sandbox_cpus: str = \"\"\n # Best-effort host-OOM guard for the TRUSTED-LOCAL (subprocess) tier. When set (e.g. \"8g\"), each\n # eval child gets an RLIMIT_AS address-space cap so a runaway trainer hits MemoryError instead of\n # OOM-killing the whole host (and the engine). POSIX only; a no-op on Windows. Default \"\" = OFF:\n # RLIMIT_AS bounds VIRTUAL memory, and CUDA/torch reserve tens of GB of virtual without using it,\n # so a default cap would spuriously kill GPU evals — leave it off for those and use the Docker\n # tier's real --memory cgroup bound (sandbox_memory) instead. Ideal for CPU/numpy/sklearn runs.\n sandbox_memory_local: str = \"\"\n # Best-effort disk-fill guard for the TRUSTED-LOCAL tier (P1-5): an RLIMIT_FSIZE cap on the size of\n # any single file an eval child writes (e.g. \"2g\"), so a runaway that writes an unbounded file gets\n # SIGXFSZ instead of filling the host disk. POSIX only. Default \"\" = OFF (large model checkpoints\n # need big files); set it for tasks that write only small artifacts.\n sandbox_fsize_local: str = \"\"\n # Search policy (ADR-2): \"greedy\" | \"evolutionary\" | \"mcts\" | \"asha\".\n policy: str = \"greedy\"\n # Ablation-driven refinement (I7): every N improves, ablate the best to find the\n # highest-impact parameter and refine it. 0 = off. (GreedyTree only.)\n ablate_every: int = 0\n # A0a (MLE-STAR): when ablating, treat each generated pipeline *code block* (not just a\n # numeric param) as an ablation unit — neutralize a block, measure the metric delta, refine\n # the highest-impact block. Off => the classic numeric-param ablation. Config-first knob.\n ablate_code_blocks: bool = False\n # A0b: real merge/ensembling. \"mean\" = legacy mean-param merge; \"ensemble\" = the Developer\n # writes a code-recombination ensemble over the two parents' solutions (verified: agent-proposed\n # ensembling 37.9%->43.9%). \"auto\" (default) = ensemble whenever the Developer actually\n # generates code (LLM/agent backends; declared via `is_code_generating`), mean for the\n # templated/toy developers where a code ensemble is meaningless — the evidence says code\n # recombination is the strongest single merge (removing it costs ~9 pp), so it is the default\n # wherever it can work. Falls back to mean when the Developer can't ensemble.\n merge_mode: str = \"auto\"\n # A0d (AIRA): inject a dynamic complexity hint into the draft/improve prompt keyed on the\n # node's child count (few children -> keep minimal; many -> escalate to ensembling/HPO).\n complexity_cue: bool = False\n # I1 (CAAFE-style) LLM feature-engineering: instruct the proposer/developer to add\n # semantically-meaningful engineered features (ratios, interactions, aggregations) as code. The\n # existing cross-validation eval + best-selection is the CV GATE — a feature set that doesn't\n # improve CV is never selected (feature engineering is non-universal, so this gate is mandatory).\n # Tabular tasks (those exposing column data). Off by default.\n feature_engineering: bool = False\n # A5: surface the REMAINING eval-compute budget into the proposal prompt so the Researcher can\n # reason over how much compute is left (explore broad while flush, exploit/cheapen when low).\n # No-op unless a max_eval_seconds budget is set.\n budget_aware: bool = False\n # A4 (LATS-style): feed a summary of the most recent FAILED branches (operator + error reason)\n # back into the proposal prompt so the proposer reflects on and avoids repeating them. ON by\n # default: it is SELECTIVE by construction (injects only when recent failures exist — the\n # evidenced form of reflection; reflecting at every step shows no gains, arXiv:2506.12928).\n failure_reflection: bool = True\n # Feed the LIVE-WATCHDOG observations (train-monitor health verdicts + ASHA intermediate-rank flags)\n # of recent experiments back into the proposal prompt, so the Researcher avoids re-proposing a config\n # whose TRAINING was already observed to be weak — even when the watchdog kills are off (default) and\n # the node ran to completion (those diagnostics are fold-ignored, so failure_reflection never sees\n # them). ON by default and SELECTIVE like failure_reflection (injects only when a recent flag exists).\n watchdog_reflection: bool = True\n # C3 deep test-driven repair: hand the Developer the failure taxonomy + a structured \"reproduce\n # then fix\" directive on debug, not just the raw stderr tail. ON by default (product surface); the\n # conservative library default in EngineOptions stays off, per that module's contract.\n deep_repair: bool = True\n # === Inline crash repair ==============================================================\n # Hybrid in-node crash repair: when an LLM-generated node CRASHES at runtime (mechanical errors\n # — bad import, removed kwarg, typo), the agent triages it and may repair the code IN PLACE within\n # the same eval (cheap; does NOT consume max_nodes and does NOT add a node to the search tree).\n # Semantic failures still flow to a new idea/debug node via the policy. ON by default; set False\n # to restore the prior behavior (every crash waits for a budgeted inter-node debug node).\n inline_repair: bool = True\n # Max in-place repair attempts per node before it fails normally. Default 0 = UNLIMITED: the same\n # agent keeps fixing + re-evaluating IN THE SAME node until it produces a clean metric — no new\n # node just to retry. Runaway is prevented not by a count but by the anti-stuck guard\n # (`inline_repair_stuck_repeat`): when the SAME error signature repeats with no progress, or the\n # agent's crash-triage says \"abandon\", the node fails. Set a positive N to cap the retries instead.\n inline_repair_attempts: int = Field(default=0, ge=0)\n # Anti-stuck: abandon in-node repair when the SAME error recurs this many times in a row (no\n # progress). Keeps \"unlimited\" repair from looping forever on an unfixable error.\n inline_repair_stuck_repeat: int = Field(default=4, ge=2)\n # Which failure reasons (_failure_reason: crash|timeout|oom|setup|no_metric|drift) are eligible for\n # inline repair. Default: mechanical crashes, timeouts AND OOM-kills — a timeout/OOM means the code\n # was too slow / too memory-hungry for the budget (or the pod's cgroup limit), not that the idea is\n # wrong, so the agent repairs it by reducing compute/memory (fewer estimators/epochs/folds/seeds, a\n # lighter model, a smaller batch, subsampling) instead of letting the node die unrecovered. Env\n # override expects a JSON array.\n inline_repair_reasons: tuple[str, ...] = (\"crash\", \"timeout\", \"oom\")\n # Cost ceiling for in-node repair of a MULTI-STAGE eval: the max number of FULL pipeline re-runs\n # (i.e. re-trains from the first stage) the inline-repair loop may do before abandoning to the\n # inter-node debug operator. A late-stage failure (a broken `score`/eval script that didn't touch\n # `train`) REUSES the completed train checkpoint and re-runs only from the failed stage — that is\n # CHEAP and NOT counted here; only a repair that changes an EARLIER stage's code (train.py/loss.py/…)\n # forces a full re-train, which each costs the whole training time. Without this bound, a repair that\n # keeps changing training code could burn many full trains (the anti-stuck guard is error-signature\n # based, not cost based). 0 = unlimited (legacy behavior). Single-command evals ignore it.\n inline_repair_retrain_cap: int = Field(default=2, ge=0)\n # Environment self-prep: when a solution crashes purely because a KNOWN library isn't installed\n # (ModuleNotFoundError), the engine pip-installs it into the eval interpreter and re-runs — so a\n # torch/XGBoost/CatBoost idea (e.g. a GRU model) actually runs on a fresh box instead of being\n # thrown away as `idea_rejected`. Trusted_local tier only (the untrusted/hostile Docker tiers run\n # --network none). A typo'd or local helper module is NOT installed (that's a code bug to repair).\n auto_install_deps: bool = True\n # Per-package wall-clock budget for an auto-install (seconds). Generous: large wheels like torch\n # can take minutes to download/build.\n dep_install_timeout: float = Field(default=900.0, gt=0)\n # --- Agent governance: who may change a setting at runtime (per-setting allow-list of roles) ---\n # Roles: \"strategist\" (A7 meta-controller; run-wide), \"boss\" (run-chat operator-proxy; run-wide),\n # \"researcher\" (per-experiment proposer; PER-NODE — e.g. set a longer eval timeout for a neural-net\n # idea). A setting ABSENT from this map is LOCKED — no agent may change it, only a human via the\n # snapshot/UI. The sole conditional exception is Strategy-only `card_scoring`: Card mode grants\n # it implicitly without changing the default flag-off snapshot, while an explicit [] revokes it.\n # The gate is ENFORCED at every AGENT seam via `_agent_may` — the strategist's whole\n # control surface (policy/operators/novelty_stance/prefer_sweep/developer/fidelity/timeout/\n # eval_parallel/llm_parallel) is checked in `_apply_strategy`, so removing a role from a knob locks it,\n # not just greys out the UI pill. The boss/max_* grants document boss-relevant knobs, but a\n # `budget_extend` is a HUMAN control intent (the boss action-builder can only emit `add_nodes`), so\n # it is applied as-is — the human always wins via the UI/snapshot. Defaults are conservative: resource/\n # search-shape knobs the agents already reason about; provider infra (llm_model/base_url/api_key,\n # docker_image) stays locked. The UI shows S/B/R pills per setting. `timeout` granted to the\n # researcher IS the \"auto\" per-node mode — the researcher sizes each experiment's wall-clock\n # (Idea.eval_timeout); the config `timeout` is the fallback for nodes it doesn't size. Env override\n # expects a JSON object. (`fidelity`/`novelty_stance`/`prefer_sweep`/`developer` are governance keys\n # for the strategist's per-node dials — not 1:1 Settings fields, but gated the same way.) The default\n # lives in the module-level DEFAULT_AGENT_CONTROL so the Engine's direct-construction default matches.\n agent_control: dict[str, list[str]] = Field(default_factory=default_agent_control)\n # C1 (Agentless localization): for repo tasks, rank the source files most relevant to the most\n # recent failure (traceback + identifiers) and surface them in the proposal/repair prompt so the\n # Developer edits the right place. Off by default; repo tasks only.\n localize_faults: bool = False\n # A2 surrogate-guided proposal (BO-lite): fit a k-NN surrogate over (params->metric) and propose\n # by acquisition instead of random/hill-climb. Numeric-bounds tasks only; bootstraps via the\n # base Researcher. Off by default. `surrogate_explore` = UCB-style exploration weight.\n surrogate_proposer: bool = False\n surrogate_explore: float = 0.1\n # E2 researcher panel: generate K candidate ideas per proposal and keep the one ranked best by a\n # cheap empirical surrogate over the (params->metric) history (NOT an LLM-judge). 1 = off.\n researcher_panel: int = 1\n # A1 ASHA / successive-halving: reduction factor (keep top 1/eta per rung) and rung budget.\n asha_eta: int = Field(default=3, ge=2)\n # Rung-0 width for ASHA/BOHB (the wide base of cheap drafts). 0 = use n_seeds (default,\n # preserving prior behavior); set >0 to seed a wider/narrower base independent of n_seeds.\n asha_rung_nodes: int = Field(default=0, ge=0)\n # A6 proxy/predictive scoring: cheaply rank a candidate's potential from early-stage signals\n # and skip a full eval for the doomed bottom fraction. 0.0 = off (no candidate skipped).\n proxy_scoring: bool = False\n proxy_kill_fraction: float = Field(default=0.0, ge=0.0, le=0.9)\n # === Novelty / dedup ==================================================================\n # E1 novelty/dedup gate: reject near-duplicate proposals (normalized param-space distance below\n # `novelty_epsilon`) by deterministically nudging them off the duplicate — stops the search\n # wasting evals re-trying the same idea. Audit event `novelty_rejected`. Off by default.\n # Novelty/dedup MODE selector: how a fresh proposal is checked against already-tried experiments.\n # \"off\" — no explicit gate; the agentic Researcher's own read-the-history judgment is trusted.\n # \"algo\" — the deterministic gate: numeric param-distance (`novelty_epsilon`) + optional embedding\n # similarity (`novelty_semantic`). Cheap, no LLM call, but can't explain itself.\n # \"llm\" — an LLM adjudicates (reads the real experiments via tools) whether the idea is a\n # near-duplicate and, if so, asks the Researcher once more for something different. One\n # extra LLM call per proposal — highest quality, follows the \"let the LLM decide\" line.\n novelty_mode: str = \"llm\"\n # Legacy sub-toggles, honored by the \"algo\" mode (and back-compat: novelty_gate=True forces \"algo\"):\n novelty_gate: bool = False\n novelty_epsilon: float = 0.05\n # T5 semantic novelty (active whenever the deterministic gate runs — novelty_mode=algo,\n # novelty_gate=true which is the legacy alias for algo, OR the Strategist novelty stance is\n # \"explore\"; a no-op only under novelty_mode=llm/off with a non-explore stance): reject a proposal\n # whose idea TEXT embeds within\n # `novelty_semantic_threshold` cosine of an existing node's. OFF BY DEFAULT: novelty is the agentic\n # Researcher's call, made by READING the actual prior experiments (read_experiment /\n # find_analogous_across_runs / list_experiments), NOT an embedding-similarity auto-reject that can't\n # explain itself and mis-fires on paraphrases. Semantic/param search stays available as TOOLS in the\n # Researcher's context; the LLM decides whether an idea is a true duplicate.\n novelty_semantic: bool = False\n novelty_semantic_threshold: float = Field(default=0.92, ge=0.5, le=1.0)\n # T10: debug-lineage depth bound for every policy — how many error-feedback repairs a failing\n # lineage gets before it is abandoned. The old default (1) abandoned lineages after ONE repair;\n # multi-turn/deeper debugging is a verified lever (AIRA2 ReAct debug +5.5 percentile points).\n debug_depth: int = Field(default=2, ge=1)\n # P4 operator bandit (GreedyTree): replace the fixed merge/ablate cadences with a\n # deterministic UCB over per-operator yield (Δmetric per eval-second, folded from the run).\n # Off by default (the cadences are well-tested and the bandit has no direct published\n # ablation); `thorough` turns it on.\n operator_bandit: bool = False\n # P1 hypothesis ledger: ask the Researcher to state the one-line hypothesis each experiment tests,\n # register deep-research directions as hypotheses, and track them to a verdict on the board. ON by\n # default. The board is shown to the Researcher and can be foresight-ordered, so it can\n # steer later proposals even though it never directly re-ranks already-evaluated nodes. Set False\n # to drop the prompt nudge + registration.\n track_hypotheses: bool = True\n # E4/M2/M3 reflection-memory -> priors (gradient-free cross-run meta-learning): at run end distill\n # the winner into `<memory_dir>/meta_notes.jsonl` AND structured lessons (incl. NEGATIVE results:\n # tested/abandoned hypotheses + failure themes) into `lessons.jsonl` with a task fingerprint; at run\n # start, inject exact-task notes + fingerprint-matched lessons from SIMILAR tasks into the proposal\n # prompt. ON by default — but a NO-OP until `memory_dir` is set (that's where cross-run memory lives).\n reflection_priors: bool = True\n # M6 comparative lessons (MARS \"comparative reflective memory\", doc 13 §7 item 2): distill\n # credit-assigned lessons from PAIRS of solutions — which SPECIFIC change made a child beat\n # (or regress from) its parent, and what fixed a failure — on top of the one-shot run-end\n # reflection. At most one extra LLM call per distillation (all pairs batched); offline a\n # deterministic param-diff credit stands in. Gated on reflection_priors + memory_dir like the\n # rest of cross-run memory — and since memory_dir has a default (~/.looplab/memory), this is\n # ACTIVE out of the box; clear memory_dir to disable cross-run memory wholesale.\n comparative_lessons: bool = True\n # M6 mid-run cadences (doc 13 §7 item 5 — the AgentRxiv live-share pattern): every N created\n # nodes, WRITE comparative lessons to the shared cross-run store during the run (lessons_every\n # — one batched LLM call per firing) and RE-READ the store so lessons distilled by CONCURRENT\n # runs reach this run's proposals (lessons_refresh_every; file re-read, no LLM call, skipped\n # when the store is unchanged). ON by default (every 4 nodes) and, like comparative_lessons\n # above, live out of the box because memory_dir defaults on; 0 = off — run-end write /\n # run-start read only, the pre-M6 behavior.\n lessons_every: int = Field(default=4, ge=0)\n lessons_refresh_every: int = Field(default=4, ge=0)\n # B3 output redaction: mask credentials (known key shapes + high-entropy tokens) in the\n # stdout/stderr tail before it is persisted to the event log / spans / UI — a leaked secret in a\n # print()/traceback must not land in the durable log. Off by default; recommend on for untrusted.\n redact_output: bool = False\n # B5 reward-hacking detector: a host-side monitor that flags suspicious wins (grader/answer-key\n # access, runtime writes to frozen files, suspiciously-perfect metrics) as a `reward_hack_suspected`\n # audit event in the Trust panel. Off by default. Whether a flag CHANGES selection is governed by\n # `trust_gate` below (default: audit-only, never changes selection).\n reward_hack_detect: bool = False\n # Trust enforcement (T2): what a reward-hack / data-leakage flag actually DOES to selection.\n # \"audit\" (default) = surface it in the Trust panel, never change selection (today's behavior);\n # \"gate\" = a flagged node can no longer be selected as BEST **and isn't bred/confirmed from**\n # (breed_excluded — see replay._apply_trust_gate / models.breedable_nodes), but stays\n # FEASIBLE so it still counts for diversity/audit — closes the \"a hacked/leaky node can\n # win OR seed its lineage\" hole;\n # \"block\" = additionally mark it fully INFEASIBLE (removed from feasible_nodes entirely).\n # Deliberately gates only high-precision CHEATING/LEAKAGE signals. Broad `critic:*` findings stay\n # advisory; `critic:hardcoded_metric` is the one narrow high-precision critic exception\n # and gates because it requires a literal metric with no computed assignment anywhere.\n trust_gate: str = \"audit\"\n # I3 data-centric: static code-leakage scan of each evaluated solution (fit-before-split,\n # fit-on-test) surfaced into the Trust panel alongside reward-hack flags. Off by default.\n code_leakage_detect: bool = False\n # 4.4 sandbox instrumentation (needs reward_hack_detect): after each eval, flag a RUNTIME write\n # to a protected/frozen file (an answer key or grader tampered mid-run) — behavioral evidence a\n # static code scan misses. ON by default; only fires when the reward-hack detector is on.\n workdir_audit: bool = True\n # D8 decoupled research Verifier: check every Deep-Research memo's CLAIMS against their cited\n # evidence (node ids / urls) before recording — synthesis is the documented weak link (Kosmos\n # 57.9% accurate). Deterministic layer always; an LLM rubric pass when the DeepResearcher has a\n # client. The verdict cannot change this run's node/champion, but finalize uses aligned supported\n # verdicts as the evidence gate for persisted D8 cross-run claims; unavailable/misaligned stays\n # unverified. ON by default (no-op with no memo/claims).\n research_verify: bool = True\n # C4 independent critic: an execution-free critic of each solution (stub / hardcoded-metric /\n # params-ignored; on host-graded tasks the metric checks become a submission-output check)\n # surfaced in the Trust panel. Broad findings are advisory; `critic:hardcoded_metric` can gate under\n # trust_gate=gate/block. Off by default.\n critic_check: bool = False\n # A7 Strategist (NEW, user-requested): optional LLM/rule meta-controller that picks the search\n # policy/allocator + operator mix + fidelity (+ Developer backend) per situation. Config-first:\n # every choice the Strategist makes is also a direct knob. Defaults to \"agent\" so the agent adapts\n # the search policy/operators/fidelity per situation; set \"off\" for static config-driven search.\n # \"agent\" = a tool-using Strategist that READS the run/data/sibling-runs/KB/memory (B1-guarded)\n # before deciding, instead of the single-shot \"llm\" call over aggregate stats.\n strategist_backend: str = \"agent\" # \"off\" | \"rule\" | \"llm\" | \"agent\" — default AGENTIC: the\n # Strategist READS the run/data/sibling-runs/KB/memory with tools before deciding, not a single-shot\n # call over aggregate stats. \"llm\" = the old non-agentic single-shot; \"rule\"/\"off\" = no LLM.\n strategist_every: int = Field(default=3, ge=1) # consult cadence (created nodes)\n # PART V (F1): concept CLASSIFIER re-tag + consolidation cadence, DECOUPLED from strategist_every. The\n # LLM concept map is heavier and slower-moving than a strategy consult, so it refreshes on its own\n # (sparser) interval. Researcher-authored idea.concepts still fold immediately at node_created — this\n # only paces the classifier-evidence + consolidation refresh (and the concept-coverage pivot snapshot).\n concept_retag_every: int = Field(default=30, ge=1)\n # === Confirmation & holdout ===========================================================\n # Multi-seed confirmation (I12, ADR-15): confirm the top-k under N seeds before\n # finishing. 0 disables (default). Only meaningful when eval has variance.\n confirm_top_k: int = 0\n confirm_seeds: int = 0\n # D1 seed-holdout discipline: the FIRST seed the confirm phase uses. Search evals run with the\n # implicit seed 0 (LOOPLAB_EVAL_SEED unset -> \"0\"), so a base of 1 keeps every confirm split\n # DISJOINT from anything the search optimized against — the confirm metric is then a\n # generalization signal, not a re-measurement (AIRA: selecting on a signal the search saw\n # overfits by 9-13 pp). Set 0 to restore the legacy overlapping seeds 0..N-1.\n confirm_seed_base: int = Field(default=1, ge=0)\n # D1 holdout-gated promotion (B6, Arbor-style): for host-graded tasks, reserve this fraction of\n # the held-out labels as a FINAL holdout partition the search never sees — every search/confirm\n # eval is scored on the remaining rows only, and at finish the val-top-k are re-scored on the\n # holdout partition (free: the predictions already exist). 0.0 = off (legacy full-label scoring).\n holdout_fraction: float = Field(default=0.25, ge=0.0, le=0.9)\n # Champion selection prefers the HOLDOUT metric among the nodes that have one (the val-top-k).\n # Recorded in run_started so replay applies the same rule; old logs fold to False (legacy pick).\n # The per-node val-holdout `generalization_gap` folds into the Trust panel either way.\n holdout_select: bool = True\n # How many val-leaders get a holdout evaluation at finish (host-graded tasks).\n holdout_top_k: int = Field(default=3, ge=1)\n # R1-c / Part IV — calibrated §12-verifier tie-break in best-selection. When a fresh node's robust\n # metric EXACTLY TIES an already-eligible node, the engine runs the §12 verifier (grounded on each\n # node's realized result, `selection_criteria`) and records a soundness score per node. The tie-break\n # then applies across the WHOLE selection path: the mean pick breaks a robust_metric tie by the score,\n # and — when `holdout_select` is on — the holdout-slot ranking prefers the sounder node so it isn't\n # denied a holdout eval, and the final holdout override breaks a holdout_metric tie by it too. Strictly\n # a TIE-BREAK — it can NEVER move a node ahead of a strictly-better metric (§21.7 advisory-never-\n # overrides). Best-effort: verification is LAZY (only fires on an actual tie) and bounded per cadence,\n # so a large/late tie stays on deterministic metric+id order unless one atomic treatment scores every\n # member. Recorded in run_started so\n # replay applies the same rule; old logs fold to False (legacy pick). Opt-in (default off); needs a\n # reachable LLM client. Enable only after validating calibration offline with `verifier.calibrate`.\n # See trust/verifier.py + core/fitness.py.\n select_verifier: bool = False\n # R1-d (§21.19): widen the verifier tie-break from EXACT-metric to a conservative STATISTICAL tie.\n # The band is capped by both the metric leader's SE and the pooled SE-of-difference, so challenger\n # variance cannot widen it and a significant difference is never a tie (§21.7). Off -> exact only.\n verifier_ci_tie: bool = False\n # Verifier sample count for `select_verifier` (the §12 repeated-sampling expectation). 3 tames the\n # single-shot variance; 1 = single-shot (cheaper, noisier).\n select_verifier_samples: int = Field(default=3, ge=1, le=32)\n # Budget (I13): hard wall-clock ceiling; the run aborts cleanly when exceeded.\n max_seconds: float | None = None\n # Eval-compute budget (#2): hard ceiling on cumulative time spent INSIDE evals (training\n # runs), separate from wall-clock. Survives resume (summed from the event log). The real\n # guard against a silent multi-hour sweep when an eval is a minutes-hours training run.\n max_eval_seconds: float | None = None\n # Cross-run memory (I19, ADR-10): if set, the best result of each run is stored as\n # a case here, and the cases become retrievable knowledge for future runs.\n memory_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"memory\"))\n # HITL (I21, ADR-11): pause for human approval of the final best before finishing.\n require_approval: bool = False\n # Diversity archive (I22): niche bucket width in parameter space.\n archive_resolution: float = 1.0\n # Coverage read-model (narrowing signal): at the strategist cadence, compute a breadth summary\n # (themes / param-niches / theme entropy / dominant-theme fraction) from the folded run, record\n # it as a `coverage_snapshot` audit event, and surface it into the Strategist's decision context.\n # Deterministic, cheap, and purely additive context — no search-behavior change on its own. On by\n # default (like the rest of the always-on situational context). See search/coverage.py.\n coverage_context: bool = True\n # PART IV Phase 2a live steering (§21.11/§21.13). When on, the `concept_retag_every` cadence (NOT\n # `strategist_every` — the producer gates on `_should_consult_concepts`, which uses the seed boundary\n # + `concept_retag_every`, default 30) records a\n # concept-graph coverage + uncovered-region snapshot (deterministic heuristic tagger over the\n # task-type skeleton) and, on an `explore` stance, the Researcher's novelty hint names the exact\n # uncovered regions (\"0 coverage in {negatives/external-mining, distillation} — go there\") instead\n # of the vague \"broaden\". ON by default in the product Settings (ce4a379), EngineOptions off: it\n # only enriches an already-mode-gated explore hint, never forces exploration, and no-ops for a task\n # with no curated concept skeleton. The snapshot is auditable, but its prompt cue can change future\n # candidate generation; it does not directly rewrite best-metric selection. See search/concept_graph.py.\n # DESIGN NOTE (2026-07-17 critique): now ON unconditionally, but a toy / non-ML task yields EMPTY concept\n # tags while still paying the per-node LLM concept cost (~10 calls/node with the full Part IV/V bundle).\n # Consider AUTO-QUIESCING: if the heuristic tagger returns empty for the first N nodes (no concept signal),\n # skip the LLM concept work for the run — keeps it on for ML tasks, free on toy/degenerate ones.\n concept_pivot: bool = True\n # PART IV Phase 2b — D3 graded novelty into the LIVE novelty gate (§21.4/§21.13). When on and the\n # task has a curated concept skeleton, the novelty gate first grades a proposal over the concept\n # graph: a level-4 \"same direction, DIFFERENT implementation\" or a level-5 \"re-opens a\n # wrongly-abandoned FAILED direction\" proposal is ALLOWED through unchanged — the flat LLM/semantic\n # dedup gate can't tell \"this DCL tweak\" from \"the whole DCL branch\" and would wrongly reject a\n # legitimate variant or never re-open a sound-but-killed direction (the node_63 archetype). Levels\n # 1/2/3 (identical / near-dup / prior-run) still fall through to the existing gate. Deterministic\n # (heuristic tagger, no LLM), audit event `novelty_graded`, replay-safe. ON by default in the product\n # Settings (ce4a379), EngineOptions off; no-ops for a task with no skeleton. See search/graded_novelty.py.\n graded_novelty: bool = True\n # PART IV Phase 2b — D7 capability-expansion forced-jump DIRECTIVE (§21.8/§21.13, issue #7). When on\n # and the concept-graph cadence detects action-space LOCK-IN (the search has stayed inside one D5\n # branch for a long consecutive streak) on an `explore` stance, the Researcher's novelty hint\n # ESCALATES from \"broaden\" to \"expand the ACTION SPACE — build the missing infra (external mining,\n # a new data pipeline, a different eval), do NOT swap another {locked} lever.\" The same\n # gate also stamps the proposal's authoritative operator as `expand`; that node competes normally\n # under SearchFitness and its separate operator yield is measured. It does not force a winner.\n # Reads the 2a concept-coverage snapshot, so it needs `concept_pivot` to record one. OPT-IN\n # (default off). See engine/proposal_cues.py + search/lock_in.py.\n capability_expansion: bool = False\n # PART IV cross-run Step 0 (§21.20.12/§21.20.13). Universal task-fingerprint tokenization: drop the\n # ASCII-only `[a-z0-9]` allowlist on goal keywords so a non-Latin goal (Russian, CJK, …) is NOT\n # silently dropped from its cross-run fingerprint (today such a goal keys only on kind/dir/metric and\n # never reaches SIMILAR-task priors/lessons/cases). Uses `[^\\W_]+`/`.casefold()` — same splitting as\n # before, any script. ON by default in the product Settings (ce4a379); the bare-library EngineOptions\n # default stays off, so a run pinned to it won't silently re-key a portfolio mid-flight. See engine/memory.py.\n fingerprint_universal: bool = True\n # PART IV cross-run Step 2 (§21.20). Fill graded-novelty's `prior_concepts` from the cross-run\n # ConceptCapsuleStore: at run end write a per-run concept capsule (the shipped `node_concepts` tags +\n # best outcome, keyed by task_fingerprint) to `memory_dir`; when a later SIMILAR run proposes an idea\n # whose concept was tried before, SURFACE the prior outcome as a `cross_run_prior` audit event (never\n # reject — D3 level 3). Audit-only, off the selection path; ON by default in the product Settings\n # (ce4a379), EngineOptions off. Needs a `memory_dir` to share capsules.\n # See engine/memory.py (ConceptCapsuleStore) + engine/novelty.py.\n # PREREQUISITES (this flag is NOT standalone): the WRITE needs per-run concept tags\n # (`node_concepts`, produced by `concept_pivot`/F1 tagging) — no tags => no capsule; the SURFACE path\n # runs inside the graded-novelty precheck, so it also needs `graded_novelty` on. With only this flag +\n # `memory_dir` it persists/surfaces nothing. Enable `concept_pivot` + `graded_novelty` alongside it.\n # A non-Latin (Russian/CJK) portfolio should ALSO set `fingerprint_universal`, else the capsule's goal\n # tokens are dropped and its fingerprint over-matches on kind/dir/metric alone (the novelty direction\n # gate narrows this, but goal-word similarity is still lost).\n cross_run_concepts: bool = True\n # PART V (B): run-base + node-DELTA concept authoring. When on, once the first evaluated node has\n # authored concepts the engine seeds RunState.run_base_concepts from them (a one-shot EV_RUN_CONCEPTS,\n # idempotent because it only fires while the base is empty), and the Researcher's proposal hint tells it\n # to author only `concepts_added`/`concepts_removed` vs the run base + its parent — minimal per-node\n # annotations that inherit down the DAG (a swap propagates). Off -> every node authors its full concept\n # set as before. Product-on; the bare EngineOptions mirror defaults off (test_options_divergence).\n concept_run_base: bool = True\n # PART IV cross-run Step 5 advisory (§21.20.5). Fold the bounded cross-run CONTEXT PACK — evidence-\n # grounded claims with BOTH support and counter-evidence (Step 4) + a portfolio-coverage line (Step 3) —\n # into the Researcher's proposal prompt, exactly like the E4 cross-run prior note. Advisory ONLY: it is\n # prompt-grounding: it can change the Researcher's later candidates, but does not directly rewrite\n # best-metric selection (§21.7). Reads `memory_dir` (lessons.jsonl + concept_capsules.jsonl); \"\" when\n # empty. ON by explicit experimental product choice; no frozen A/B validation is claimed. The\n # bare-library EngineOptions default stays off (engine/options.py, frozen in\n # test_options_divergence). See engine/proposal_cues.py.\n cross_run_advisory: bool = True\n # PART IV cross-run §21.20.13 (full CR of the lean fuzzy claim merge). Switch the claim read-model to the\n # SCOPE+POLARITY-safe STRUCTURED claim key (engine/claim_key.py): claims from different tasks never\n # merge, opposite polarity (\"X helps\" vs \"X never helps\") is surfaced as a CONTRADICTION instead of being\n # collapsed, paraphrase/inflection variants group by exact structured key (no transitive over-merge), and\n # operator governance is scope-precise (a decision in task A cannot reach a same-worded claim in task B).\n # Affects the `cross_run_advisory` context pack only; ON by default in the product Settings (ce4a379);\n # the bare-library EngineOptions default stays off (engine/options.py). See engine/claims.py.\n cross_run_structured_claims: bool = True\n # PART IV cross-run §22.4 (AGENTIC taxonomy steward). At finalize, when an LLM client is available, let\n # the concept steward (engine/concept_steward.py) review the freshly-updated portfolio concept graph and\n # PROPOSE a curation — merge duplicate slugs / split conflated ones / purge noise. Proposals are LOGGED to\n # `concept_curation_log.jsonl` for operator ratification (via the serve/CLI governance surface); the LLM\n # never mutates fold state. The calls run synchronously during finalize, so they add latency and may add\n # paid inference; a caught steward failure does not prevent terminal completion or trigger a retry.\n # ON by explicit experimental product choice when `memory_dir` + an LLM backend are present; the\n # bare-library EngineOptions default stays off (engine/options.py). See engine/lessons.py.\n cross_run_curation: bool = True\n # Deprecated compatibility flag. Steward output is untrusted and finalize is proposal-only regardless\n # of this value; retained so old snapshots still validate and logs can disclose that auto was requested.\n cross_run_curation_auto: bool = False\n # Role backend (ADR-7/14): \"toy\" (offline optimizer) | \"llm\" (live model). Validated in\n # `_check_trust_gate` alongside the other enum-ish strings — every consumer tests `== \"llm\"`\n # exactly (cli/__init__.py, adapters/tasks.py), so an untyped `--set`/file/env typo (or a mis-cased\n # \"LLM\") used to fall through to the OFFLINE toy roles and quietly produce a run that never called\n # the model. Left as `str`, not `Literal`, so old snapshots still validate through the same path.\n backend: str = \"toy\"\n # Developer backend (ADR-7): \"default\" (templated/LLM from the task) or an external\n # CLI coding agent: \"opencode\" | \"aider\" | \"goose\" | \"continue\".\n developer_backend: str = \"default\"\n # C2 best-of-N: generate N candidate implementations per node and keep the best by an\n # execution-free reward (static validity + metric-print). 1 = off. In-house LLM developer only\n # (not external agents — ADR-7 cost rule). The top SWE-bench reliability lever for weak models.\n best_of_n: int = 1\n # D10 list-wise best-of-N selection: when the top static-scorers TIE, break the tie with a\n # comparative LLM selection over the candidates presented together (+~3 pts vs pointwise on\n # GAIA, arXiv:2506.12928). The static score stays the primary filter — the LLM is a weak\n # comparative prior, never the sole oracle. ON by default (no-op when best_of_n==1).\n best_of_n_listwise: bool = True\n # === Foresight / predict-before-execute ===============================================\n # FOREAGENT predict-before-execute (arXiv:2601.05930): use the LLM as an IMPLICIT WORLD MODEL to\n # predict — WITHOUT executing — which candidate / hypothesis will score best, primed with a\n # Verified Data Analysis Report (data profile / brief) + the accumulated experiment memory. Master\n # switch, ON by default; a no-op wherever there is nothing to rank (best_of_n==1 and\n # foresight_panel==1) or no LLM client. See `looplab/search/foresight.py`.\n foresight: bool = True\n # Hypothesis foresight breadth: generate K candidate ideas per proposal and keep the one PREDICTED\n # best pre-execution by the world model — it ranks the STRUCTURAL / text ideas the numeric k-NN\n # surrogate (researcher_panel) is blind to, primed with the data profile + memory (the synergy).\n # >1 enables it (LLM backend only; needs `foresight` on); 2 = on by default at modest cost, 1 = off.\n foresight_panel: int = 2\n # AGENTIC foresight: run the ranking (hypothesis-board prioritization + K-idea pick) as a TOOL-USING\n # loop that can pull actual experiment results / data facts before deciding, instead of a one-shot\n # prediction from a pre-baked report. ON by default (needs foresight + a client; falls back to the\n # one-shot predictor on any hiccup). A few extra LLM calls per proposal; set False for the cheaper\n # single-call ranker.\n foresight_agentic: bool = True\n # §1 foresight confidence gate: the minimum predicted confidence at which a predict-before-execute\n # pick is ACTED ON. Below it the ranker abstains — the K-idea panel falls back to the first\n # proposal, best-of-N falls through to the D10 tie-break — instead of committing a low-confidence\n # choice. 0.0 (default) = OFF (act on every pick, the historical behavior); raise toward ~0.5 to\n # make the world model defer when it isn't sure. Pairs with the foresight track record (§1) the\n # predictor is now primed with. Range 0.0-1.0.\n # CLAUDE REVIEW: [EDGE-CASE] the field comment says \"Range 0.0-1.0\" but unlike the sibling\n # confidence/fraction knobs (train_monitor_kill_confidence, asha_live_quantile, ...) there is no\n # Field(ge=0.0, le=1.0) bound, so e.g. 5.0 validates silently — a gate that can never pass, so\n # every foresight pick permanently abstains with nothing in the snapshot to show why.\n foresight_min_confidence: float = 0.0\n # PART IV Phase 2c — replace the world model's SELF-REPORTED confidence (measured Pearson≈0 with the\n # realized outcome, §21.12) with a CALIBRATED §12-verifier score. When on and a client is available,\n # after the K-idea ranker picks the predicted-best candidate the §12 verifier scores it (grounded +\n # repeated + criteria-decomposed: `foresight_criteria` — likely to improve the objective, and\n # sound/feasible) and THAT calibrated score becomes the confidence the `foresight_min_confidence` gate\n # and the telemetry use. Degrades to the self-reported confidence without a client or on any verifier\n # error (the telemetry records which source was used). ON by default in the product Settings (ce4a379),\n # EngineOptions off; a few extra LLM calls per acted-on proposal. See looplab/trust/verifier.py + search/foresight.py.\n # REVISIT (cost/quality, not yet resolved): a live classification run measured ~10 LLM calls & ~3.8 min/node\n # on deepseek-v4-flash with the full Part IV/V default bundle; the calibrated verifier is part of that per-node\n # tax. Kept ON deliberately — it raises decision quality (self-reported foresight confidence has ≈0 Pearson\n # with realized outcome), and lowering quality to save calls is the wrong trade. TODO: run a frozen quality/cost\n # A/B (verifier-on vs -off) to quantify the recall@objective gain per extra call before considering a default flip.\n foresight_verify: bool = True\n # Verifier sample count for `foresight_verify` (the §12 repeated-sampling expectation on a no-logprob\n # backend). 3 tames the single-shot variance §21.12 measured; 1 = single-shot (cheaper, noisier).\n # RESUME-critical: this value is persisted in config.snapshot.json, so a hard `le` schema bound would\n # make an old run started with a larger LOOPLAB_FORESIGHT_VERIFY_SAMPLES un-resumable (Settings(**snapshot)\n # would raise on `looplab resume` / finalize-recovery / serve replay). CLAMP into [1, MAX] via a validator\n # instead of rejecting — the runtime cap is preserved without breaking the snapshots/env-compat rule.\n foresight_verify_samples: int = 3\n\n @field_validator(\"foresight_verify_samples\")\n @classmethod\n def _clamp_foresight_verify_samples(cls, v: int) -> int:\n # Coerce a persisted/env value into [1, MAX] rather than reject (see field note above).\n try:\n v = int(v)\n except (TypeError, ValueError):\n return 3\n return max(1, min(v, MAX_FORESIGHT_VERIFY_SAMPLES))\n\n # T7 LLM response cache: serve an identical DETERMINISTIC (temperature 0) request from an\n # in-process content-addressed cache instead of re-hitting the model — cuts cost on\n # retry/panel/verify flows. NEVER caches sampling calls (temp>0: best-of-N / panel / novelty\n # re-propose must vary). Off by default (most role calls run at temperature>0 anyway).\n llm_cache: bool = False\n agent_cmd: str | None = None # override the agent's launcher (path / wrapper)\n # External-agent validation (ADR-7): wrap a CLI-agent Developer with a validator that\n # audits each output (no-op/syntax/crash/timeout), retries with feedback, and falls\n # back to the in-house LLM Developer. Off -> use the raw agent output unchecked.\n validate_agent: bool = True\n agent_max_retries: int = 1 # re-prompts of the agent on an invalid result\n # Patch-gated multi-file agent (ADR-7 Rule 3): run the CLI agent in a git worktree and\n # accept only edits whose paths match `agent_surface` (reject-not-strip out-of-surface\n # touches). Lets the agent create helper modules, not just solution.py. Degrades to\n # whole-file readback if git is unavailable.\n agent_patch_gate: bool = True\n agent_surface: list[str] = [\"*.py\"] # edit-surface allow-list (globs)\n # Trust policy for an agent-authored eval/metric adapter (RepoTask onboarding, Phase 3):\n # \"ratify_freeze\" waits for human approval, \"autonomous\" accepts the proposed spec without that\n # gate, and \"ratify_freeze_drift\" also enforces the configured independent drift cross-check.\n eval_trust_mode: str = \"ratify_freeze\"\n # RepoTask node seeding policy (run-wide fallback; a task/editable `seed_mode` overrides):\n # \"auto\" (default) copies git-tracked files when the editable is a git repo (so a tree bloated\n # with untracked artifacts isn't deep-copied per node), else a full copy; \"tracked\" forces\n # code-only; \"all\" forces a full recursive copy (legacy). Genesis authors per-task from the\n # user's words; this is the global default when unspecified.\n seed_mode: str = \"auto\"\n llm_model: str = \"qwen3:8b\"\n # === LLM / transport ==================================================================\n llm_base_url: str = \"http://localhost:11434/v1\" # Ollama OpenAI-compatible endpoint\n llm_temperature: float = 0.6\n # H3 per-role model presets (5090 recipe): optionally run the Researcher and Developer on\n # DIFFERENT models/endpoints (e.g. Developer=Qwen3-Coder-30B for code, a fast model for breadth).\n # Blank = use the shared llm_model/llm_base_url. Generalizes the role-backend swap to per-role.\n researcher_model: str | None = None\n developer_model: str | None = None\n researcher_base_url: str | None = None\n developer_base_url: str | None = None\n # Per-role sampling temperature (§4.1): the Researcher wants breadth (higher temp = more diverse\n # ideas), the Developer wants determinism (lower temp = fewer syntax slips), the Strategist a\n # steady hand. Each overrides the shared `llm_temperature` for that role only; None = use the\n # shared value (byte-identical to before). Flat + nullable, mirroring the per-role model fields.\n researcher_temperature: float | None = None\n developer_temperature: float | None = None\n strategist_temperature: float | None = None\n # The Strategist had per-role TEMPERATURE but no model/endpoint of its own, so in split mode it\n # always ran on the shared llm_model however the other roles were pointed. These close that gap\n # with the same names/shape as the pair above; blank = its profile, else the shared value.\n strategist_model: str | None = None\n strategist_base_url: str | None = None\n # === Connection profiles (multi-provider) =============================================\n # A \"profile\" is a NAMED connection: {model, base_url, temperature, api_key_env, provider}. Roles\n # point at one by name through `role_profiles`; `llm_profile` is the default for every role that\n # doesn't. All three are EMPTY by default and the resolver short-circuits to the shared\n # llm_model/llm_base_url/llm_api_key path when they are — an operator running one model never\n # needs to know the word \"profile\".\n #\n # A profile stores `api_key_env`, the NAME of an environment variable, never the key itself. That\n # is ADR-11's \"a secret is a reference, not a value\" turned from a convention into a data type:\n # the whole profile map is then safe to write into config.snapshot.json, hand back over HTTP and\n # render into LOOPLAB_* — there is nothing in it to mask. It is also what makes per-role\n # CREDENTIALS expressible at all: two roles on one provider can carry different keys, which a\n # key-per-endpoint map could not say.\n llm_profiles: dict[str, dict] = Field(default_factory=dict)\n llm_profile: str | None = None\n role_profiles: dict[str, str] = Field(default_factory=dict)\n # Unified self-driving agent: one LLM identity that plays Researcher + Developer (+ Strategist)\n # across pipeline stages, choosing its own model/toolset per stage. ON by default — the agent\n # drives the loop (incl. crash triage: repair/abandon/reject_idea) and, via\n # `agent_drives_actions`, picks the next macro action within a pure legal-action gate. Set both\n # to False for the legacy byte-identical split-role behavior. (No-op unless backend=\"llm\".)\n unified_agent: bool = True\n agent_drives_actions: bool = True\n # Layer 3 Card queue ownership. False preserves the existing policy/unified-pilot action path.\n # True is pinned in run_started because changing the selector on resume would mix two search\n # treatments in one run. When both this and agent_drives_actions are true, Card selection wins.\n card_driven_selection: bool = False\n # Layer 5 bounded speculative Card buffer. Zero is a hard OFF switch and preserves the\n # historical alternating build/eval spine; positive values are pinned by run_started so a\n # resume cannot silently mix search treatments after a snapshot/default edit.\n speculation_depth: int = Field(default=0, ge=0, le=64)\n # Local evidence receipt produced by ``looplab speculation-gate``. This is deliberately absent\n # from the curated Settings UI: positive-depth execution is a calibrated rollout decision, not a\n # casual tuning knob. The Engine validates the receipt against the current scorer,\n # implementation, GPU inventory and raw paired-run evidence before it enables speculation.\n speculation_gate_receipt: str | None = Field(default=None, max_length=4096)\n # Per-stage model/endpoint overrides for the unified agent. Recognized keys (see\n # tasks.build_unified_agent): `propose` (researcher), `implement`/`repair` (developer),\n # `strategy`, `pilot`. Unlisted keys are ignored. Empty = the per-role researcher_*/developer_*\n # models, then the shared llm_model. Explicit map wins. No-op unless `unified_agent` is true.\n agent_stage_models: dict = {}\n agent_stage_base_urls: dict = {}\n llm_parser: str = \"tool_call\"\n # Reasoning/thinking toggle sent IN the request (providers differ — see `llm.reasoning_body`):\n # Qwen3 on vLLM/SGLang -> chat_template_kwargs.enable_thinking (bool)\n # OpenAI/Ollama-v1/DeepSeek -> reasoning_effort (low|medium|high|none)\n # `llm_reasoning`: \"\" = send nothing (server default — current behavior); off = actively disable;\n # on = enable at default depth; low|medium|high = enable at that effort.\n # Defaults to \"high\" so the agent reasons before proposing/repairing/triaging (better code, fewer\n # crashes); set \"\" to send nothing (server default) or \"off\" to disable. The model's chain-of-thought\n # is CAPTURED either way (reasoning_content field or inline <think>). NOTE: to get a SEPARATE\n # reasoning_content field from SGLang/vLLM, the server also needs `--reasoning-parser qwen3`.\n llm_reasoning: str = \"high\"\n llm_reasoning_style: str = \"auto\" # auto | qwen | effort | none — how to shape the request param\n llm_reasoning_extra: dict = {} # raw fields merged into the body (escape hatch, e.g. Anthropic thinking)\n # H1: drive structured calls with the endpoint's constrained decoding (vLLM/SGLang guided_json +\n # response_format json_schema) so weak models can't emit invalid JSON. Off for Ollama (default).\n llm_guided_json: bool = False\n # Stream every LLM request (SSE) and reassemble it, so the request `timeout` is an INTER-TOKEN\n # idle timeout rather than a whole-request deadline: a long-but-alive generation is never cut off\n # (tokens keep resetting the timer), while a genuinely STALLED endpoint (no token for `timeout` s)\n # is detected and retried on a fresh connection — the fix for silent multi-minute hangs. Set False\n # to use one blocking read (old per-op timeout semantics) if an endpoint streams badly.\n llm_stream: bool = True\n # LLM IDLE timeout (seconds). Stream mode: the INTER-TOKEN stall limit — a steady generation is\n # never cut off, only a silent endpoint. Non-stream: bounds the whole read. Raise for endpoints\n # with a long prefill on huge prompts; lower to fail fast on a flaky shared endpoint.\n llm_timeout: float = Field(default=180.0, gt=0)\n # First-byte (response-headers) window for STREAM attempts, seconds: an SSE response sends its\n # headers on admission, so no-headers ≈ a black-holed request — fail over to a fresh connection\n # fast instead of waiting the full idle timeout. Clamped to llm_timeout. Non-stream attempts are\n # NOT bounded by this (their headers arrive only after the whole generation). The default mirrors\n # `DEFAULT_HEADER_TIMEOUT_S` in looplab/core/llm.py is the single source (imported above).\n llm_header_timeout: float = Field(default=DEFAULT_HEADER_TIMEOUT_S, gt=0)\n # Honour HTTP(S)_PROXY / NO_PROXY / SSL_CERT_FILE env vars for LLM calls. Default False: the\n # transport connects DIRECTLY (many internal/OLAP endpoints are reachable only by bypassing an\n # ambient proxy — a picked-up proxy yields \"connection refused\"). Set true when the endpoint is\n # reachable ONLY via a corporate proxy or needs a custom CA bundle from the environment.\n llm_trust_env: bool = False\n # H4/C2: compact the growing agentic tool-call history once it exceeds this many chars (auto_summary\n # LLM-summarizes the stale middle; else middle-truncate). Sized to the model's real context window so\n # files read earlier STAY in context instead of being compacted away and re-read: ~1,000,000 chars ≈\n # 250k tokens (≈4 chars/token), matching the configured deepseek-v4-flash (≥200k tokens confirmed).\n # The old 120k-char (~30k-token) default was ~8× too aggressive and drove a read-thrash loop. 0 = off.\n context_budget_chars: int = 1_000_000\n # M5: the Researcher's always-on experiments digest budget (chars). 0 = AUTO — scales with the\n # run size (60 chars/node, bounded [1200, 6000]) instead of one flat cap for an 8-node toy run\n # and a 200-node benchmark run alike. Set a positive value to pin it.\n digest_char_cap: int = Field(default=0, ge=0)\n # D11 compression model slot (open_deep_research's four-slot pattern): a dedicated CHEAP model\n # for agent-history summarization (C2 auto-summary), so compression doesn't pay the main\n # model's price. Blank = use each loop's own model (legacy). `compressor_base_url` blank =\n # the shared llm_base_url.\n compressor_model: str | None = None\n compressor_base_url: str | None = None\n # === Agentic tool-loop ================================================================\n # Agentic tool-loop limits — apply to EVERY tool-using agent (the LLM Researcher, the unified\n # agent's pilot + crash-triage, Deep-Research, the run-chat Boss, the genesis repo-scout, and the\n # cross-run report synthesizer). The loop lets the model call read-only tools across turns before\n # emitting its structured result. Both default to UNLIMITED so the agent takes as many turns / as\n # long as it needs — it is never cut off mid-reasoning; set a positive value here (or per-run, or\n # in the UI settings) to bound latency/cost. These used to be hardcoded per call site (e.g. the\n # boss at max_turns=3 / 45s), which silently dropped a slow reasoning model to a no-op reply.\n agent_max_turns: int = 0 # max tool turns before the emit is forced (0 = unlimited)\n agent_time_budget_s: float = 0.0 # wall-clock ceiling across the loop's turns (0 = no cap)\n # G · Soft convergence SAFETY NET (not a research cap). Two thresholds on the agent's tool-turn count:\n # at `agent_emit_after` the loop NUDGES the agent once (\"you've investigated enough — emit now\"); at\n # `agent_emit_force` it FORCES the emit from what was gathered. Set GENEROUSLY — a focused researcher\n # may legitimately read many files/experiments before it can propose a grounded idea; these only catch\n # a genuine runaway that keeps issuing DIFFERENT calls forever (which stuck-detection, keyed on\n # repeats, misses). NB: the old 499-read runaway (node 63) was 79% REDUNDANT re-reads caused by a\n # broken read_file — fixing read_file (pagination) is the real cure; this is just insurance. 0 = off.\n agent_emit_after: int = 300\n agent_emit_force: int = 500\n # B1 · No-progress / stuck detection — the safety net that makes \"unlimited turns\" safe. The\n # turn/time ceilings above are only BACKSTOPS; this is what actually stops a runaway loop, on the\n # cheapest signal: when the model repeats the SAME tool call (or ping-pongs between two, or keeps\n # hitting the SAME error) with no progress, the loop forces the final emit and finishes. ON by\n # default; reading DIFFERENT files or running ONE long command never trips it. (OpenHands-style.)\n agent_stuck_detection: bool = True\n agent_stuck_repeat: int = 4 # identical calls in a row that count as \"stuck\" (>=2)\n agent_stuck_alternate: int = 4 # ping-pong cycles between two calls that count as \"stuck\" (>=2)\n # C1 · Self-plan (TodoWrite-style): expose an `update_plan` tool so a long-running agent keeps its\n # OWN working TODO and re-surfaces it every `agent_plan_reinject_every` turns — keeps the goal in\n # view across a long loop so the agent \"writes its own context\" (Claude Code style). ON by default.\n agent_self_plan: bool = True\n agent_plan_reinject_every: int = 5\n # C2 · Auto-summary (ON by default): when the tool-loop history grows long, LLM-summarize the\n # stale middle instead of only middle-truncating it. The trigger is `context_budget_chars` if set,\n # else a built-in high-water mark (context_budget.DEFAULT_SUMMARY_CHARS ≈ 120k chars) so short\n # loops are untouched. Falls back to truncation on any summarizer error; one extra model call per\n # over-budget turn.\n agent_auto_summary: bool = True\n # C4 · Repo-developer plan decomposition. A big feature attempted in ONE developer session made a\n # non-converging reasoning model loop indefinitely — it kept writing + exploring without ever\n # emitting `done` (10k+ LLM calls / hours; agentic stuck-detection can't catch VARIED exploration,\n # only literal repeats). Fix: the developer first proposes an ordered plan of ATOMIC steps; a\n # multi-step plan is executed step-by-step, each in a FRESH bounded session with only that step's\n # scope + the files accumulated so far, syntax-validated per write. Every session stays\n # small-context and convergent. ON by default for repo tasks (a 1-step plan == the old single pass).\n developer_plan_decompose: bool = True\n developer_plan_min_steps: int = 2 # a proposed plan with >= this many steps runs step-by-step\n developer_plan_max_steps: int = 8 # cap on plan length (a runaway planner can't spawn 100 steps)\n # Hard per-session backstop for the repo developer — the ONE write-heavy agent that can run away\n # even with stuck-detection on (varied writes/reads never trip the repeat/ping-pong signal). Bounds\n # the plan phase, EACH step, and the single-session fallback. Unlike the global agent_* limits\n # (0 = unlimited, meant for read-only agents), the developer always gets a FINITE ceiling so a\n # model that never emits `done` fails cleanly with the code it wrote so far, instead of looping.\n developer_session_max_turns: int = 500\n developer_session_time_budget_s: float = 1200.0 # 20 min wall-clock per developer session\n # Phase-handoff summaries. Each LLM phase in a node build (Researcher·propose → Developer·stages →\n # plan → implement) ends with ONE extra LLM call that distills its transcript — the repo structure\n # it mapped, files/data confirmed, decisions made — into a brief injected into the NEXT phase (even\n # across the role boundary). The next phase TRUSTS that instead of re-reading the same files, which\n # cuts the biggest source of tool-call thrash (stages/plan/implement each re-exploring the repo).\n # One summary call per phase in exchange for many fewer read calls downstream. ON by default.\n phase_handoff_summary: bool = True\n # Observability (ADR-17): capture each LLM call's full prompt + completion into the active\n # span (spans.jsonl) so the UI can show exactly what the model read and wrote per node.\n # Diagnostics only — `replay.fold` never reads spans. Default on for local single-user; set\n # LOOPLAB_TRACE_LLM_IO=0 to suppress (e.g. to keep spans.jsonl small or avoid storing prompts).\n trace_llm_io: bool = True\n # Run-introspection tools (ON by default): make the LLM Researcher (and DeepResearcher) a\n # tool-using agent that can read its OWN experiments (list/read/find-analogous/themes/code) and\n # the task data (schema/profile/asset) mid-loop, instead of seeing only best+parent. Advisory —\n # never changes best-selection. Off = the legacy single-shot Researcher (richer digest still added).\n researcher_tools: bool = True\n # Cross-run introspection: give the agentic Researcher / pilot read-only tools to look at\n # SIBLING runs (same task_id, same run-root) — list them, read an experiment / its code, and\n # find analogous configs across runs — so a run can build on what neighbouring runs already\n # learned instead of rediscovering it. Advisory; never changes best-selection. Needs the run's\n # own dir wired through (no-op for unit-built roles). Off = the legacy single-run view only.\n cross_run_tools: bool = True\n # Read-only access to EVERY run on this machine ACROSS ALL TASKS (not just same-task siblings):\n # the Developer/Researcher get list_all_runs + read_run_code + read_run_experiment so they can read\n # the code + result of ANY past experiment anywhere and reuse an approach. Broader than\n # `cross_run_tools` (same-task only); the agent decides when a foreign run is relevant. Advisory;\n # never changes best-selection. Needs the run's own dir wired through (no-op for unit-built roles).\n all_runs_tools: bool = True\n # PART V §22 — read-only CROSS-RUN KNOWLEDGE tool for the reasoning roles (Researcher, Strategist,\n # deep-research, and a Developer-scoped variant). Adds `cross_run_prior_attempts` / `cross_run_claims`\n # / `cross_run_atlas` over the §21.20 read-models (concept overview + claim assessments + atlas), read\n # from `memory_dir` (lessons.jsonl + concept_capsules.jsonl). Read-only is a storage-authority\n # boundary, not a behavioral no-op: retrieved content can steer later proposals, and a tool-using\n # reasoning loop can consume additional model calls. The tools never mutate cross-run truth (facts\n # are engine-written, verdicts operator-ratified, §22.4) or directly select a winner. ON by explicit\n # experimental product choice, EngineOptions off; stores only exist once Part-IV features have run.\n cross_run_read_tools: bool = True\n # Agentic retrieval (ADR-16): if set, the LLM Researcher gets grep/kb_search/read\n # tools over this directory of markdown notes and chooses when to use them.\n knowledge_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"knowledge\"))\n # T4 real embeddings: model id for an OpenAI-compatible `/embeddings` endpoint used for\n # kb_search / case retrieval. Blank (default) = the dependency-free lexical `hash_embed` (today's\n # behavior). Set e.g. \"nomic-embed-text\" (Ollama) or \"text-embedding-3-small\" for SEMANTIC\n # retrieval; a misconfigured/offline endpoint degrades back to hash_embed at call time (never\n # crashes a run). `embed_base_url` blank = reuse `llm_base_url`; the shared `llm_api_key` is used.\n embed_model: str | None = None\n embed_base_url: str | None = None\n # Harmonic memory (Memora, ICML'26 — idea import). When ON, cross-run cases and knowledge notes are\n # indexed by a short ABSTRACTION + cue ANCHORS instead of their raw text, near-duplicate memories are\n # CONSOLIDATED into one entry under a matching abstraction, and `kb_search`/case retrieval EXPAND\n # through the top hits' anchors to surface related-but-not-similar memories. ON by default. Set\n # `memora=false` to restore the pre-Memora raw-text index. Never a source of truth — abstractions\n # live only in the derived, rebuildable retrieval index (never the event log or canonical cases.jsonl).\n memora: bool = True\n # Write abstractions with the wired chat model (richer than the deterministic lexical fallback). ON\n # by default; results are CACHED by content hash (see `memora_cache`) so a re-built index doesn't\n # re-call the model on unchanged notes/cases, and any endpoint failure degrades to lexical at call\n # time — so an offline box just gets lexical abstractions, never a crash. Set false to force lexical.\n memora_llm: bool = True\n # === Cross-run memory / Memora ========================================================\n memora_anchors: int = 6 # max cue anchors kept per memory\n # Cosine similarity at/above which a new case is CONSOLIDATED into an existing one (same evolving\n # topic) rather than stored as a separate entry. Only used when `memora` is on.\n memora_consolidate_threshold: float = 0.86\n # Where the LLM-abstraction cache lives (JSON). Blank (default) derives it from `memory_dir`\n # (`<memory_dir>/memora_cache.json`) or else `knowledge_dir` (`<knowledge_dir>/.memora_cache.json`);\n # with neither set the cache is in-memory only (per process). No-op unless `memora_llm` is on.\n memora_cache: str | None = None\n # E3 literature-grounded ideation (network-OPTIONAL): give the agentic Researcher an arXiv search\n # tool to ground ideas in real techniques. Off by default (egress is unreliable on some boxes);\n # fails gracefully if the network is blocked.\n literature_search: bool = False\n # Deep-Research stage (network-OPTIONAL): give the DeepResearcher a general web search/fetch\n # tool (DuckDuckGo) on top of arXiv to read across results + the web before steering the next\n # batch. Off by default (egress is unreliable on some boxes); fails gracefully when blocked.\n web_search: bool = False\n # Cadence for the Deep-Research stage: run it automatically every N created nodes. Zero disables\n # only the cadence; manual `deep_research` and Strategist `request_research` triggers still fire.\n # Default 3: deep research analyzes the accumulating results and steers the next batch (its\n # directions become hints + open hypotheses); paired with `concurrent_research` it overlaps the\n # think with the GPU-bound eval, so the cadence costs LLM tokens but no wall-clock on the search.\n deep_research_every: int = 3\n # Overlap a DUE deep-research \"think\" with the GPU-bound eval instead of running it in its own\n # serial step (the agent is otherwise idle while a node trains). research() computes from a state\n # snapshot; the joined background task records only allowlisted, order-tolerant\n # research/hint/hypothesis events as soon as the memo finishes. ON by default: the LLM is typically\n # remote (no GPU contention with eval), so overlapping hides latency behind long training.\n concurrent_research: bool = True\n # Concurrent-research REPEAT — don't idle a multi-DAY eval. The overlapped \"think\" fires ONCE per\n # window by default; ON here, it RE-RUNS on an adaptive time cadence for the WHOLE eval window\n # (a two-day training must not leave the reasoning agents idle after one memo). Self-paced: it\n # only records a memo whose CONTENT is new (identical re-runs are skipped, so the log/board don't\n # bloat) and backs off geometrically as the analysis converges, capped so it always re-checks.\n # The appended records never rewrite the current metric champion, but their hints/open hypotheses\n # deliberately steer later proposals and replay reconstructs that advice. The library default is\n # one-shot (== today, byte-identical); this product default turns the repetition on.\n concurrent_research_repeat: bool = True\n # Base cadence (seconds) between repeated concurrent-research passes. Research is EXPENSIVE\n # (multi-turn LLM + web/arXiv), so this is a FLOOR, not a ceiling: the effective cadence is\n # max(this, ~5% of the per-experiment time budget) — a two-day eval is re-researched about hourly,\n # a short eval not at all (the first tick outlasts it). Only matters when the repeat is on.\n concurrent_research_interval_s: float = Field(default=1800.0, gt=0)\n # Per-eval-window backstop on repeated-research LLM calls (0 = cadence-only, no cap). The adaptive\n # cadence + convergence backoff are the primary budget control; this bounds a pathological\n # never-converging window. Past it the loop stops calling the LLM (the health monitor still runs).\n concurrent_research_max_calls: int = Field(default=40, ge=0)\n # Concurrent hypothesis-board CONSOLIDATION during the eval window. Repeated research keeps ADDING\n # near-duplicate directions to the open board (each memo registers up to 5 as open hypotheses), so a\n # long eval's board bloats before the between-nodes merge runs. This dedups/merges it on the SAME\n # background loop so the board stays clean for the next proposal. In Card-driven mode the same merge\n # changes ownership/readiness, so it is deferred to the joined main-task cadence instead. Needs the\n # reflect client + `track_hypotheses`.\n # Only active while `concurrent_research` runs the overlap loop. Library default off (== today).\n concurrent_consolidate: bool = True\n # Cadence for the agent-authored run report: regenerate the conclusion-first narrative every N\n # created nodes (0 = off; it still regenerates on a manual `report_refresh` from the UI). The\n # deterministic report always renders from the node set regardless of this knob.\n report_every: int = 3\n # Agent Skills (I18, ADR-9): dir of SKILL.md the Researcher can list/load as tools.\n skills_dir: str | None = None\n # Prompt store (I18, ADR-8): dir of editable, hot-reloaded role prompt .md files.\n prompt_dir: str | None = None\n # API key as a reference, never serialized as a value (ADR-11). Local servers\n # ignore it; default to a placeholder.\n llm_api_key: SecretStr | None = None\n\n @model_validator(mode=\"before\")\n @classmethod\n def _reject_bool_for_numeric_fields(cls, data):\n \"\"\"A JSON boolean is a TYPE ERROR for a numeric setting, not the number 1.\n\n Pydantic runs in lax mode here (`model_config` sets no `strict`), and `isinstance(True, int)`\n is True — so `{\"max_nodes\": true}` from a UI/API type slip validated cleanly as a budget of\n ONE node, and `{\"eval_parallel\": true}` collapsed the width to one. The run then looked\n configured and simply did almost nothing, with no 422 and nothing in the snapshot to show why.\n Rejecting at the boundary covers every numeric field at once, including the many that carry no\n `Field(ge=...)` bound of their own to trip over.\n \"\"\"\n if not isinstance(data, dict):\n return data\n for key, val in data.items():\n if type(val) is bool and _is_numeric_annotation(\n getattr(cls.model_fields.get(key), \"annotation\", None)):\n raise ValueError(f\"{key} must be a number, not a boolean (got {val!r})\")\n return data\n\n @model_validator(mode=\"before\")\n @classmethod\n def _apply_profile(cls, data):\n \"\"\"Expand `profile` into a bundle of setting defaults BEFORE validation.\n\n pydantic-settings hands this validator the fully-merged input from ALL explicit sources\n (init kwargs + LOOPLAB_* env + .env), with fields the user left unset simply ABSENT (they\n fall to field defaults afterward). So a profile value is applied only when its key is not\n already present — i.e. any explicit file/CLI/env setting wins over the profile, and the\n profile wins over the bare field default. This makes `thorough` a true convenience preset,\n never an override of what the user asked for.\"\"\"\n if not isinstance(data, dict):\n return data\n prof = data.get(\"profile\", \"default\")\n # env may deliver the key as \"profile\" (env_prefix stripped) — normalize to a str\n prof = str(prof).strip().lower() if prof is not None else \"default\"\n overrides = PROFILES.get(prof)\n if overrides is None:\n raise ValueError(f\"unknown profile {prof!r}; known: {sorted(PROFILES)}\")\n for key, val in overrides.items():\n if key not in data: # explicit source (file/CLI/env) always wins\n data[key] = val\n return data\n\n @model_validator(mode=\"after\")\n def _check_trust_gate(self):\n if self.trust_gate not in (\"audit\", \"gate\", \"block\"):\n raise ValueError(\n f\"trust_gate must be audit|gate|block, got {self.trust_gate!r}\")\n if self.merge_mode not in (\"auto\", \"mean\", \"ensemble\"):\n raise ValueError(\n f\"merge_mode must be auto|mean|ensemble, got {self.merge_mode!r}\")\n # novelty_mode drives the dedup/novelty gate; an out-of-set value (e.g. a mis-cased\n # LOOPLAB_NOVELTY_MODE=LLM, or \"on\") used to fall through _apply_novelty_gate silently as a\n # NO-OP — turning the gate off with no diagnostic. Fail loudly at config time like trust_gate/\n # merge_mode do. (Other enum-ish string fields — seed_mode/eval_trust_mode/strategist_backend —\n # share the pattern; left unvalidated here only because their full value sets are less settled.)\n if self.novelty_mode not in (\"off\", \"algo\", \"llm\"):\n raise ValueError(\n f\"novelty_mode must be off|algo|llm, got {self.novelty_mode!r}\")\n # The remaining enum-ish string fields (arch-review §5 P3): a typo used to be accepted at\n # construction and only fail-safe/later-loud downstream (e.g. a mis-cased strategist_backend\n # silently ran the default). Fail loudly here like the fields above.\n if self.strategist_backend not in (\"off\", \"rule\", \"llm\", \"agent\"):\n raise ValueError(\n f\"strategist_backend must be off|rule|llm|agent, got {self.strategist_backend!r}\")\n if self.eval_trust_mode not in (\"ratify_freeze\", \"autonomous\", \"ratify_freeze_drift\"):\n raise ValueError(\n \"eval_trust_mode must be ratify_freeze|autonomous|ratify_freeze_drift, \"\n f\"got {self.eval_trust_mode!r}\")\n if self.seed_mode not in (\"auto\", \"tracked\", \"all\"):\n raise ValueError(f\"seed_mode must be auto|tracked|all, got {self.seed_mode!r}\")\n if self.backend not in (\"toy\", \"llm\"):\n raise ValueError(f\"backend must be toy|llm, got {self.backend!r}\")\n self._check_llm_profiles()\n return self\n\n def _check_llm_profiles(self) -> None:\n \"\"\"Validate the connection-profile maps LOUDLY, at construction.\n\n Assignment is not validated anywhere in this project, and these two maps are the kind of\n thing a typo makes into a silent no-op: an unknown role name would simply never be read, and\n a reference to a missing profile would fall back to the shared model while the operator\n believed a second provider was in play. Both are errors here instead.\n\n The `api_key_env` rules exist for a specific reason. It must LOOK like a secret to\n `runtime/sandbox.py::SECRET_ENV`, because that is the filter that strips the operator's keys\n out of the environment handed to generated candidate code — a key parked in a variable named\n `MY_AUTH` would be invisible to it and ride straight into the sandbox. (The pattern is\n duplicated below rather than imported: layering forbids `core` from importing `runtime`.\n `tests/test_secret_env_pattern.py` pins the two together.) And a literal key inside a profile\n is refused outright: the profile map is written verbatim into config.snapshot.json and served\n over HTTP precisely because it is supposed to contain no secrets.\"\"\"\n from looplab.core.llm import LLM_ROLE_KEYS\n if not isinstance(self.llm_profiles, dict):\n raise ValueError(\"llm_profiles must be a map of profile name -> profile\")\n for name, entry in self.llm_profiles.items():\n if not isinstance(entry, dict):\n raise ValueError(f\"llm_profiles[{name!r}] must be an object\")\n # BEFORE the unknown-field check: none of these is a known field, so checking them\n # afterwards could never fire and someone who pasted a live key was told it was a typo.\n for banned in (\"api_key\", \"key\", \"token\", \"secret\"):\n if banned in entry:\n raise ValueError(\n f\"llm_profiles[{name!r}] must not contain {banned!r}: a profile stores \"\n \"api_key_env (the NAME of an environment variable), never a key value — \"\n \"the whole map is written to config.snapshot.json and served over HTTP\")\n unknown = set(entry) - _PROFILE_FIELDS\n if unknown:\n raise ValueError(\n f\"llm_profiles[{name!r}] has unknown field(s) {sorted(unknown)}; \"\n f\"known: {sorted(_PROFILE_FIELDS)}\")\n env = entry.get(\"api_key_env\")\n if env is not None and not _SECRET_ENV_NAME.match(str(env)):\n raise ValueError(\n f\"llm_profiles[{name!r}].api_key_env={env!r} is not a usable variable name: it \"\n \"must be UPPER_SNAKE and contain KEY/SECRET/TOKEN/PASSWORD/PASSWD/CREDENTIAL, so \"\n \"the sandbox's secret-name filter strips it from generated code's environment\")\n temp = entry.get(\"temperature\")\n if temp is not None and (isinstance(temp, bool) or not isinstance(temp, (int, float))):\n raise ValueError(f\"llm_profiles[{name!r}].temperature must be a number\")\n if not isinstance(self.role_profiles, dict):\n raise ValueError(\"role_profiles must be a map of role name -> profile name\")\n for role, profile in self.role_profiles.items():\n if role not in LLM_ROLE_KEYS:\n raise ValueError(\n f\"role_profiles has unknown role {role!r}; known: {sorted(LLM_ROLE_KEYS)}\")\n if profile not in self.llm_profiles:\n raise ValueError(\n f\"role_profiles[{role!r}] names profile {profile!r}, which is not in llm_profiles\")\n if self.llm_profile is not None and self.llm_profile not in self.llm_profiles:\n raise ValueError(\n f\"llm_profile={self.llm_profile!r} is not in llm_profiles\")\n\n def masked_snapshot(self) -> dict:\n # Snapshots are a JSON contract (they are written verbatim to config.snapshot.json), not a\n # Python-object dump. In particular tuple-valued schema fields such as\n # ``inline_repair_reasons`` must become JSON arrays before callers hash the runtime envelope;\n # otherwise the strict speculation-scope canonicalizer sees a different/non-JSON preimage\n # from the one that ``json.dumps`` eventually persists.\n d = self.model_dump(mode=\"json\")\n if self.llm_api_key is not None:\n d[\"llm_api_key\"] = \"***\"\n else:\n d[\"llm_api_key\"] = None\n # The snapshot's own FORMAT version — not a Settings field (it is never settable by env or\n # file, and it describes the document, not the run). `settings_from_snapshot` fails closed on\n # a version it does not understand; see CONFIG_SNAPSHOT_SCHEMA.\n d[CONFIG_SNAPSHOT_SCHEMA_KEY] = CONFIG_SNAPSHOT_SCHEMA\n return d\n\n\n# The config snapshot's document-format version, written by `masked_snapshot` and enforced by\n# `settings_from_snapshot`. Bump it when a snapshot written by this binary would be MISREAD by an\n# older one — i.e. when a new field changes paid, concurrency or selection behaviour and silently\n# defaulting it would resume the same event history under different semantics. `Settings` is\n# `extra=\"ignore\"`, so without this an older binary drops what it does not recognize and resumes\n# anyway, with no diagnostic; a version it does not know is now a loud refusal instead.\n# Version 1 is the first VERSIONED format. An absent key means a pre-versioning snapshot, which stays\n# readable through `LEGACY_CONFIG_SNAPSHOT_DEFAULTS` — that historical contract is unchanged.\nCONFIG_SNAPSHOT_SCHEMA = 1\nCONFIG_SNAPSHOT_SCHEMA_KEY = \"config_snapshot_schema\"\n\n\nclass ConfigSnapshotVersionError(ValueError):\n \"\"\"A config snapshot was written by a NEWER binary than the one reading it.\"\"\"\n\n\n# Pre-versioned snapshots are full Settings dumps, so absence is a reliable per-field version marker.\n# Keep their historical effective behavior when newer product defaults become active: re-entry must not\n# silently add paid calls, interventions, concurrency or a different selection policy to an old run.\nLEGACY_CONFIG_SNAPSHOT_DEFAULTS: dict[str, object] = {\n \"parallel_build\": 1,\n \"eval_parallel\": None,\n \"llm_parallel\": None,\n \"train_monitor\": False,\n \"train_monitor_interval_s\": 600.0,\n \"train_monitor_kill\": False,\n \"train_monitor_kill_confidence\": 0.8,\n \"asha_live\": False,\n \"asha_live_kill\": False,\n \"asha_live_quantile\": 0.5,\n \"asha_live_min_siblings\": 3,\n # Before this operator ceiling existed, the sandbox's 24-hour process limit was authoritative.\n \"max_eval_timeout\": 24 * 3600.0,\n \"watchdog_reflection\": False,\n \"card_driven_selection\": False,\n \"speculation_depth\": 0,\n \"speculation_gate_receipt\": None,\n \"concurrent_research_repeat\": False,\n \"concurrent_research_interval_s\": 1800.0,\n # Zero means unlimited; 40 was the first bounded default, and is latent while repeat is disabled.\n \"concurrent_research_max_calls\": 40,\n \"concurrent_consolidate\": False,\n # The map originally covered only the flags added in the days just before it was written\n # (the entries above are all 2026-07-19..07-21; the map itself landed 07-22). Everything below was\n # added 2026-06-24..07-17 — AFTER `config.snapshot.json` existed (it ships in the initial commit,\n # 2026-06-23) and BEFORE this map did — so a snapshot from that window carries none of these keys\n # and, without an entry here, resumed with today's paid product default ACTIVE. That is precisely\n # the \"silently add paid calls ... to an old run\" the paragraph above forbids: the resumed half of\n # the run would make predict-before-execute and cross-run LLM calls the original never made, and\n # admit candidates under a different novelty policy.\n # `setdefault` makes every one of these a no-op for a snapshot that DOES carry the key, so only\n # genuinely older runs are affected. Each value is the behaviour before the field existed: absent\n # feature = off, absent cadence = 0 (the same \"conservative library\" value the frozen\n # Settings-vs-EngineOptions divergence table records for these fields). Each was dated against the\n # tree before being listed — e.g. `report_every` (2026-06-25) is 0 because the run-report writer\n # itself did not exist before that commit, so an older run wrote no report to preserve.\n \"foresight\": False,\n \"foresight_agentic\": False,\n \"foresight_verify\": False,\n \"concept_pivot\": False,\n \"graded_novelty\": False,\n \"cross_run_concepts\": False,\n \"cross_run_advisory\": False,\n \"cross_run_structured_claims\": False,\n \"cross_run_curation\": False,\n \"cross_run_read_tools\": False,\n \"concept_run_base\": False,\n \"fingerprint_universal\": False,\n \"memora_llm\": False,\n \"comparative_lessons\": False,\n \"lessons_every\": 0,\n \"lessons_refresh_every\": 0,\n \"unified_agent\": False,\n \"deep_research_every\": 0,\n \"report_every\": 0,\n \"failure_reflection\": False,\n \"reflection_priors\": False,\n \"agent_drives_actions\": False,\n \"concurrent_research\": False,\n \"deep_repair\": False,\n # WHAT THIS MAP IS NOT. It is a hand-maintained list of FEATURE switches whose before-the-field\n # value is unambiguous, not a complete partition of `Settings`. Two classes stay out on purpose,\n # because for them a wrong entry is worse than a missing one — it would silently REMOVE behaviour\n # the original run really had:\n # * knobs that are latent while their parent feature is off — `foresight_panel`,\n # `foresight_verify_samples`, `memora_anchors`, `holdout_top_k`, `asha_eta`: the parent above\n # already pins them out of play, and their own before-the-field value is not recoverable;\n # * magnitudes and depths for behaviour that ALREADY existed — `debug_depth`,\n # `context_budget_chars`, `developer_plan_max_steps`, `agent_emit_after`: guessing 0/off there\n # disables working machinery rather than preserving history.\n # A new field belongs here only when it (a) postdates 2026-06-23, (b) defaults to adding paid\n # calls / interventions / concurrency / a different selection policy, and (c) has a historical\n # value you can point at a commit for. When (c) fails, leave it out and say so here.\n}\n\n\ndef migrate_config_snapshot(data: dict) -> dict:\n \"\"\"Return an effective copy of one legacy/current snapshot without rewriting evidence bytes.\"\"\"\n if not isinstance(data, dict):\n raise TypeError(\"config snapshot must be a JSON object\")\n migrated = dict(data)\n for key, value in LEGACY_CONFIG_SNAPSHOT_DEFAULTS.items():\n migrated.setdefault(key, value)\n return migrated\n\n\ndef config_snapshot_schema(data: dict) -> int:\n \"\"\"The snapshot's declared format version. 0 = pre-versioning (the key did not exist yet).\n\n A malformed value is treated as UNREADABLE rather than as 0: a snapshot whose own version marker\n is corrupt cannot be assumed to be the oldest format.\"\"\"\n raw = data.get(CONFIG_SNAPSHOT_SCHEMA_KEY, 0) if isinstance(data, dict) else 0\n if isinstance(raw, bool) or not isinstance(raw, int) or raw < 0:\n raise ConfigSnapshotVersionError(\n f\"config snapshot has a malformed {CONFIG_SNAPSHOT_SCHEMA_KEY}: {raw!r}\")\n return raw\n\n\ndef settings_from_snapshot(data: dict) -> Settings:\n \"\"\"Build re-entry Settings from a masked snapshot under its historical missing-field contract.\n\n Fails closed on a snapshot written by a NEWER binary. `Settings` is `extra=\"ignore\"`, so such a\n snapshot would otherwise load with every unrecognized control silently dropped — the run would\n resume on the same event history under different paid/concurrency/selection semantics, with\n nothing to show why. Refusing is the only honest answer: this binary cannot know what it is\n dropping. Older and pre-versioning snapshots keep loading exactly as before.\"\"\"\n found = config_snapshot_schema(data)\n if found > CONFIG_SNAPSHOT_SCHEMA:\n raise ConfigSnapshotVersionError(\n f\"this run's config.snapshot.json declares format v{found}, but this build understands \"\n f\"at most v{CONFIG_SNAPSHOT_SCHEMA}. It was written by a newer LoopLab; resuming here \"\n \"would silently drop settings this build does not know. Upgrade LoopLab to resume it.\")\n migrated = migrate_config_snapshot(data)\n migrated.pop(\"llm_api_key\", None)\n migrated.pop(CONFIG_SNAPSHOT_SCHEMA_KEY, None) # a document marker, never a Settings field\n return Settings(**migrated)\n"}, "aaa2cb1170e6eba534c08c0770b661c70a815227": {"t": 1785768748.8550243, "n": 113229, "h": "8db55338a396a530", "c": "\"\"\"Engine config: the schema (what settings exist). (I0, ADR-11)\n\nOne pydantic-settings class declaring every engine knob, its default, and its docs. The loader —\nhow a run file / CLI / env becomes a `Settings` — is `looplab.core.appconfig`, which also owns\nthe config-precedence order. A resolved, secret-masked snapshot is written next to each run for\nreproducibility. (No real secrets in P0, but the masking discipline is in place.)\n\"\"\"\nfrom __future__ import annotations\n\nimport re\nimport types\nimport typing\nfrom pathlib import Path\n\nfrom pydantic import Field, SecretStr, field_validator, model_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n# Single source of the LLM first-byte (response-headers) default — see core/llm.py.\nfrom looplab.core.llm import DEFAULT_HEADER_TIMEOUT_S\n\n# Default home for cross-run memory + the knowledge base (shared across every run). Both are ON by\n# default now (a user asked for it) so a fresh install accumulates + consults knowledge with no setup;\n# set the env var to \"\" to disable, or to a path to relocate. `~/.looplab/` keeps them out of any one\n# project + shared across projects.\n_LL_HOME = Path.home() / \".looplab\"\n\n# What one `llm_profiles` entry may contain. Deliberately closed: an unrecognized field is a typo the\n# operator would otherwise never see fail, and the map is a public artifact (config.snapshot.json,\n# HTTP, LOOPLAB_*), so it must never accrete free-form keys.\n_PROFILE_FIELDS = frozenset({\"model\", \"base_url\", \"temperature\", \"api_key_env\"})\n# A variable name that `runtime/sandbox.py::SECRET_ENV` will recognize as holding a secret and strip\n# from generated code's environment. Duplicated here, not imported: layering forbids `core` from\n# importing `runtime`. `tests/test_secret_env_pattern.py` holds the two in agreement.\n_SECRET_ENV_NAME = re.compile(\n r\"\\A(?=[A-Z][A-Z0-9_]{0,63}\\Z)(?=.*(KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL)).*\\Z\")\n\n\ndef _is_numeric_annotation(ann) -> bool:\n \"\"\"True when a field's annotation admits int/float but NOT bool.\n\n Used by `Settings._reject_bool_for_numeric_fields`: pydantic resolves annotations at class build,\n so `model_fields[...].annotation` is a real type object here even under\n `from __future__ import annotations`. Optionals are numeric when every non-None member is\n (`int | None` yes, `int | str` no), so a boolean can never slip in through a union arm.\"\"\"\n if ann is bool:\n return False # a bool field legitimately takes a bool\n if ann is int or ann is float:\n return True\n args = [a for a in typing.get_args(ann) if a is not type(None)]\n origin = typing.get_origin(ann)\n if args and origin in (typing.Union, types.UnionType):\n return all(_is_numeric_annotation(a) for a in args)\n return False\n\n# Autonomous roles that may be granted per-setting write access (see Settings.agent_control).\n# strategist = A7 meta-controller (run-wide); boss = run-chat operator-proxy (run-wide);\n# researcher = per-experiment proposer (per-node sizing, e.g. eval timeout for a heavy model).\nAGENT_ROLES = (\"strategist\", \"boss\", \"researcher\")\n\n# A foresight score is computed for every acted-on proposal. Keep this lower than the generic\n# verifier's emergency ceiling so a typo cannot multiply paid synchronous calls across a run.\nMAX_FORESIGHT_VERIFY_SAMPLES = 8\n\n\n# Layer-2 parallelism renamed two public settings without removing the durable legacy fields. Keep\n# the equivalence in one place: config-source precedence, governance migration, and live strategy\n# application must all agree which names describe the same authority/value.\nPARALLELISM_ALIASES: dict[str, str] = {\n \"max_parallel\": \"eval_parallel\",\n \"parallel_build\": \"llm_parallel\",\n}\n\n\ndef parallelism_aliases(setting: str) -> tuple[str, ...]:\n \"\"\"Return every spelling in ``setting``'s alias family, canonical name first.\"\"\"\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if setting in (legacy, canonical):\n return canonical, legacy\n return (setting,)\n\n\ndef canonicalize_parallelism_source(\n values: dict, *, promote_build_to_llm_parallel: bool = False,\n) -> dict:\n \"\"\"Promote legacy parallel keys *within one config-precedence source*.\n\n Applying this only after file/CLI/env were flattened would let a lower-priority canonical env\n value shadow a higher-priority legacy file value (or a file canonical value shadow a CLI legacy\n override), so callers with sub-layers normalize each layer independently.\n\n ``max_parallel``/``eval_parallel`` are true aliases (pure eval width, no extra semantics) and are\n always promoted. ``parallel_build``/``llm_parallel`` are NOT: a positive ``llm_parallel`` also flips\n on the finite shared LLM broker (``orchestrator._startup_llm_total``), while legacy ``parallel_build``\n means \"legacy build width, unbounded overlap, no shared total\". So promoting a legacy-only\n ``parallel_build`` layer (e.g. just ``LOOPLAB_PARALLEL_BUILD=1`` in env) would silently serialize\n every provider call — contradicting llm_broker.py's docstring, orchestrator's \"legacy-only preserves\n unbounded overlap\" note, the broker-neutral live-control path, and the configuration.md ``llm_parallel``\n row (\"falls back ... without enabling a shared total\"). Config/startup loads therefore keep broker\n opt-in tied to an EXPLICITLY-spelled canonical ``llm_parallel``; the orchestrator's own width fallback\n (``llm_parallel if not None else parallel_build``) still carries the legacy build width. Only the\n Strategist live-control path (``canonicalize_strategy_parallelism``) opts into\n ``promote_build_to_llm_parallel``, where ``parallel_build`` is a deliberate full alias of the\n canonical knob (see strategy.py ``_apply_strategy``).\n \"\"\"\n if not isinstance(values, dict):\n return values\n out = dict(values)\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if canonical == \"llm_parallel\" and not promote_build_to_llm_parallel:\n continue\n # None is the durable legacy mode for llm_parallel, not a missing value. A normal snapshot\n # contains llm_parallel:null plus parallel_build:1; promoting null to 1 would unexpectedly enable\n # the global one-call broker. Promote aliases only when the canonical key is absent, and pin\n # snapshot round-trip parity. Canonicalize inside each precedence layer, never across the\n # flattened result, so the alias migration cannot invert CLI > file > env precedence.\n if canonical not in out and out.get(legacy) is not None:\n out[canonical] = out[legacy]\n return out\n\n\ndef flatten_parallelism_layers(layers) -> dict:\n \"\"\"Flatten same-tier precedence layers (LOWEST priority first) and resolve the legacy pair.\n\n PRECEDENCE REPAIR for the non-promoted `parallel_build`/`llm_parallel` pair. Declining to promote\n in `canonicalize_parallelism_source` is right (a legacy-only layer must not flip on the shared\n broker), but it let a LOWER-priority canonical value survive a HIGHER-priority legacy override:\n file `llm_parallel: 2` + CLI `-s parallel_build=8` flattened to BOTH keys, and the orchestrator\n prefers `llm_parallel`, so the operator's explicit CLI width was silently discarded. When a higher\n layer names the legacy width over a lower layer's canonical one, mask with the LEGACY SENTINEL\n (`llm_parallel=None` is the durable \"legacy mode — build width from parallel_build, no shared\n total\"), so the higher layer wins with the semantics it asked for.\n\n The mask lives HERE, not in the per-layer canonicalizer, because only a flatten knows the layer\n ORDER. Stamping it per layer meant any layer naming `parallel_build` emitted `llm_parallel=None`\n even when NO layer in the tier set the canonical key — and since pydantic-settings ranks init\n kwargs above env, that sentinel then silently overrode a `LOOPLAB_LLM_PARALLEL` the operator had\n exported as a deliberate provider rate/cost ceiling. Precedence is per FIELD: a tier that never\n spelled `llm_parallel` has not set it, and it must fall through to env/.env/defaults — which is\n exactly what `build_settings`' docstring promises (\"the merged dict wins only where it actually\n sets a value\").\n \"\"\"\n merged: dict = {}\n legacy_at = canonical_at = -1\n for index, layer in enumerate(layers):\n if not isinstance(layer, dict):\n continue\n merged.update(layer)\n if layer.get(\"parallel_build\") is not None:\n legacy_at = index\n if \"llm_parallel\" in layer:\n canonical_at = index\n # Only when this tier DID set the canonical key, at a strictly lower layer than the legacy one.\n if canonical_at >= 0 and legacy_at > canonical_at:\n merged[\"llm_parallel\"] = None\n return merged\n\n# Settings whose semantics are committed by ``run_started`` and restored from the folded event log\n# on every re-entry. Changing one only in ``config.snapshot.json`` would mix incompatible evidence\n# (a different holdout split) or make the live producer disagree with replay's selection policy.\n# The HTTP config editor and the engine both consume this one contract; keep deliberately mutable\n# controls such as ``trust_gate`` out (that field has its own durable change event).\nRUN_START_PINNED_FIELDS = frozenset({\n \"card_driven_selection\",\n \"speculation_depth\",\n \"holdout_fraction\",\n \"holdout_select\",\n \"select_verifier\",\n \"select_verifier_samples\",\n \"verifier_ci_tie\",\n})\n\n\ndef run_start_pinned_settings(state) -> dict:\n \"\"\"Return the effective Settings names committed by a folded run-start record.\n\n Pre-pin legacy logs have no recorded holdout fraction, so their snapshot remains authoritative\n for both holdout knobs. Verifier fields, however, have always been re-pinned from the legacy-safe\n fold defaults whenever a valid ``run_started`` identity exists.\n \"\"\"\n if not getattr(state, \"run_id\", \"\"):\n return {}\n values = {\n # Layer 3 changes which queue owns macro-action selection. Once a run starts, replay and\n # resume must keep the same owner even if the snapshot or ambient default changes later.\n \"card_driven_selection\": bool(getattr(state, \"card_driven_selection\", False)),\n # Layer 5 overlaps Card production with evaluation only when this pinned treatment is\n # positive. Old logs omit it and therefore retain the exact serial build/eval spine.\n \"speculation_depth\": int(getattr(state, \"speculation_depth\", 0)),\n \"select_verifier\": bool(getattr(state, \"select_verifier_tiebreak\", False)),\n \"select_verifier_samples\": int(getattr(state, \"select_verifier_samples\", 3)),\n \"verifier_ci_tie\": bool(getattr(state, \"verifier_ci_tie\", False)),\n }\n holdout_fraction = getattr(state, \"holdout_fraction\", None)\n if holdout_fraction is not None:\n values.update({\n \"holdout_fraction\": holdout_fraction,\n \"holdout_select\": bool(getattr(state, \"holdout_select\", False)),\n })\n if not values.keys() <= RUN_START_PINNED_FIELDS:\n raise RuntimeError(\"folded run-start pinned settings contract drifted\")\n return values\n\n# --- Run profiles (config-first presets) -------------------------------------------------------\n# A `profile` is a NAMED BUNDLE of setting defaults — nothing more. It only fills fields the user\n# did NOT set explicitly (via file / --set / LOOPLAB_* env / init-kwargs), so any explicit knob\n# always wins (see `_apply_profile`). Most engine intelligence lives behind individual flags. The\n# PART IV/V cross-run + concept-graph machinery (concept_pivot, graded_novelty, cross_run_concepts /\n# _advisory / _structured_claims / _curation / _read_tools, fingerprint_universal, foresight_verify)\n# now ships ON in the product `Settings` default. That is an explicit experimental product choice,\n# not a claim that a frozen A/B or workload-wide cost/quality gate has cleared: the bundle can steer\n# prompts and candidate generation, change graded-novelty admission / pre-execution choices, and add\n# paid LLM work when its client and memory prerequisites are present. The bare-library `EngineOptions`\n# default stays lean (see tests/test_options_divergence.py — product side is the aggressive one) so a\n# toy `Engine(...)` in a test doesn't fire cross-run LLM work unasked. The trust/confirm/ablate QUALITY\n# bundle still ships OFF and is turned on by `thorough`. Every value here is reachable by hand — the\n# profile is a convenience, never a hidden mode.\n# keep this rationale behavioral and evidence-based; do not relabel read-only storage\n# access or proposal-only governance as a no-op for agent behavior, cost, or proposal admission.\n#\n# `thorough` deliberately touches only QUALITY/TRUST machinery, not spend: it does NOT raise\n# max_nodes / eval_parallel (those are cost knobs the user owns). It enables multi-seed confirmation\n# (demote seed-lucky leaders), the reward-hack / leakage / critic monitors AND flips them from\n# advisory to *gating* (`trust_gate=\"gate\"` — a\n# flagged win can no longer be selected as best), ablation-driven refinement, and the prompt cues\n# (complexity / budget / failure-reflection / watchdog-reflection). Agentic novelty and the experimental\n# Part IV/V bundle are already product defaults, not profile changes. Effect is large; marginal cost is a\n# few extra evals.\nPROFILES: dict[str, dict] = {\n \"default\": {}, # explicit no-op alias for \"ship-as-is\" (== omitting profile)\n \"fast\": {}, # today's lean defaults, named for symmetry with `thorough`\n \"thorough\": {\n \"confirm_top_k\": 3,\n \"confirm_seeds\": 3,\n # novelty_gate intentionally NOT enabled here: duplicate-detection is the agentic Researcher's\n # job (it reads past experiments + find_analogous_across_runs and DECIDES), not an algorithmic\n # param-distance / embedding auto-reject that mis-fired (it nudged a good fresh idea onto a\n # twice-dead lineage — live node 61). Search stays as tools; the LLM judges novelty.\n \"operator_bandit\": True, # P4: adaptive operator mix over folded yields (GreedyTree)\n \"reward_hack_detect\": True,\n \"code_leakage_detect\": True,\n \"critic_check\": True,\n \"trust_gate\": \"gate\", # detectors stop gating a flagged node from WINNING (not just audit)\n \"ablate_every\": 3,\n \"complexity_cue\": True,\n \"budget_aware\": True,\n \"failure_reflection\": True,\n \"watchdog_reflection\": True, # feed recent live-watchdog (train-monitor/ASHA) flags to proposals\n \"reflection_priors\": True, # no-op unless memory_dir is set (cross-run priors)\n },\n}\n\n\n# The shipped agent-governance matrix (who may change which setting at runtime). Defined as a module\n# constant so the Engine's direct-construction default matches this shipped default exactly (the\n# EngineOptions \"Engine() == shipped defaults\" invariant) instead of diverging to an all-locked map.\nDEFAULT_AGENT_CONTROL: dict[str, list[str]] = {\n \"timeout\": [\"researcher\", \"strategist\"],\n # Layer-2 canonical names are the presented/default governance surface. `_agent_may` falls back\n # to an old snapshot's legacy grant only when its canonical counterpart is absent, so removing\n # duplicate defaults preserves resume authority while an explicit canonical revocation still wins.\n \"eval_parallel\": [\"strategist\"],\n \"llm_parallel\": [\"strategist\"],\n # Strategy-only pseudo-setting: the closed per-lane allocation inside the canonical LLM total.\n # It is not a launch Settings field; operators pin it through `set_strategy` and the Strategist\n # may reallocate it only while this grant is present.\n \"llm_lane_limits\": [\"strategist\"],\n \"max_nodes\": [\"strategist\", \"boss\"],\n \"max_eval_seconds\": [\"strategist\", \"boss\"],\n \"policy\": [\"strategist\"],\n # The policy NAME and its PARAMS are gated independently in `_apply_strategy`; grant both by default\n # so the Strategist that may switch to (say) MCTS may also apply the `c`/`eta` it decided — else its\n # tuned params are silently dropped and the recorded `active_strategy` diverges from the live engine.\n \"policy_params\": [\"strategist\"],\n \"n_seeds\": [\"strategist\"],\n \"ablate_every\": [\"strategist\"],\n \"merge_mode\": [\"strategist\"],\n \"complexity_cue\": [\"strategist\"],\n \"ablate_code_blocks\": [\"strategist\"],\n \"prefer_sweep\": [\"strategist\"],\n \"novelty_stance\": [\"strategist\"],\n \"developer\": [\"strategist\"],\n \"fidelity\": [\"strategist\", \"researcher\"],\n}\n\n\ndef default_agent_control() -> dict[str, list[str]]:\n \"\"\"A FRESH deep-ish copy of the shipped governance matrix (each role list copied so a caller can\n mutate its own map without touching the module constant). The SINGLE constructor of the default,\n called by both `Settings.agent_control`'s default_factory AND the Engine's direct-construction\n default — so \"Engine() == shipped defaults\" can't drift if the copy depth ever needs to change\n (e.g. nested values requiring copy.deepcopy), instead of two verbatim copy expressions.\"\"\"\n return {k: list(v) for k, v in DEFAULT_AGENT_CONTROL.items()}\n\n\nclass Settings(BaseSettings):\n \"\"\"The engine settings schema (every knob a run accepts).\n\n Timeout family — six distinct knobs, each owned by a different subsystem:\n - `timeout`: per-eval wall-clock budget for ONE experiment's evaluation (engine/eval).\n - `max_eval_timeout`: hard ceiling for a governed per-node Researcher timeout override.\n - `llm_timeout`: LLM request idle timeout — inter-token stall limit in stream mode\n (core.llm OpenAICompatibleClient).\n - `llm_header_timeout`: LLM first-byte (response-headers) window for stream attempts (core.llm).\n - `agent_time_budget_s`: wall-clock ceiling across a tool-using agent loop's turns (agents).\n - `dep_install_timeout`: per-package budget for auto-installing a missing dep (env self-prep).\n\n Config-source precedence is owned by `looplab.core.appconfig` (the loader) — see its module\n docstring for the one canonical order.\n \"\"\"\n # Config precedence: see appconfig.py — the loader owns the full order. The `.env` is read\n # so `looplab run`/`looplab ui` pick up LOOPLAB_LLM_BASE_URL etc. without exporting them by hand\n # (keys MUST carry the LOOPLAB_ prefix, same as the env vars). utf-8-sig tolerates a BOM from\n # Windows editors. The test suite disables this (tests/conftest.py) so a dev's real .env in the\n # repo root can't leak into assertions.\n model_config = SettingsConfigDict(\n env_prefix=\"LOOPLAB_\",\n env_file=\".env\",\n env_file_encoding=\"utf-8-sig\",\n extra=\"ignore\",\n allow_inf_nan=False,\n )\n\n @classmethod\n def settings_customise_sources(\n cls, settings_cls, init_settings, env_settings, dotenv_settings,\n file_secret_settings):\n \"\"\"Preserve source precedence across canonical/legacy parallelism names.\n\n Pydantic ranks init > env > dotenv correctly, but it ranks field names rather than semantic\n alias families. Promoting a legacy value inside each source before the merge makes an old\n explicit snapshot/file value beat a lower-priority canonical env value. ``appconfig`` also\n promotes its file/typed/--set sub-layers before combining them into the init source.\n \"\"\"\n def _with_parallel_aliases(source):\n return lambda: canonicalize_parallelism_source(source())\n\n return (\n _with_parallel_aliases(init_settings),\n _with_parallel_aliases(env_settings),\n _with_parallel_aliases(dotenv_settings),\n _with_parallel_aliases(file_secret_settings),\n )\n\n # Run profile (config-first preset, see PROFILES above). \"default\"/\"fast\" = today's lean\n # defaults; \"thorough\" turns the built quality/trust machinery ON in one word. A profile only\n # fills fields the user did NOT set — every knob it touches stays individually overridable.\n profile: str = \"default\"\n\n # Bounds (review C/⚪): a non-positive node/seed budget or timeout is never valid. Upper bounds are\n # equally load-bearing — the UI /start path writes these straight into the engine's env, and an\n # unbounded `eval_parallel` (or legacy `max_parallel`; e.g. 100000 from a crafted preflight) would\n # fan out that many sandboxes → resource exhaustion. Reject at config time, not mid-run. Ceilings\n # are generous; tests top out near 50.\n # Pydantic's lax mode coerces a JSON boolean into any numeric field (`true` -> 1), so a UI/API type\n # error used to silently collapse a run budget to ONE node instead of returning 422. Rejected for\n # EVERY numeric field by `_reject_bool_for_numeric_fields` below, not just the ones bounded here.\n n_seeds: int = Field(default=3, ge=1, le=1024)\n max_nodes: int = Field(default=8, ge=1, le=1_000_000)\n # legacy evaluation-width input. `eval_parallel` below is canonical and wins when set;\n # keep this field for old env/config/snapshots and direct Engine call sites. A width > 1 admits\n # concurrent evals, whose resource reservations may be CPU-only or span one/more GPUs — there is no\n # one-node/one-GPU promise. `max_parallel=0` is launch-time AUTO (detected GPU count, at least 1).\n max_parallel: int = Field(default=1, ge=0, le=1024)\n # legacy build-width input. `llm_parallel` below is canonical and also controls the\n # shared provider-call total when positive. A width > 1 uses fresh pooled researcher/developer pairs;\n # it says nothing about the later eval's CPU/GPU reservation. Startup `0` derives build fan-out from\n # resolved eval width; a live Strategist/operator zero settles to 1. Without role_factory it clamps to 1.\n parallel_build: int = Field(default=1, ge=0, le=64)\n # Hypothesis-card Kanban re-architecture (docs/23, Layer 2). Two INDEPENDENT concurrency axes,\n # decoupled from each other so cost (LLM work) can be tuned separately from throughput (GPU evals).\n # These are the CANONICAL names going forward; `max_parallel`/`parallel_build` above are kept as thin\n # back-compat aliases (env vars / snapshots / ~100 Engine(...) call sites depend on the exact names,\n # so they cannot be renamed — CLAUDE.md). Resolution (orchestrator.__init__): a NEW name, when set,\n # WINS over its legacy alias; None => fall back to the legacy field => byte-identical to today.\n # `eval_parallel`: concurrent EVALS (the GPU/experiment consumer). None -> max_parallel; 0 = AUTO\n # (one experiment per detected GPU). This is the in-Run eval-concurrency ceiling; the resource\n # scheduler bin-packs declared footprints separately. Sibling local GPU-owning Runs serialize\n # through the conservative host-pool lease; external execution needs its own admission boundary.\n # `llm_parallel`: total concurrent LLM provider calls + BUILD fan-out. None -> legacy\n # parallel_build semantics without a shared total; 0 = startup AUTO (= resolved eval_parallel\n # for build fan-out, historical research overlap unbounded). A positive canonical value enables\n # the named-lane broker the Strategist can reallocate at cadence.\n eval_parallel: int | None = Field(default=None, ge=0, le=1024)\n llm_parallel: int | None = Field(default=None, ge=0, le=64)\n # Training-log monitor (I-series watchdog family): a per-eval background observer that tails the live\n # training log while a (often multi-hour) declared stage runs in a worker thread. ON in the product\n # surface (Settings). Its alert event is fold-ignored and cannot directly change lifecycle/champion;\n # with watchdog_reflection on, that alert is nevertheless advice in a later Researcher\n # prompt and can change future proposals. It only fires on the command-eval path with an LLM client;\n # bare-library EngineOptions stays OFF. See test_options_divergence.\n # `_interval_s` is the BASE tick cadence (the effective one adapts to the per-experiment budget).\n train_monitor: bool = True\n train_monitor_interval_s: float = Field(default=600.0, gt=0)\n # Phase 3 — INTERVENTION (opt-in, separate from observation): let the monitor tree-kill a training the\n # LLM judges 'broken' (diverged / silent CPU fallback / not learning) EARLY, instead of burning the\n # whole budget. Off by default — the observer only WATCHES unless this is on. A kill only fires on a\n # 'broken' verdict (a plateau is 'watch', never killed) with confidence >= the threshold; the node then\n # fails normally (reason='monitor_broken'), so replay reconstructs it from that one terminal event.\n train_monitor_kill: bool = False\n train_monitor_kill_confidence: float = Field(default=0.8, ge=0.0, le=1.0)\n # ASHA live-curve watchdog (sibling of the training monitor): reads the latest INTERMEDIATE value of\n # the objective metric off the live log (reusing the eval's OWN metric reader). Finished-endpoint rank\n # remains advisory. An opt-in KILL additionally requires an operator-declared metric.resource_key and\n # enough sibling observations at exactly that resource; absent comparable evidence cannot stop a run.\n # Records a fold-ignored `asha_rank` diagnostic + trace span; the library default is OFF (off == today).\n asha_live: bool = True\n # Opt-in INTERVENTION (separate from the advisory signal): let the watchdog tree-kill a node whose\n # intermediate metric stays below the SAME-RESOURCE bar past a short grace window. Off by default —\n # it only surfaces endpoint rank unless this is on. A kill fails normally (asha_underperforming).\n asha_live_kill: bool = False\n # The bar sits at `asha_live_quantile` along a WORST->BEST ordering of the finished siblings' finals:\n # 0.5 = the median (a node worse than the median peer flags); a SMALLER value is more conservative —\n # it lowers the bar toward the WORST finished peer, so only a node worse than nearly all peers flags\n # (0.0 = worse than the worst). `min_siblings` is the floor of finished peers required before it ranks\n # at all, so it never acts on too little evidence.\n asha_live_quantile: float = Field(default=0.5, ge=0.0, le=1.0)\n asha_live_min_siblings: int = Field(default=3, ge=1)\n timeout: float = Field(default=30.0, gt=0)\n # Hard ceiling for the Researcher's governed per-node `Idea.eval_timeout`. One hour preserves the\n # existing heavy/neural-net authoring example while staying well below the sandbox's defensive\n # 24-hour subprocess ceiling. This is operator-owned run config: agents may request less or more,\n # but the accepted action is always clamped here after the `agent_control.timeout` permission gate.\n max_eval_timeout: float = Field(default=3600.0, gt=0, le=24 * 3600.0)\n # Intra-node sweep: a sweep node runs a whole grid in one process, so it gets this multiple of\n # `timeout` as its wall-clock budget (solution.py path; RepoTasks use their per-profile timeout).\n sweep_timeout_mult: float = Field(default=8.0, ge=1)\n # Eval STALL watchdog CAP (seconds): a stage that is completely SILENT on stdout/stderr for this\n # long — while still alive and BELOW its wall-clock deadline — is tree-killed early with a STALLED\n # marker (a hung dataloader/deadlock dies in minutes instead of burning a multi-hour timeout). The\n # per-stage window is `min(this, the stage's own timeout)`, so a short scorer's deadline fires first.\n # `0` DISABLES the watchdog entirely (only the hard deadline applies) — for a legitimately quiet\n # non-Python stage (block-buffered stdout, a script that logs only to its own file). Threaded into\n # the eval and surfaced to the Developer so its code can emit periodic progress to stay alive.\n eval_stall_timeout_s: float = Field(default=1800.0, ge=0)\n # Sandbox tier (ADR-13): \"trusted_local\" (subprocess, no Docker) for the CLI;\n # \"untrusted\" (Docker --network none, shared-kernel runtime) for hosted/multi-tenant UI;\n # \"hostile\" (untrusted + a true-isolation OCI runtime, gVisor `runsc` by default / Kata) for\n # running untrusted code. See runtime/sandbox.py::make_sandbox for the tier selection.\n trust_mode: str = \"trusted_local\"\n # Image for the untrusted command-eval tier (RepoTask, Phase 4): the framework's deps\n # should be baked in (the container runs --network none, so a pip setup can't fetch).\n docker_image: str = \"python:3.12-slim\"\n # Resource caps for the untrusted/hostile Docker tier (passed to `docker run --memory/--cpus`).\n # The default 4g bound protects other tenants from an OOM, but a model-training MLE-bench eval\n # routinely needs more — raise it (e.g. \"16g\") for such runs. \"\" disables the cap (unbounded).\n # Ignored by the trusted_local (subprocess) tier.\n sandbox_memory: str = \"4g\"\n sandbox_cpus: str = \"\"\n # Best-effort host-OOM guard for the TRUSTED-LOCAL (subprocess) tier. When set (e.g. \"8g\"), each\n # eval child gets an RLIMIT_AS address-space cap so a runaway trainer hits MemoryError instead of\n # OOM-killing the whole host (and the engine). POSIX only; a no-op on Windows. Default \"\" = OFF:\n # RLIMIT_AS bounds VIRTUAL memory, and CUDA/torch reserve tens of GB of virtual without using it,\n # so a default cap would spuriously kill GPU evals — leave it off for those and use the Docker\n # tier's real --memory cgroup bound (sandbox_memory) instead. Ideal for CPU/numpy/sklearn runs.\n sandbox_memory_local: str = \"\"\n # Best-effort disk-fill guard for the TRUSTED-LOCAL tier (P1-5): an RLIMIT_FSIZE cap on the size of\n # any single file an eval child writes (e.g. \"2g\"), so a runaway that writes an unbounded file gets\n # SIGXFSZ instead of filling the host disk. POSIX only. Default \"\" = OFF (large model checkpoints\n # need big files); set it for tasks that write only small artifacts.\n sandbox_fsize_local: str = \"\"\n # Search policy (ADR-2): \"greedy\" | \"evolutionary\" | \"mcts\" | \"asha\".\n policy: str = \"greedy\"\n # Ablation-driven refinement (I7): every N improves, ablate the best to find the\n # highest-impact parameter and refine it. 0 = off. (GreedyTree only.)\n ablate_every: int = 0\n # A0a (MLE-STAR): when ablating, treat each generated pipeline *code block* (not just a\n # numeric param) as an ablation unit — neutralize a block, measure the metric delta, refine\n # the highest-impact block. Off => the classic numeric-param ablation. Config-first knob.\n ablate_code_blocks: bool = False\n # A0b: real merge/ensembling. \"mean\" = legacy mean-param merge; \"ensemble\" = the Developer\n # writes a code-recombination ensemble over the two parents' solutions (verified: agent-proposed\n # ensembling 37.9%->43.9%). \"auto\" (default) = ensemble whenever the Developer actually\n # generates code (LLM/agent backends; declared via `is_code_generating`), mean for the\n # templated/toy developers where a code ensemble is meaningless — the evidence says code\n # recombination is the strongest single merge (removing it costs ~9 pp), so it is the default\n # wherever it can work. Falls back to mean when the Developer can't ensemble.\n merge_mode: str = \"auto\"\n # A0d (AIRA): inject a dynamic complexity hint into the draft/improve prompt keyed on the\n # node's child count (few children -> keep minimal; many -> escalate to ensembling/HPO).\n complexity_cue: bool = False\n # I1 (CAAFE-style) LLM feature-engineering: instruct the proposer/developer to add\n # semantically-meaningful engineered features (ratios, interactions, aggregations) as code. The\n # existing cross-validation eval + best-selection is the CV GATE — a feature set that doesn't\n # improve CV is never selected (feature engineering is non-universal, so this gate is mandatory).\n # Tabular tasks (those exposing column data). Off by default.\n feature_engineering: bool = False\n # A5: surface the REMAINING eval-compute budget into the proposal prompt so the Researcher can\n # reason over how much compute is left (explore broad while flush, exploit/cheapen when low).\n # No-op unless a max_eval_seconds budget is set.\n budget_aware: bool = False\n # A4 (LATS-style): feed a summary of the most recent FAILED branches (operator + error reason)\n # back into the proposal prompt so the proposer reflects on and avoids repeating them. ON by\n # default: it is SELECTIVE by construction (injects only when recent failures exist — the\n # evidenced form of reflection; reflecting at every step shows no gains, arXiv:2506.12928).\n failure_reflection: bool = True\n # Feed the LIVE-WATCHDOG observations (train-monitor health verdicts + ASHA intermediate-rank flags)\n # of recent experiments back into the proposal prompt, so the Researcher avoids re-proposing a config\n # whose TRAINING was already observed to be weak — even when the watchdog kills are off (default) and\n # the node ran to completion (those diagnostics are fold-ignored, so failure_reflection never sees\n # them). ON by default and SELECTIVE like failure_reflection (injects only when a recent flag exists).\n watchdog_reflection: bool = True\n # C3 deep test-driven repair: hand the Developer the failure taxonomy + a structured \"reproduce\n # then fix\" directive on debug, not just the raw stderr tail. ON by default (product surface); the\n # conservative library default in EngineOptions stays off, per that module's contract.\n deep_repair: bool = True\n # === Inline crash repair ==============================================================\n # Hybrid in-node crash repair: when an LLM-generated node CRASHES at runtime (mechanical errors\n # — bad import, removed kwarg, typo), the agent triages it and may repair the code IN PLACE within\n # the same eval (cheap; does NOT consume max_nodes and does NOT add a node to the search tree).\n # Semantic failures still flow to a new idea/debug node via the policy. ON by default; set False\n # to restore the prior behavior (every crash waits for a budgeted inter-node debug node).\n inline_repair: bool = True\n # Max in-place repair attempts per node before it fails normally. Default 0 = UNLIMITED: the same\n # agent keeps fixing + re-evaluating IN THE SAME node until it produces a clean metric — no new\n # node just to retry. Runaway is prevented not by a count but by the anti-stuck guard\n # (`inline_repair_stuck_repeat`): when the SAME error signature repeats with no progress, or the\n # agent's crash-triage says \"abandon\", the node fails. Set a positive N to cap the retries instead.\n inline_repair_attempts: int = Field(default=0, ge=0)\n # Anti-stuck: abandon in-node repair when the SAME error recurs this many times in a row (no\n # progress). Keeps \"unlimited\" repair from looping forever on an unfixable error.\n inline_repair_stuck_repeat: int = Field(default=4, ge=2)\n # Which failure reasons (_failure_reason: crash|timeout|oom|setup|no_metric|drift) are eligible for\n # inline repair. Default: mechanical crashes, timeouts AND OOM-kills — a timeout/OOM means the code\n # was too slow / too memory-hungry for the budget (or the pod's cgroup limit), not that the idea is\n # wrong, so the agent repairs it by reducing compute/memory (fewer estimators/epochs/folds/seeds, a\n # lighter model, a smaller batch, subsampling) instead of letting the node die unrecovered. Env\n # override expects a JSON array.\n inline_repair_reasons: tuple[str, ...] = (\"crash\", \"timeout\", \"oom\")\n # Cost ceiling for in-node repair of a MULTI-STAGE eval: the max number of FULL pipeline re-runs\n # (i.e. re-trains from the first stage) the inline-repair loop may do before abandoning to the\n # inter-node debug operator. A late-stage failure (a broken `score`/eval script that didn't touch\n # `train`) REUSES the completed train checkpoint and re-runs only from the failed stage — that is\n # CHEAP and NOT counted here; only a repair that changes an EARLIER stage's code (train.py/loss.py/…)\n # forces a full re-train, which each costs the whole training time. Without this bound, a repair that\n # keeps changing training code could burn many full trains (the anti-stuck guard is error-signature\n # based, not cost based). 0 = unlimited (legacy behavior). Single-command evals ignore it.\n inline_repair_retrain_cap: int = Field(default=2, ge=0)\n # Environment self-prep: when a solution crashes purely because a KNOWN library isn't installed\n # (ModuleNotFoundError), the engine pip-installs it into the eval interpreter and re-runs — so a\n # torch/XGBoost/CatBoost idea (e.g. a GRU model) actually runs on a fresh box instead of being\n # thrown away as `idea_rejected`. Trusted_local tier only (the untrusted/hostile Docker tiers run\n # --network none). A typo'd or local helper module is NOT installed (that's a code bug to repair).\n auto_install_deps: bool = True\n # Per-package wall-clock budget for an auto-install (seconds). Generous: large wheels like torch\n # can take minutes to download/build.\n dep_install_timeout: float = Field(default=900.0, gt=0)\n # --- Agent governance: who may change a setting at runtime (per-setting allow-list of roles) ---\n # Roles: \"strategist\" (A7 meta-controller; run-wide), \"boss\" (run-chat operator-proxy; run-wide),\n # \"researcher\" (per-experiment proposer; PER-NODE — e.g. set a longer eval timeout for a neural-net\n # idea). A setting ABSENT from this map is LOCKED — no agent may change it, only a human via the\n # snapshot/UI. The sole conditional exception is Strategy-only `card_scoring`: Card mode grants\n # it implicitly without changing the default flag-off snapshot, while an explicit [] revokes it.\n # The gate is ENFORCED at every AGENT seam via `_agent_may` — the strategist's whole\n # control surface (policy/operators/novelty_stance/prefer_sweep/developer/fidelity/timeout/\n # eval_parallel/llm_parallel) is checked in `_apply_strategy`, so removing a role from a knob locks it,\n # not just greys out the UI pill. The boss/max_* grants document boss-relevant knobs, but a\n # `budget_extend` is a HUMAN control intent (the boss action-builder can only emit `add_nodes`), so\n # it is applied as-is — the human always wins via the UI/snapshot. Defaults are conservative: resource/\n # search-shape knobs the agents already reason about; provider infra (llm_model/base_url/api_key,\n # docker_image) stays locked. The UI shows S/B/R pills per setting. `timeout` granted to the\n # researcher IS the \"auto\" per-node mode — the researcher sizes each experiment's wall-clock\n # (Idea.eval_timeout); the config `timeout` is the fallback for nodes it doesn't size. Env override\n # expects a JSON object. (`fidelity`/`novelty_stance`/`prefer_sweep`/`developer` are governance keys\n # for the strategist's per-node dials — not 1:1 Settings fields, but gated the same way.) The default\n # lives in the module-level DEFAULT_AGENT_CONTROL so the Engine's direct-construction default matches.\n agent_control: dict[str, list[str]] = Field(default_factory=default_agent_control)\n # C1 (Agentless localization): for repo tasks, rank the source files most relevant to the most\n # recent failure (traceback + identifiers) and surface them in the proposal/repair prompt so the\n # Developer edits the right place. Off by default; repo tasks only.\n localize_faults: bool = False\n # A2 surrogate-guided proposal (BO-lite): fit a k-NN surrogate over (params->metric) and propose\n # by acquisition instead of random/hill-climb. Numeric-bounds tasks only; bootstraps via the\n # base Researcher. Off by default. `surrogate_explore` = UCB-style exploration weight.\n surrogate_proposer: bool = False\n surrogate_explore: float = 0.1\n # E2 researcher panel: generate K candidate ideas per proposal and keep the one ranked best by a\n # cheap empirical surrogate over the (params->metric) history (NOT an LLM-judge). 1 = off.\n researcher_panel: int = 1\n # A1 ASHA / successive-halving: reduction factor (keep top 1/eta per rung) and rung budget.\n asha_eta: int = Field(default=3, ge=2)\n # Rung-0 width for ASHA/BOHB (the wide base of cheap drafts). 0 = use n_seeds (default,\n # preserving prior behavior); set >0 to seed a wider/narrower base independent of n_seeds.\n asha_rung_nodes: int = Field(default=0, ge=0)\n # A6 proxy/predictive scoring: cheaply rank a candidate's potential from early-stage signals\n # and skip a full eval for the doomed bottom fraction. 0.0 = off (no candidate skipped).\n proxy_scoring: bool = False\n proxy_kill_fraction: float = Field(default=0.0, ge=0.0, le=0.9)\n # === Novelty / dedup ==================================================================\n # E1 novelty/dedup gate: reject near-duplicate proposals (normalized param-space distance below\n # `novelty_epsilon`) by deterministically nudging them off the duplicate — stops the search\n # wasting evals re-trying the same idea. Audit event `novelty_rejected`. Off by default.\n # Novelty/dedup MODE selector: how a fresh proposal is checked against already-tried experiments.\n # \"off\" — no explicit gate; the agentic Researcher's own read-the-history judgment is trusted.\n # \"algo\" — the deterministic gate: numeric param-distance (`novelty_epsilon`) + optional embedding\n # similarity (`novelty_semantic`). Cheap, no LLM call, but can't explain itself.\n # \"llm\" — an LLM adjudicates (reads the real experiments via tools) whether the idea is a\n # near-duplicate and, if so, asks the Researcher once more for something different. One\n # extra LLM call per proposal — highest quality, follows the \"let the LLM decide\" line.\n novelty_mode: str = \"llm\"\n # Legacy sub-toggles, honored by the \"algo\" mode (and back-compat: novelty_gate=True forces \"algo\"):\n novelty_gate: bool = False\n novelty_epsilon: float = 0.05\n # T5 semantic novelty (active whenever the deterministic gate runs — novelty_mode=algo,\n # novelty_gate=true which is the legacy alias for algo, OR the Strategist novelty stance is\n # \"explore\"; a no-op only under novelty_mode=llm/off with a non-explore stance): reject a proposal\n # whose idea TEXT embeds within\n # `novelty_semantic_threshold` cosine of an existing node's. OFF BY DEFAULT: novelty is the agentic\n # Researcher's call, made by READING the actual prior experiments (read_experiment /\n # find_analogous_across_runs / list_experiments), NOT an embedding-similarity auto-reject that can't\n # explain itself and mis-fires on paraphrases. Semantic/param search stays available as TOOLS in the\n # Researcher's context; the LLM decides whether an idea is a true duplicate.\n novelty_semantic: bool = False\n novelty_semantic_threshold: float = Field(default=0.92, ge=0.5, le=1.0)\n # T10: debug-lineage depth bound for every policy — how many error-feedback repairs a failing\n # lineage gets before it is abandoned. The old default (1) abandoned lineages after ONE repair;\n # multi-turn/deeper debugging is a verified lever (AIRA2 ReAct debug +5.5 percentile points).\n debug_depth: int = Field(default=2, ge=1)\n # P4 operator bandit (GreedyTree): replace the fixed merge/ablate cadences with a\n # deterministic UCB over per-operator yield (Δmetric per eval-second, folded from the run).\n # Off by default (the cadences are well-tested and the bandit has no direct published\n # ablation); `thorough` turns it on.\n operator_bandit: bool = False\n # P1 hypothesis ledger: ask the Researcher to state the one-line hypothesis each experiment tests,\n # register deep-research directions as hypotheses, and track them to a verdict on the board. ON by\n # default. The board is shown to the Researcher and can be foresight-ordered, so it can\n # steer later proposals even though it never directly re-ranks already-evaluated nodes. Set False\n # to drop the prompt nudge + registration.\n track_hypotheses: bool = True\n # E4/M2/M3 reflection-memory -> priors (gradient-free cross-run meta-learning): at run end distill\n # the winner into `<memory_dir>/meta_notes.jsonl` AND structured lessons (incl. NEGATIVE results:\n # tested/abandoned hypotheses + failure themes) into `lessons.jsonl` with a task fingerprint; at run\n # start, inject exact-task notes + fingerprint-matched lessons from SIMILAR tasks into the proposal\n # prompt. ON by default — but a NO-OP until `memory_dir` is set (that's where cross-run memory lives).\n reflection_priors: bool = True\n # M6 comparative lessons (MARS \"comparative reflective memory\", doc 13 §7 item 2): distill\n # credit-assigned lessons from PAIRS of solutions — which SPECIFIC change made a child beat\n # (or regress from) its parent, and what fixed a failure — on top of the one-shot run-end\n # reflection. At most one extra LLM call per distillation (all pairs batched); offline a\n # deterministic param-diff credit stands in. Gated on reflection_priors + memory_dir like the\n # rest of cross-run memory — and since memory_dir has a default (~/.looplab/memory), this is\n # ACTIVE out of the box; clear memory_dir to disable cross-run memory wholesale.\n comparative_lessons: bool = True\n # M6 mid-run cadences (doc 13 §7 item 5 — the AgentRxiv live-share pattern): every N created\n # nodes, WRITE comparative lessons to the shared cross-run store during the run (lessons_every\n # — one batched LLM call per firing) and RE-READ the store so lessons distilled by CONCURRENT\n # runs reach this run's proposals (lessons_refresh_every; file re-read, no LLM call, skipped\n # when the store is unchanged). ON by default (every 4 nodes) and, like comparative_lessons\n # above, live out of the box because memory_dir defaults on; 0 = off — run-end write /\n # run-start read only, the pre-M6 behavior.\n lessons_every: int = Field(default=4, ge=0)\n lessons_refresh_every: int = Field(default=4, ge=0)\n # B3 output redaction: mask credentials (known key shapes + high-entropy tokens) in the\n # stdout/stderr tail before it is persisted to the event log / spans / UI — a leaked secret in a\n # print()/traceback must not land in the durable log. Off by default; recommend on for untrusted.\n redact_output: bool = False\n # B5 reward-hacking detector: a host-side monitor that flags suspicious wins (grader/answer-key\n # access, runtime writes to frozen files, suspiciously-perfect metrics) as a `reward_hack_suspected`\n # audit event in the Trust panel. Off by default. Whether a flag CHANGES selection is governed by\n # `trust_gate` below (default: audit-only, never changes selection).\n reward_hack_detect: bool = False\n # Trust enforcement (T2): what a reward-hack / data-leakage flag actually DOES to selection.\n # \"audit\" (default) = surface it in the Trust panel, never change selection (today's behavior);\n # \"gate\" = a flagged node can no longer be selected as BEST **and isn't bred/confirmed from**\n # (breed_excluded — see replay._apply_trust_gate / models.breedable_nodes), but stays\n # FEASIBLE so it still counts for diversity/audit — closes the \"a hacked/leaky node can\n # win OR seed its lineage\" hole;\n # \"block\" = additionally mark it fully INFEASIBLE (removed from feasible_nodes entirely).\n # Deliberately gates only high-precision CHEATING/LEAKAGE signals. Broad `critic:*` findings stay\n # advisory; `critic:hardcoded_metric` is the one narrow high-precision critic exception\n # and gates because it requires a literal metric with no computed assignment anywhere.\n trust_gate: str = \"audit\"\n # I3 data-centric: static code-leakage scan of each evaluated solution (fit-before-split,\n # fit-on-test) surfaced into the Trust panel alongside reward-hack flags. Off by default.\n code_leakage_detect: bool = False\n # 4.4 sandbox instrumentation (needs reward_hack_detect): after each eval, flag a RUNTIME write\n # to a protected/frozen file (an answer key or grader tampered mid-run) — behavioral evidence a\n # static code scan misses. ON by default; only fires when the reward-hack detector is on.\n workdir_audit: bool = True\n # D8 decoupled research Verifier: check every Deep-Research memo's CLAIMS against their cited\n # evidence (node ids / urls) before recording — synthesis is the documented weak link (Kosmos\n # 57.9% accurate). Deterministic layer always; an LLM rubric pass when the DeepResearcher has a\n # client. The verdict cannot change this run's node/champion, but finalize uses aligned supported\n # verdicts as the evidence gate for persisted D8 cross-run claims; unavailable/misaligned stays\n # unverified. ON by default (no-op with no memo/claims).\n research_verify: bool = True\n # C4 independent critic: an execution-free critic of each solution (stub / hardcoded-metric /\n # params-ignored; on host-graded tasks the metric checks become a submission-output check)\n # surfaced in the Trust panel. Broad findings are advisory; `critic:hardcoded_metric` can gate under\n # trust_gate=gate/block. Off by default.\n critic_check: bool = False\n # A7 Strategist (NEW, user-requested): optional LLM/rule meta-controller that picks the search\n # policy/allocator + operator mix + fidelity (+ Developer backend) per situation. Config-first:\n # every choice the Strategist makes is also a direct knob. Defaults to \"agent\" so the agent adapts\n # the search policy/operators/fidelity per situation; set \"off\" for static config-driven search.\n # \"agent\" = a tool-using Strategist that READS the run/data/sibling-runs/KB/memory (B1-guarded)\n # before deciding, instead of the single-shot \"llm\" call over aggregate stats.\n strategist_backend: str = \"agent\" # \"off\" | \"rule\" | \"llm\" | \"agent\" — default AGENTIC: the\n # Strategist READS the run/data/sibling-runs/KB/memory with tools before deciding, not a single-shot\n # call over aggregate stats. \"llm\" = the old non-agentic single-shot; \"rule\"/\"off\" = no LLM.\n strategist_every: int = Field(default=3, ge=1) # consult cadence (created nodes)\n # PART V (F1): concept CLASSIFIER re-tag + consolidation cadence, DECOUPLED from strategist_every. The\n # LLM concept map is heavier and slower-moving than a strategy consult, so it refreshes on its own\n # (sparser) interval. Researcher-authored idea.concepts still fold immediately at node_created — this\n # only paces the classifier-evidence + consolidation refresh (and the concept-coverage pivot snapshot).\n concept_retag_every: int = Field(default=30, ge=1)\n # === Confirmation & holdout ===========================================================\n # Multi-seed confirmation (I12, ADR-15): confirm the top-k under N seeds before\n # finishing. 0 disables (default). Only meaningful when eval has variance.\n confirm_top_k: int = 0\n confirm_seeds: int = 0\n # D1 seed-holdout discipline: the FIRST seed the confirm phase uses. Search evals run with the\n # implicit seed 0 (LOOPLAB_EVAL_SEED unset -> \"0\"), so a base of 1 keeps every confirm split\n # DISJOINT from anything the search optimized against — the confirm metric is then a\n # generalization signal, not a re-measurement (AIRA: selecting on a signal the search saw\n # overfits by 9-13 pp). Set 0 to restore the legacy overlapping seeds 0..N-1.\n confirm_seed_base: int = Field(default=1, ge=0)\n # D1 holdout-gated promotion (B6, Arbor-style): for host-graded tasks, reserve this fraction of\n # the held-out labels as a FINAL holdout partition the search never sees — every search/confirm\n # eval is scored on the remaining rows only, and at finish the val-top-k are re-scored on the\n # holdout partition (free: the predictions already exist). 0.0 = off (legacy full-label scoring).\n holdout_fraction: float = Field(default=0.25, ge=0.0, le=0.9)\n # Champion selection prefers the HOLDOUT metric among the nodes that have one (the val-top-k).\n # Recorded in run_started so replay applies the same rule; old logs fold to False (legacy pick).\n # The per-node val-holdout `generalization_gap` folds into the Trust panel either way.\n holdout_select: bool = True\n # How many val-leaders get a holdout evaluation at finish (host-graded tasks).\n holdout_top_k: int = Field(default=3, ge=1)\n # R1-c / Part IV — calibrated §12-verifier tie-break in best-selection. When a fresh node's robust\n # metric EXACTLY TIES an already-eligible node, the engine runs the §12 verifier (grounded on each\n # node's realized result, `selection_criteria`) and records a soundness score per node. The tie-break\n # then applies across the WHOLE selection path: the mean pick breaks a robust_metric tie by the score,\n # and — when `holdout_select` is on — the holdout-slot ranking prefers the sounder node so it isn't\n # denied a holdout eval, and the final holdout override breaks a holdout_metric tie by it too. Strictly\n # a TIE-BREAK — it can NEVER move a node ahead of a strictly-better metric (§21.7 advisory-never-\n # overrides). Best-effort: verification is LAZY (only fires on an actual tie) and bounded per cadence,\n # so a large/late tie stays on deterministic metric+id order unless one atomic treatment scores every\n # member. Recorded in run_started so\n # replay applies the same rule; old logs fold to False (legacy pick). Opt-in (default off); needs a\n # reachable LLM client. Enable only after validating calibration offline with `verifier.calibrate`.\n # See trust/verifier.py + core/fitness.py.\n select_verifier: bool = False\n # R1-d (§21.19): widen the verifier tie-break from EXACT-metric to a conservative STATISTICAL tie.\n # The band is capped by both the metric leader's SE and the pooled SE-of-difference, so challenger\n # variance cannot widen it and a significant difference is never a tie (§21.7). Off -> exact only.\n verifier_ci_tie: bool = False\n # Verifier sample count for `select_verifier` (the §12 repeated-sampling expectation). 3 tames the\n # single-shot variance; 1 = single-shot (cheaper, noisier).\n select_verifier_samples: int = Field(default=3, ge=1, le=32)\n # Budget (I13): hard wall-clock ceiling; the run aborts cleanly when exceeded.\n max_seconds: float | None = None\n # Eval-compute budget (#2): hard ceiling on cumulative time spent INSIDE evals (training\n # runs), separate from wall-clock. Survives resume (summed from the event log). The real\n # guard against a silent multi-hour sweep when an eval is a minutes-hours training run.\n max_eval_seconds: float | None = None\n # Cross-run memory (I19, ADR-10): if set, the best result of each run is stored as\n # a case here, and the cases become retrievable knowledge for future runs.\n memory_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"memory\"))\n # HITL (I21, ADR-11): pause for human approval of the final best before finishing.\n require_approval: bool = False\n # Diversity archive (I22): niche bucket width in parameter space.\n archive_resolution: float = 1.0\n # Coverage read-model (narrowing signal): at the strategist cadence, compute a breadth summary\n # (themes / param-niches / theme entropy / dominant-theme fraction) from the folded run, record\n # it as a `coverage_snapshot` audit event, and surface it into the Strategist's decision context.\n # Deterministic, cheap, and purely additive context — no search-behavior change on its own. On by\n # default (like the rest of the always-on situational context). See search/coverage.py.\n coverage_context: bool = True\n # PART IV Phase 2a live steering (§21.11/§21.13). When on, the `concept_retag_every` cadence (NOT\n # `strategist_every` — the producer gates on `_should_consult_concepts`, which uses the seed boundary\n # + `concept_retag_every`, default 30) records a\n # concept-graph coverage + uncovered-region snapshot (deterministic heuristic tagger over the\n # task-type skeleton) and, on an `explore` stance, the Researcher's novelty hint names the exact\n # uncovered regions (\"0 coverage in {negatives/external-mining, distillation} — go there\") instead\n # of the vague \"broaden\". ON by default in the product Settings (ce4a379), EngineOptions off: it\n # only enriches an already-mode-gated explore hint, never forces exploration, and no-ops for a task\n # with no curated concept skeleton. The snapshot is auditable, but its prompt cue can change future\n # candidate generation; it does not directly rewrite best-metric selection. See search/concept_graph.py.\n # DESIGN NOTE (2026-07-17 critique): now ON unconditionally, but a toy / non-ML task yields EMPTY concept\n # tags while still paying the per-node LLM concept cost (~10 calls/node with the full Part IV/V bundle).\n # Consider AUTO-QUIESCING: if the heuristic tagger returns empty for the first N nodes (no concept signal),\n # skip the LLM concept work for the run — keeps it on for ML tasks, free on toy/degenerate ones.\n concept_pivot: bool = True\n # PART IV Phase 2b — D3 graded novelty into the LIVE novelty gate (§21.4/§21.13). When on and the\n # task has a curated concept skeleton, the novelty gate first grades a proposal over the concept\n # graph: a level-4 \"same direction, DIFFERENT implementation\" or a level-5 \"re-opens a\n # wrongly-abandoned FAILED direction\" proposal is ALLOWED through unchanged — the flat LLM/semantic\n # dedup gate can't tell \"this DCL tweak\" from \"the whole DCL branch\" and would wrongly reject a\n # legitimate variant or never re-open a sound-but-killed direction (the node_63 archetype). Levels\n # 1/2/3 (identical / near-dup / prior-run) still fall through to the existing gate. Deterministic\n # (heuristic tagger, no LLM), audit event `novelty_graded`, replay-safe. ON by default in the product\n # Settings (ce4a379), EngineOptions off; no-ops for a task with no skeleton. See search/graded_novelty.py.\n graded_novelty: bool = True\n # PART IV Phase 2b — D7 capability-expansion forced-jump DIRECTIVE (§21.8/§21.13, issue #7). When on\n # and the concept-graph cadence detects action-space LOCK-IN (the search has stayed inside one D5\n # branch for a long consecutive streak) on an `explore` stance, the Researcher's novelty hint\n # ESCALATES from \"broaden\" to \"expand the ACTION SPACE — build the missing infra (external mining,\n # a new data pipeline, a different eval), do NOT swap another {locked} lever.\" The same\n # gate also stamps the proposal's authoritative operator as `expand`; that node competes normally\n # under SearchFitness and its separate operator yield is measured. It does not force a winner.\n # Reads the 2a concept-coverage snapshot, so it needs `concept_pivot` to record one. OPT-IN\n # (default off). See engine/proposal_cues.py + search/lock_in.py.\n capability_expansion: bool = False\n # PART IV cross-run Step 0 (§21.20.12/§21.20.13). Universal task-fingerprint tokenization: drop the\n # ASCII-only `[a-z0-9]` allowlist on goal keywords so a non-Latin goal (Russian, CJK, …) is NOT\n # silently dropped from its cross-run fingerprint (today such a goal keys only on kind/dir/metric and\n # never reaches SIMILAR-task priors/lessons/cases). Uses `[^\\W_]+`/`.casefold()` — same splitting as\n # before, any script. ON by default in the product Settings (ce4a379); the bare-library EngineOptions\n # default stays off, so a run pinned to it won't silently re-key a portfolio mid-flight. See engine/memory.py.\n fingerprint_universal: bool = True\n # PART IV cross-run Step 2 (§21.20). Fill graded-novelty's `prior_concepts` from the cross-run\n # ConceptCapsuleStore: at run end write a per-run concept capsule (the shipped `node_concepts` tags +\n # best outcome, keyed by task_fingerprint) to `memory_dir`; when a later SIMILAR run proposes an idea\n # whose concept was tried before, SURFACE the prior outcome as a `cross_run_prior` audit event (never\n # reject — D3 level 3). Audit-only, off the selection path; ON by default in the product Settings\n # (ce4a379), EngineOptions off. Needs a `memory_dir` to share capsules.\n # See engine/memory.py (ConceptCapsuleStore) + engine/novelty.py.\n # PREREQUISITES (this flag is NOT standalone): the WRITE needs per-run concept tags\n # (`node_concepts`, produced by `concept_pivot`/F1 tagging) — no tags => no capsule; the SURFACE path\n # runs inside the graded-novelty precheck, so it also needs `graded_novelty` on. With only this flag +\n # `memory_dir` it persists/surfaces nothing. Enable `concept_pivot` + `graded_novelty` alongside it.\n # A non-Latin (Russian/CJK) portfolio should ALSO set `fingerprint_universal`, else the capsule's goal\n # tokens are dropped and its fingerprint over-matches on kind/dir/metric alone (the novelty direction\n # gate narrows this, but goal-word similarity is still lost).\n cross_run_concepts: bool = True\n # PART V (B): run-base + node-DELTA concept authoring. When on, once the first evaluated node has\n # authored concepts the engine seeds RunState.run_base_concepts from them (a one-shot EV_RUN_CONCEPTS,\n # idempotent because it only fires while the base is empty), and the Researcher's proposal hint tells it\n # to author only `concepts_added`/`concepts_removed` vs the run base + its parent — minimal per-node\n # annotations that inherit down the DAG (a swap propagates). Off -> every node authors its full concept\n # set as before. Product-on; the bare EngineOptions mirror defaults off (test_options_divergence).\n concept_run_base: bool = True\n # PART IV cross-run Step 5 advisory (§21.20.5). Fold the bounded cross-run CONTEXT PACK — evidence-\n # grounded claims with BOTH support and counter-evidence (Step 4) + a portfolio-coverage line (Step 3) —\n # into the Researcher's proposal prompt, exactly like the E4 cross-run prior note. Advisory ONLY: it is\n # prompt-grounding: it can change the Researcher's later candidates, but does not directly rewrite\n # best-metric selection (§21.7). Reads `memory_dir` (lessons.jsonl + concept_capsules.jsonl); \"\" when\n # empty. ON by explicit experimental product choice; no frozen A/B validation is claimed. The\n # bare-library EngineOptions default stays off (engine/options.py, frozen in\n # test_options_divergence). See engine/proposal_cues.py.\n cross_run_advisory: bool = True\n # PART IV cross-run §21.20.13 (full CR of the lean fuzzy claim merge). Switch the claim read-model to the\n # SCOPE+POLARITY-safe STRUCTURED claim key (engine/claim_key.py): claims from different tasks never\n # merge, opposite polarity (\"X helps\" vs \"X never helps\") is surfaced as a CONTRADICTION instead of being\n # collapsed, paraphrase/inflection variants group by exact structured key (no transitive over-merge), and\n # operator governance is scope-precise (a decision in task A cannot reach a same-worded claim in task B).\n # Affects the `cross_run_advisory` context pack only; ON by default in the product Settings (ce4a379);\n # the bare-library EngineOptions default stays off (engine/options.py). See engine/claims.py.\n cross_run_structured_claims: bool = True\n # PART IV cross-run §22.4 (AGENTIC taxonomy steward). At finalize, when an LLM client is available, let\n # the concept steward (engine/concept_steward.py) review the freshly-updated portfolio concept graph and\n # PROPOSE a curation — merge duplicate slugs / split conflated ones / purge noise. Proposals are LOGGED to\n # `concept_curation_log.jsonl` for operator ratification (via the serve/CLI governance surface); the LLM\n # never mutates fold state. The calls run synchronously during finalize, so they add latency and may add\n # paid inference; a caught steward failure does not prevent terminal completion or trigger a retry.\n # ON by explicit experimental product choice when `memory_dir` + an LLM backend are present; the\n # bare-library EngineOptions default stays off (engine/options.py). See engine/lessons.py.\n cross_run_curation: bool = True\n # Deprecated compatibility flag. Steward output is untrusted and finalize is proposal-only regardless\n # of this value; retained so old snapshots still validate and logs can disclose that auto was requested.\n cross_run_curation_auto: bool = False\n # Role backend (ADR-7/14): \"toy\" (offline optimizer) | \"llm\" (live model). Validated in\n # `_check_trust_gate` alongside the other enum-ish strings — every consumer tests `== \"llm\"`\n # exactly (cli/__init__.py, adapters/tasks.py), so an untyped `--set`/file/env typo (or a mis-cased\n # \"LLM\") used to fall through to the OFFLINE toy roles and quietly produce a run that never called\n # the model. Left as `str`, not `Literal`, so old snapshots still validate through the same path.\n backend: str = \"toy\"\n # Developer backend (ADR-7): \"default\" (templated/LLM from the task) or an external\n # CLI coding agent: \"opencode\" | \"aider\" | \"goose\" | \"continue\".\n developer_backend: str = \"default\"\n # C2 best-of-N: generate N candidate implementations per node and keep the best by an\n # execution-free reward (static validity + metric-print). 1 = off. In-house LLM developer only\n # (not external agents — ADR-7 cost rule). The top SWE-bench reliability lever for weak models.\n best_of_n: int = 1\n # D10 list-wise best-of-N selection: when the top static-scorers TIE, break the tie with a\n # comparative LLM selection over the candidates presented together (+~3 pts vs pointwise on\n # GAIA, arXiv:2506.12928). The static score stays the primary filter — the LLM is a weak\n # comparative prior, never the sole oracle. ON by default (no-op when best_of_n==1).\n best_of_n_listwise: bool = True\n # === Foresight / predict-before-execute ===============================================\n # FOREAGENT predict-before-execute (arXiv:2601.05930): use the LLM as an IMPLICIT WORLD MODEL to\n # predict — WITHOUT executing — which candidate / hypothesis will score best, primed with a\n # Verified Data Analysis Report (data profile / brief) + the accumulated experiment memory. Master\n # switch, ON by default; a no-op wherever there is nothing to rank (best_of_n==1 and\n # foresight_panel==1) or no LLM client. See `looplab/search/foresight.py`.\n foresight: bool = True\n # Hypothesis foresight breadth: generate K candidate ideas per proposal and keep the one PREDICTED\n # best pre-execution by the world model — it ranks the STRUCTURAL / text ideas the numeric k-NN\n # surrogate (researcher_panel) is blind to, primed with the data profile + memory (the synergy).\n # >1 enables it (LLM backend only; needs `foresight` on); 2 = on by default at modest cost, 1 = off.\n foresight_panel: int = 2\n # AGENTIC foresight: run the ranking (hypothesis-board prioritization + K-idea pick) as a TOOL-USING\n # loop that can pull actual experiment results / data facts before deciding, instead of a one-shot\n # prediction from a pre-baked report. ON by default (needs foresight + a client; falls back to the\n # one-shot predictor on any hiccup). A few extra LLM calls per proposal; set False for the cheaper\n # single-call ranker.\n foresight_agentic: bool = True\n # §1 foresight confidence gate: the minimum predicted confidence at which a predict-before-execute\n # pick is ACTED ON. Below it the ranker abstains — the K-idea panel falls back to the first\n # proposal, best-of-N falls through to the D10 tie-break — instead of committing a low-confidence\n # choice. 0.0 (default) = OFF (act on every pick, the historical behavior); raise toward ~0.5 to\n # make the world model defer when it isn't sure. Pairs with the foresight track record (§1) the\n # predictor is now primed with. Range 0.0-1.0.\n # CLAUDE REVIEW: [EDGE-CASE] the field comment says \"Range 0.0-1.0\" but unlike the sibling\n # confidence/fraction knobs (train_monitor_kill_confidence, asha_live_quantile, ...) there is no\n # Field(ge=0.0, le=1.0) bound, so e.g. 5.0 validates silently — a gate that can never pass, so\n # every foresight pick permanently abstains with nothing in the snapshot to show why.\n foresight_min_confidence: float = 0.0\n # PART IV Phase 2c — replace the world model's SELF-REPORTED confidence (measured Pearson≈0 with the\n # realized outcome, §21.12) with a CALIBRATED §12-verifier score. When on and a client is available,\n # after the K-idea ranker picks the predicted-best candidate the §12 verifier scores it (grounded +\n # repeated + criteria-decomposed: `foresight_criteria` — likely to improve the objective, and\n # sound/feasible) and THAT calibrated score becomes the confidence the `foresight_min_confidence` gate\n # and the telemetry use. Degrades to the self-reported confidence without a client or on any verifier\n # error (the telemetry records which source was used). ON by default in the product Settings (ce4a379),\n # EngineOptions off; a few extra LLM calls per acted-on proposal. See looplab/trust/verifier.py + search/foresight.py.\n # REVISIT (cost/quality, not yet resolved): a live classification run measured ~10 LLM calls & ~3.8 min/node\n # on deepseek-v4-flash with the full Part IV/V default bundle; the calibrated verifier is part of that per-node\n # tax. Kept ON deliberately — it raises decision quality (self-reported foresight confidence has ≈0 Pearson\n # with realized outcome), and lowering quality to save calls is the wrong trade. TODO: run a frozen quality/cost\n # A/B (verifier-on vs -off) to quantify the recall@objective gain per extra call before considering a default flip.\n foresight_verify: bool = True\n # Verifier sample count for `foresight_verify` (the §12 repeated-sampling expectation on a no-logprob\n # backend). 3 tames the single-shot variance §21.12 measured; 1 = single-shot (cheaper, noisier).\n # RESUME-critical: this value is persisted in config.snapshot.json, so a hard `le` schema bound would\n # make an old run started with a larger LOOPLAB_FORESIGHT_VERIFY_SAMPLES un-resumable (Settings(**snapshot)\n # would raise on `looplab resume` / finalize-recovery / serve replay). CLAMP into [1, MAX] via a validator\n # instead of rejecting — the runtime cap is preserved without breaking the snapshots/env-compat rule.\n foresight_verify_samples: int = 3\n\n @field_validator(\"foresight_verify_samples\")\n @classmethod\n def _clamp_foresight_verify_samples(cls, v: int) -> int:\n # Coerce a persisted/env value into [1, MAX] rather than reject (see field note above).\n try:\n v = int(v)\n except (TypeError, ValueError):\n return 3\n return max(1, min(v, MAX_FORESIGHT_VERIFY_SAMPLES))\n\n # T7 LLM response cache: serve an identical DETERMINISTIC (temperature 0) request from an\n # in-process content-addressed cache instead of re-hitting the model — cuts cost on\n # retry/panel/verify flows. NEVER caches sampling calls (temp>0: best-of-N / panel / novelty\n # re-propose must vary). Off by default (most role calls run at temperature>0 anyway).\n llm_cache: bool = False\n agent_cmd: str | None = None # override the agent's launcher (path / wrapper)\n # External-agent validation (ADR-7): wrap a CLI-agent Developer with a validator that\n # audits each output (no-op/syntax/crash/timeout), retries with feedback, and falls\n # back to the in-house LLM Developer. Off -> use the raw agent output unchecked.\n validate_agent: bool = True\n agent_max_retries: int = 1 # re-prompts of the agent on an invalid result\n # Patch-gated multi-file agent (ADR-7 Rule 3): run the CLI agent in a git worktree and\n # accept only edits whose paths match `agent_surface` (reject-not-strip out-of-surface\n # touches). Lets the agent create helper modules, not just solution.py. Degrades to\n # whole-file readback if git is unavailable.\n agent_patch_gate: bool = True\n agent_surface: list[str] = [\"*.py\"] # edit-surface allow-list (globs)\n # Trust policy for an agent-authored eval/metric adapter (RepoTask onboarding, Phase 3):\n # \"ratify_freeze\" waits for human approval, \"autonomous\" accepts the proposed spec without that\n # gate, and \"ratify_freeze_drift\" also enforces the configured independent drift cross-check.\n eval_trust_mode: str = \"ratify_freeze\"\n # RepoTask node seeding policy (run-wide fallback; a task/editable `seed_mode` overrides):\n # \"auto\" (default) copies git-tracked files when the editable is a git repo (so a tree bloated\n # with untracked artifacts isn't deep-copied per node), else a full copy; \"tracked\" forces\n # code-only; \"all\" forces a full recursive copy (legacy). Genesis authors per-task from the\n # user's words; this is the global default when unspecified.\n seed_mode: str = \"auto\"\n llm_model: str = \"qwen3:8b\"\n # === LLM / transport ==================================================================\n llm_base_url: str = \"http://localhost:11434/v1\" # Ollama OpenAI-compatible endpoint\n llm_temperature: float = 0.6\n # H3 per-role model presets (5090 recipe): optionally run the Researcher and Developer on\n # DIFFERENT models/endpoints (e.g. Developer=Qwen3-Coder-30B for code, a fast model for breadth).\n # Blank = use the shared llm_model/llm_base_url. Generalizes the role-backend swap to per-role.\n researcher_model: str | None = None\n developer_model: str | None = None\n researcher_base_url: str | None = None\n developer_base_url: str | None = None\n # Per-role sampling temperature (§4.1): the Researcher wants breadth (higher temp = more diverse\n # ideas), the Developer wants determinism (lower temp = fewer syntax slips), the Strategist a\n # steady hand. Each overrides the shared `llm_temperature` for that role only; None = use the\n # shared value (byte-identical to before). Flat + nullable, mirroring the per-role model fields.\n researcher_temperature: float | None = None\n developer_temperature: float | None = None\n strategist_temperature: float | None = None\n # The Strategist had per-role TEMPERATURE but no model/endpoint of its own, so in split mode it\n # always ran on the shared llm_model however the other roles were pointed. These close that gap\n # with the same names/shape as the pair above; blank = its profile, else the shared value.\n strategist_model: str | None = None\n strategist_base_url: str | None = None\n # === Connection profiles (multi-provider) =============================================\n # A \"profile\" is a NAMED connection: {model, base_url, temperature, api_key_env, provider}. Roles\n # point at one by name through `role_profiles`; `llm_profile` is the default for every role that\n # doesn't. All three are EMPTY by default and the resolver short-circuits to the shared\n # llm_model/llm_base_url/llm_api_key path when they are — an operator running one model never\n # needs to know the word \"profile\".\n #\n # A profile stores `api_key_env`, the NAME of an environment variable, never the key itself. That\n # is ADR-11's \"a secret is a reference, not a value\" turned from a convention into a data type:\n # the whole profile map is then safe to write into config.snapshot.json, hand back over HTTP and\n # render into LOOPLAB_* — there is nothing in it to mask. It is also what makes per-role\n # CREDENTIALS expressible at all: two roles on one provider can carry different keys, which a\n # key-per-endpoint map could not say.\n llm_profiles: dict[str, dict] = Field(default_factory=dict)\n llm_profile: str | None = None\n role_profiles: dict[str, str] = Field(default_factory=dict)\n # Unified self-driving agent: one LLM identity that plays Researcher + Developer (+ Strategist)\n # across pipeline stages, choosing its own model/toolset per stage. ON by default — the agent\n # drives the loop (incl. crash triage: repair/abandon/reject_idea) and, via\n # `agent_drives_actions`, picks the next macro action within a pure legal-action gate. Set both\n # to False for the legacy byte-identical split-role behavior. (No-op unless backend=\"llm\".)\n unified_agent: bool = True\n agent_drives_actions: bool = True\n # Layer 3 Card queue ownership. False preserves the existing policy/unified-pilot action path.\n # True is pinned in run_started because changing the selector on resume would mix two search\n # treatments in one run. When both this and agent_drives_actions are true, Card selection wins.\n card_driven_selection: bool = False\n # Layer 5 bounded speculative Card buffer. Zero is a hard OFF switch and preserves the\n # historical alternating build/eval spine; positive values are pinned by run_started so a\n # resume cannot silently mix search treatments after a snapshot/default edit.\n speculation_depth: int = Field(default=0, ge=0, le=64)\n # Local evidence receipt produced by ``looplab speculation-gate``. This is deliberately absent\n # from the curated Settings UI: positive-depth execution is a calibrated rollout decision, not a\n # casual tuning knob. The Engine validates the receipt against the current scorer,\n # implementation, GPU inventory and raw paired-run evidence before it enables speculation.\n speculation_gate_receipt: str | None = Field(default=None, max_length=4096)\n # Per-stage model/endpoint overrides for the unified agent. Recognized keys (see\n # tasks.build_unified_agent): `propose` (researcher), `implement`/`repair` (developer),\n # `strategy`, `pilot`. Unlisted keys are ignored. Empty = the per-role researcher_*/developer_*\n # models, then the shared llm_model. Explicit map wins. No-op unless `unified_agent` is true.\n agent_stage_models: dict = {}\n agent_stage_base_urls: dict = {}\n llm_parser: str = \"tool_call\"\n # Reasoning/thinking toggle sent IN the request (providers differ — see `llm.reasoning_body`):\n # Qwen3 on vLLM/SGLang -> chat_template_kwargs.enable_thinking (bool)\n # OpenAI/Ollama-v1/DeepSeek -> reasoning_effort (low|medium|high|none)\n # `llm_reasoning`: \"\" = send nothing (server default — current behavior); off = actively disable;\n # on = enable at default depth; low|medium|high = enable at that effort.\n # Defaults to \"high\" so the agent reasons before proposing/repairing/triaging (better code, fewer\n # crashes); set \"\" to send nothing (server default) or \"off\" to disable. The model's chain-of-thought\n # is CAPTURED either way (reasoning_content field or inline <think>). NOTE: to get a SEPARATE\n # reasoning_content field from SGLang/vLLM, the server also needs `--reasoning-parser qwen3`.\n llm_reasoning: str = \"high\"\n llm_reasoning_style: str = \"auto\" # auto | qwen | effort | none — how to shape the request param\n llm_reasoning_extra: dict = {} # raw fields merged into the body (escape hatch, e.g. Anthropic thinking)\n # H1: drive structured calls with the endpoint's constrained decoding (vLLM/SGLang guided_json +\n # response_format json_schema) so weak models can't emit invalid JSON. Off for Ollama (default).\n llm_guided_json: bool = False\n # Stream every LLM request (SSE) and reassemble it, so the request `timeout` is an INTER-TOKEN\n # idle timeout rather than a whole-request deadline: a long-but-alive generation is never cut off\n # (tokens keep resetting the timer), while a genuinely STALLED endpoint (no token for `timeout` s)\n # is detected and retried on a fresh connection — the fix for silent multi-minute hangs. Set False\n # to use one blocking read (old per-op timeout semantics) if an endpoint streams badly.\n llm_stream: bool = True\n # LLM IDLE timeout (seconds). Stream mode: the INTER-TOKEN stall limit — a steady generation is\n # never cut off, only a silent endpoint. Non-stream: bounds the whole read. Raise for endpoints\n # with a long prefill on huge prompts; lower to fail fast on a flaky shared endpoint.\n llm_timeout: float = Field(default=180.0, gt=0)\n # First-byte (response-headers) window for STREAM attempts, seconds: an SSE response sends its\n # headers on admission, so no-headers ≈ a black-holed request — fail over to a fresh connection\n # fast instead of waiting the full idle timeout. Clamped to llm_timeout. Non-stream attempts are\n # NOT bounded by this (their headers arrive only after the whole generation). The default mirrors\n # `DEFAULT_HEADER_TIMEOUT_S` in looplab/core/llm.py is the single source (imported above).\n llm_header_timeout: float = Field(default=DEFAULT_HEADER_TIMEOUT_S, gt=0)\n # Honour HTTP(S)_PROXY / NO_PROXY / SSL_CERT_FILE env vars for LLM calls. Default False: the\n # transport connects DIRECTLY (many internal/OLAP endpoints are reachable only by bypassing an\n # ambient proxy — a picked-up proxy yields \"connection refused\"). Set true when the endpoint is\n # reachable ONLY via a corporate proxy or needs a custom CA bundle from the environment.\n llm_trust_env: bool = False\n # H4/C2: compact the growing agentic tool-call history once it exceeds this many chars (auto_summary\n # LLM-summarizes the stale middle; else middle-truncate). Sized to the model's real context window so\n # files read earlier STAY in context instead of being compacted away and re-read: ~1,000,000 chars ≈\n # 250k tokens (≈4 chars/token), matching the configured deepseek-v4-flash (≥200k tokens confirmed).\n # The old 120k-char (~30k-token) default was ~8× too aggressive and drove a read-thrash loop. 0 = off.\n context_budget_chars: int = 1_000_000\n # M5: the Researcher's always-on experiments digest budget (chars). 0 = AUTO — scales with the\n # run size (60 chars/node, bounded [1200, 6000]) instead of one flat cap for an 8-node toy run\n # and a 200-node benchmark run alike. Set a positive value to pin it.\n digest_char_cap: int = Field(default=0, ge=0)\n # D11 compression model slot (open_deep_research's four-slot pattern): a dedicated CHEAP model\n # for agent-history summarization (C2 auto-summary), so compression doesn't pay the main\n # model's price. Blank = use each loop's own model (legacy). `compressor_base_url` blank =\n # the shared llm_base_url.\n compressor_model: str | None = None\n compressor_base_url: str | None = None\n # === Agentic tool-loop ================================================================\n # Agentic tool-loop limits — apply to EVERY tool-using agent (the LLM Researcher, the unified\n # agent's pilot + crash-triage, Deep-Research, the run-chat Boss, the genesis repo-scout, and the\n # cross-run report synthesizer). The loop lets the model call read-only tools across turns before\n # emitting its structured result. Both default to UNLIMITED so the agent takes as many turns / as\n # long as it needs — it is never cut off mid-reasoning; set a positive value here (or per-run, or\n # in the UI settings) to bound latency/cost. These used to be hardcoded per call site (e.g. the\n # boss at max_turns=3 / 45s), which silently dropped a slow reasoning model to a no-op reply.\n agent_max_turns: int = 0 # max tool turns before the emit is forced (0 = unlimited)\n agent_time_budget_s: float = 0.0 # wall-clock ceiling across the loop's turns (0 = no cap)\n # G · Soft convergence SAFETY NET (not a research cap). Two thresholds on the agent's tool-turn count:\n # at `agent_emit_after` the loop NUDGES the agent once (\"you've investigated enough — emit now\"); at\n # `agent_emit_force` it FORCES the emit from what was gathered. Set GENEROUSLY — a focused researcher\n # may legitimately read many files/experiments before it can propose a grounded idea; these only catch\n # a genuine runaway that keeps issuing DIFFERENT calls forever (which stuck-detection, keyed on\n # repeats, misses). NB: the old 499-read runaway (node 63) was 79% REDUNDANT re-reads caused by a\n # broken read_file — fixing read_file (pagination) is the real cure; this is just insurance. 0 = off.\n agent_emit_after: int = 300\n agent_emit_force: int = 500\n # B1 · No-progress / stuck detection — the safety net that makes \"unlimited turns\" safe. The\n # turn/time ceilings above are only BACKSTOPS; this is what actually stops a runaway loop, on the\n # cheapest signal: when the model repeats the SAME tool call (or ping-pongs between two, or keeps\n # hitting the SAME error) with no progress, the loop forces the final emit and finishes. ON by\n # default; reading DIFFERENT files or running ONE long command never trips it. (OpenHands-style.)\n agent_stuck_detection: bool = True\n agent_stuck_repeat: int = 4 # identical calls in a row that count as \"stuck\" (>=2)\n agent_stuck_alternate: int = 4 # ping-pong cycles between two calls that count as \"stuck\" (>=2)\n # C1 · Self-plan (TodoWrite-style): expose an `update_plan` tool so a long-running agent keeps its\n # OWN working TODO and re-surfaces it every `agent_plan_reinject_every` turns — keeps the goal in\n # view across a long loop so the agent \"writes its own context\" (Claude Code style). ON by default.\n agent_self_plan: bool = True\n agent_plan_reinject_every: int = 5\n # C2 · Auto-summary (ON by default): when the tool-loop history grows long, LLM-summarize the\n # stale middle instead of only middle-truncating it. The trigger is `context_budget_chars` if set,\n # else a built-in high-water mark (context_budget.DEFAULT_SUMMARY_CHARS ≈ 120k chars) so short\n # loops are untouched. Falls back to truncation on any summarizer error; one extra model call per\n # over-budget turn.\n agent_auto_summary: bool = True\n # C4 · Repo-developer plan decomposition. A big feature attempted in ONE developer session made a\n # non-converging reasoning model loop indefinitely — it kept writing + exploring without ever\n # emitting `done` (10k+ LLM calls / hours; agentic stuck-detection can't catch VARIED exploration,\n # only literal repeats). Fix: the developer first proposes an ordered plan of ATOMIC steps; a\n # multi-step plan is executed step-by-step, each in a FRESH bounded session with only that step's\n # scope + the files accumulated so far, syntax-validated per write. Every session stays\n # small-context and convergent. ON by default for repo tasks (a 1-step plan == the old single pass).\n developer_plan_decompose: bool = True\n developer_plan_min_steps: int = 2 # a proposed plan with >= this many steps runs step-by-step\n developer_plan_max_steps: int = 8 # cap on plan length (a runaway planner can't spawn 100 steps)\n # Hard per-session backstop for the repo developer — the ONE write-heavy agent that can run away\n # even with stuck-detection on (varied writes/reads never trip the repeat/ping-pong signal). Bounds\n # the plan phase, EACH step, and the single-session fallback. Unlike the global agent_* limits\n # (0 = unlimited, meant for read-only agents), the developer always gets a FINITE ceiling so a\n # model that never emits `done` fails cleanly with the code it wrote so far, instead of looping.\n developer_session_max_turns: int = 500\n developer_session_time_budget_s: float = 1200.0 # 20 min wall-clock per developer session\n # Phase-handoff summaries. Each LLM phase in a node build (Researcher·propose → Developer·stages →\n # plan → implement) ends with ONE extra LLM call that distills its transcript — the repo structure\n # it mapped, files/data confirmed, decisions made — into a brief injected into the NEXT phase (even\n # across the role boundary). The next phase TRUSTS that instead of re-reading the same files, which\n # cuts the biggest source of tool-call thrash (stages/plan/implement each re-exploring the repo).\n # One summary call per phase in exchange for many fewer read calls downstream. ON by default.\n phase_handoff_summary: bool = True\n # Observability (ADR-17): capture each LLM call's full prompt + completion into the active\n # span (spans.jsonl) so the UI can show exactly what the model read and wrote per node.\n # Diagnostics only — `replay.fold` never reads spans. Default on for local single-user; set\n # LOOPLAB_TRACE_LLM_IO=0 to suppress (e.g. to keep spans.jsonl small or avoid storing prompts).\n trace_llm_io: bool = True\n # Run-introspection tools (ON by default): make the LLM Researcher (and DeepResearcher) a\n # tool-using agent that can read its OWN experiments (list/read/find-analogous/themes/code) and\n # the task data (schema/profile/asset) mid-loop, instead of seeing only best+parent. Advisory —\n # never changes best-selection. Off = the legacy single-shot Researcher (richer digest still added).\n researcher_tools: bool = True\n # Cross-run introspection: give the agentic Researcher / pilot read-only tools to look at\n # SIBLING runs (same task_id, same run-root) — list them, read an experiment / its code, and\n # find analogous configs across runs — so a run can build on what neighbouring runs already\n # learned instead of rediscovering it. Advisory; never changes best-selection. Needs the run's\n # own dir wired through (no-op for unit-built roles). Off = the legacy single-run view only.\n cross_run_tools: bool = True\n # Read-only access to EVERY run on this machine ACROSS ALL TASKS (not just same-task siblings):\n # the Developer/Researcher get list_all_runs + read_run_code + read_run_experiment so they can read\n # the code + result of ANY past experiment anywhere and reuse an approach. Broader than\n # `cross_run_tools` (same-task only); the agent decides when a foreign run is relevant. Advisory;\n # never changes best-selection. Needs the run's own dir wired through (no-op for unit-built roles).\n all_runs_tools: bool = True\n # PART V §22 — read-only CROSS-RUN KNOWLEDGE tool for the reasoning roles (Researcher, Strategist,\n # deep-research, and a Developer-scoped variant). Adds `cross_run_prior_attempts` / `cross_run_claims`\n # / `cross_run_atlas` over the §21.20 read-models (concept overview + claim assessments + atlas), read\n # from `memory_dir` (lessons.jsonl + concept_capsules.jsonl). Read-only is a storage-authority\n # boundary, not a behavioral no-op: retrieved content can steer later proposals, and a tool-using\n # reasoning loop can consume additional model calls. The tools never mutate cross-run truth (facts\n # are engine-written, verdicts operator-ratified, §22.4) or directly select a winner. ON by explicit\n # experimental product choice, EngineOptions off; stores only exist once Part-IV features have run.\n cross_run_read_tools: bool = True\n # Agentic retrieval (ADR-16): if set, the LLM Researcher gets grep/kb_search/read\n # tools over this directory of markdown notes and chooses when to use them.\n knowledge_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"knowledge\"))\n # T4 real embeddings: model id for an OpenAI-compatible `/embeddings` endpoint used for\n # kb_search / case retrieval. Blank (default) = the dependency-free lexical `hash_embed` (today's\n # behavior). Set e.g. \"nomic-embed-text\" (Ollama) or \"text-embedding-3-small\" for SEMANTIC\n # retrieval; a misconfigured/offline endpoint degrades back to hash_embed at call time (never\n # crashes a run). `embed_base_url` blank = reuse `llm_base_url`; the shared `llm_api_key` is used.\n embed_model: str | None = None\n embed_base_url: str | None = None\n # Harmonic memory (Memora, ICML'26 — idea import). When ON, cross-run cases and knowledge notes are\n # indexed by a short ABSTRACTION + cue ANCHORS instead of their raw text, near-duplicate memories are\n # CONSOLIDATED into one entry under a matching abstraction, and `kb_search`/case retrieval EXPAND\n # through the top hits' anchors to surface related-but-not-similar memories. ON by default. Set\n # `memora=false` to restore the pre-Memora raw-text index. Never a source of truth — abstractions\n # live only in the derived, rebuildable retrieval index (never the event log or canonical cases.jsonl).\n memora: bool = True\n # Write abstractions with the wired chat model (richer than the deterministic lexical fallback). ON\n # by default; results are CACHED by content hash (see `memora_cache`) so a re-built index doesn't\n # re-call the model on unchanged notes/cases, and any endpoint failure degrades to lexical at call\n # time — so an offline box just gets lexical abstractions, never a crash. Set false to force lexical.\n memora_llm: bool = True\n # === Cross-run memory / Memora ========================================================\n memora_anchors: int = 6 # max cue anchors kept per memory\n # Cosine similarity at/above which a new case is CONSOLIDATED into an existing one (same evolving\n # topic) rather than stored as a separate entry. Only used when `memora` is on.\n memora_consolidate_threshold: float = 0.86\n # Where the LLM-abstraction cache lives (JSON). Blank (default) derives it from `memory_dir`\n # (`<memory_dir>/memora_cache.json`) or else `knowledge_dir` (`<knowledge_dir>/.memora_cache.json`);\n # with neither set the cache is in-memory only (per process). No-op unless `memora_llm` is on.\n memora_cache: str | None = None\n # E3 literature-grounded ideation (network-OPTIONAL): give the agentic Researcher an arXiv search\n # tool to ground ideas in real techniques. Off by default (egress is unreliable on some boxes);\n # fails gracefully if the network is blocked.\n literature_search: bool = False\n # Deep-Research stage (network-OPTIONAL): give the DeepResearcher a general web search/fetch\n # tool (DuckDuckGo) on top of arXiv to read across results + the web before steering the next\n # batch. Off by default (egress is unreliable on some boxes); fails gracefully when blocked.\n web_search: bool = False\n # Cadence for the Deep-Research stage: run it automatically every N created nodes. Zero disables\n # only the cadence; manual `deep_research` and Strategist `request_research` triggers still fire.\n # Default 3: deep research analyzes the accumulating results and steers the next batch (its\n # directions become hints + open hypotheses); paired with `concurrent_research` it overlaps the\n # think with the GPU-bound eval, so the cadence costs LLM tokens but no wall-clock on the search.\n deep_research_every: int = 3\n # Overlap a DUE deep-research \"think\" with the GPU-bound eval instead of running it in its own\n # serial step (the agent is otherwise idle while a node trains). research() computes from a state\n # snapshot; the joined background task records only allowlisted, order-tolerant\n # research/hint/hypothesis events as soon as the memo finishes. ON by default: the LLM is typically\n # remote (no GPU contention with eval), so overlapping hides latency behind long training.\n concurrent_research: bool = True\n # Concurrent-research REPEAT — don't idle a multi-DAY eval. The overlapped \"think\" fires ONCE per\n # window by default; ON here, it RE-RUNS on an adaptive time cadence for the WHOLE eval window\n # (a two-day training must not leave the reasoning agents idle after one memo). Self-paced: it\n # only records a memo whose CONTENT is new (identical re-runs are skipped, so the log/board don't\n # bloat) and backs off geometrically as the analysis converges, capped so it always re-checks.\n # The appended records never rewrite the current metric champion, but their hints/open hypotheses\n # deliberately steer later proposals and replay reconstructs that advice. The library default is\n # one-shot (== today, byte-identical); this product default turns the repetition on.\n concurrent_research_repeat: bool = True\n # Base cadence (seconds) between repeated concurrent-research passes. Research is EXPENSIVE\n # (multi-turn LLM + web/arXiv), so this is a FLOOR, not a ceiling: the effective cadence is\n # max(this, ~5% of the per-experiment time budget) — a two-day eval is re-researched about hourly,\n # a short eval not at all (the first tick outlasts it). Only matters when the repeat is on.\n concurrent_research_interval_s: float = Field(default=1800.0, gt=0)\n # Per-eval-window backstop on repeated-research LLM calls (0 = cadence-only, no cap). The adaptive\n # cadence + convergence backoff are the primary budget control; this bounds a pathological\n # never-converging window. Past it the loop stops calling the LLM (the health monitor still runs).\n concurrent_research_max_calls: int = Field(default=40, ge=0)\n # Concurrent hypothesis-board CONSOLIDATION during the eval window. Repeated research keeps ADDING\n # near-duplicate directions to the open board (each memo registers up to 5 as open hypotheses), so a\n # long eval's board bloats before the between-nodes merge runs. This dedups/merges it on the SAME\n # background loop so the board stays clean for the next proposal. In Card-driven mode the same merge\n # changes ownership/readiness, so it is deferred to the joined main-task cadence instead. Needs the\n # reflect client + `track_hypotheses`.\n # Only active while `concurrent_research` runs the overlap loop. Library default off (== today).\n concurrent_consolidate: bool = True\n # Cadence for the agent-authored run report: regenerate the conclusion-first narrative every N\n # created nodes (0 = off; it still regenerates on a manual `report_refresh` from the UI). The\n # deterministic report always renders from the node set regardless of this knob.\n report_every: int = 3\n # Agent Skills (I18, ADR-9): dir of SKILL.md the Researcher can list/load as tools.\n skills_dir: str | None = None\n # Prompt store (I18, ADR-8): dir of editable, hot-reloaded role prompt .md files.\n prompt_dir: str | None = None\n # API key as a reference, never serialized as a value (ADR-11). Local servers\n # ignore it; default to a placeholder.\n llm_api_key: SecretStr | None = None\n\n @model_validator(mode=\"before\")\n @classmethod\n def _reject_bool_for_numeric_fields(cls, data):\n \"\"\"A JSON boolean is a TYPE ERROR for a numeric setting, not the number 1.\n\n Pydantic runs in lax mode here (`model_config` sets no `strict`), and `isinstance(True, int)`\n is True — so `{\"max_nodes\": true}` from a UI/API type slip validated cleanly as a budget of\n ONE node, and `{\"eval_parallel\": true}` collapsed the width to one. The run then looked\n configured and simply did almost nothing, with no 422 and nothing in the snapshot to show why.\n Rejecting at the boundary covers every numeric field at once, including the many that carry no\n `Field(ge=...)` bound of their own to trip over.\n \"\"\"\n if not isinstance(data, dict):\n return data\n for key, val in data.items():\n if type(val) is bool and _is_numeric_annotation(\n getattr(cls.model_fields.get(key), \"annotation\", None)):\n raise ValueError(f\"{key} must be a number, not a boolean (got {val!r})\")\n return data\n\n @model_validator(mode=\"before\")\n @classmethod\n def _apply_profile(cls, data):\n \"\"\"Expand `profile` into a bundle of setting defaults BEFORE validation.\n\n pydantic-settings hands this validator the fully-merged input from ALL explicit sources\n (init kwargs + LOOPLAB_* env + .env), with fields the user left unset simply ABSENT (they\n fall to field defaults afterward). So a profile value is applied only when its key is not\n already present — i.e. any explicit file/CLI/env setting wins over the profile, and the\n profile wins over the bare field default. This makes `thorough` a true convenience preset,\n never an override of what the user asked for.\"\"\"\n if not isinstance(data, dict):\n return data\n prof = data.get(\"profile\", \"default\")\n # env may deliver the key as \"profile\" (env_prefix stripped) — normalize to a str\n prof = str(prof).strip().lower() if prof is not None else \"default\"\n overrides = PROFILES.get(prof)\n if overrides is None:\n raise ValueError(f\"unknown profile {prof!r}; known: {sorted(PROFILES)}\")\n for key, val in overrides.items():\n if key not in data: # explicit source (file/CLI/env) always wins\n data[key] = val\n return data\n\n @model_validator(mode=\"after\")\n def _check_trust_gate(self):\n if self.trust_gate not in (\"audit\", \"gate\", \"block\"):\n raise ValueError(\n f\"trust_gate must be audit|gate|block, got {self.trust_gate!r}\")\n if self.merge_mode not in (\"auto\", \"mean\", \"ensemble\"):\n raise ValueError(\n f\"merge_mode must be auto|mean|ensemble, got {self.merge_mode!r}\")\n # novelty_mode drives the dedup/novelty gate; an out-of-set value (e.g. a mis-cased\n # LOOPLAB_NOVELTY_MODE=LLM, or \"on\") used to fall through _apply_novelty_gate silently as a\n # NO-OP — turning the gate off with no diagnostic. Fail loudly at config time like trust_gate/\n # merge_mode do. (Other enum-ish string fields — seed_mode/eval_trust_mode/strategist_backend —\n # share the pattern; left unvalidated here only because their full value sets are less settled.)\n if self.novelty_mode not in (\"off\", \"algo\", \"llm\"):\n raise ValueError(\n f\"novelty_mode must be off|algo|llm, got {self.novelty_mode!r}\")\n # The remaining enum-ish string fields (arch-review §5 P3): a typo used to be accepted at\n # construction and only fail-safe/later-loud downstream (e.g. a mis-cased strategist_backend\n # silently ran the default). Fail loudly here like the fields above.\n if self.strategist_backend not in (\"off\", \"rule\", \"llm\", \"agent\"):\n raise ValueError(\n f\"strategist_backend must be off|rule|llm|agent, got {self.strategist_backend!r}\")\n if self.eval_trust_mode not in (\"ratify_freeze\", \"autonomous\", \"ratify_freeze_drift\"):\n raise ValueError(\n \"eval_trust_mode must be ratify_freeze|autonomous|ratify_freeze_drift, \"\n f\"got {self.eval_trust_mode!r}\")\n if self.seed_mode not in (\"auto\", \"tracked\", \"all\"):\n raise ValueError(f\"seed_mode must be auto|tracked|all, got {self.seed_mode!r}\")\n if self.backend not in (\"toy\", \"llm\"):\n raise ValueError(f\"backend must be toy|llm, got {self.backend!r}\")\n self._check_llm_profiles()\n return self\n\n def _check_llm_profiles(self) -> None:\n \"\"\"Validate the connection-profile maps LOUDLY, at construction.\n\n Assignment is not validated anywhere in this project, and these two maps are the kind of\n thing a typo makes into a silent no-op: an unknown role name would simply never be read, and\n a reference to a missing profile would fall back to the shared model while the operator\n believed a second provider was in play. Both are errors here instead.\n\n The `api_key_env` rules exist for a specific reason. It must LOOK like a secret to\n `runtime/sandbox.py::SECRET_ENV`, because that is the filter that strips the operator's keys\n out of the environment handed to generated candidate code — a key parked in a variable named\n `MY_AUTH` would be invisible to it and ride straight into the sandbox. (The pattern is\n duplicated below rather than imported: layering forbids `core` from importing `runtime`.\n `tests/test_secret_env_pattern.py` pins the two together.) And a literal key inside a profile\n is refused outright: the profile map is written verbatim into config.snapshot.json and served\n over HTTP precisely because it is supposed to contain no secrets.\"\"\"\n from looplab.core.llm import LLM_ROLE_KEYS\n if not isinstance(self.llm_profiles, dict):\n raise ValueError(\"llm_profiles must be a map of profile name -> profile\")\n for name, entry in self.llm_profiles.items():\n if not isinstance(entry, dict):\n raise ValueError(f\"llm_profiles[{name!r}] must be an object\")\n # BEFORE the unknown-field check: none of these is a known field, so checking them\n # afterwards could never fire and someone who pasted a live key was told it was a typo.\n for banned in (\"api_key\", \"key\", \"token\", \"secret\"):\n if banned in entry:\n raise ValueError(\n f\"llm_profiles[{name!r}] must not contain {banned!r}: a profile stores \"\n \"api_key_env (the NAME of an environment variable), never a key value — \"\n \"the whole map is written to config.snapshot.json and served over HTTP\")\n unknown = set(entry) - _PROFILE_FIELDS\n if unknown:\n raise ValueError(\n f\"llm_profiles[{name!r}] has unknown field(s) {sorted(unknown)}; \"\n f\"known: {sorted(_PROFILE_FIELDS)}\")\n env = entry.get(\"api_key_env\")\n if env is not None and not _SECRET_ENV_NAME.match(str(env)):\n raise ValueError(\n f\"llm_profiles[{name!r}].api_key_env={env!r} is not a usable variable name: it \"\n \"must be UPPER_SNAKE and contain KEY/SECRET/TOKEN/PASSWORD/PASSWD/CREDENTIAL, so \"\n \"the sandbox's secret-name filter strips it from generated code's environment\")\n temp = entry.get(\"temperature\")\n if temp is not None and (isinstance(temp, bool) or not isinstance(temp, (int, float))):\n raise ValueError(f\"llm_profiles[{name!r}].temperature must be a number\")\n if not isinstance(self.role_profiles, dict):\n raise ValueError(\"role_profiles must be a map of role name -> profile name\")\n for role, profile in self.role_profiles.items():\n if role not in LLM_ROLE_KEYS:\n raise ValueError(\n f\"role_profiles has unknown role {role!r}; known: {sorted(LLM_ROLE_KEYS)}\")\n if profile not in self.llm_profiles:\n raise ValueError(\n f\"role_profiles[{role!r}] names profile {profile!r}, which is not in llm_profiles\")\n if self.llm_profile is not None and self.llm_profile not in self.llm_profiles:\n raise ValueError(\n f\"llm_profile={self.llm_profile!r} is not in llm_profiles\")\n\n def masked_snapshot(self) -> dict:\n # Snapshots are a JSON contract (they are written verbatim to config.snapshot.json), not a\n # Python-object dump. In particular tuple-valued schema fields such as\n # ``inline_repair_reasons`` must become JSON arrays before callers hash the runtime envelope;\n # otherwise the strict speculation-scope canonicalizer sees a different/non-JSON preimage\n # from the one that ``json.dumps`` eventually persists.\n d = self.model_dump(mode=\"json\")\n if self.llm_api_key is not None:\n d[\"llm_api_key\"] = \"***\"\n else:\n d[\"llm_api_key\"] = None\n # The snapshot's own FORMAT version — not a Settings field (it is never settable by env or\n # file, and it describes the document, not the run). `settings_from_snapshot` fails closed on\n # a version it does not understand; see CONFIG_SNAPSHOT_SCHEMA.\n d[CONFIG_SNAPSHOT_SCHEMA_KEY] = CONFIG_SNAPSHOT_SCHEMA\n return d\n\n\n# The config snapshot's document-format version, written by `masked_snapshot` and enforced by\n# `settings_from_snapshot`. Bump it when a snapshot written by this binary would be MISREAD by an\n# older one — i.e. when a new field changes paid, concurrency or selection behaviour and silently\n# defaulting it would resume the same event history under different semantics. `Settings` is\n# `extra=\"ignore\"`, so without this an older binary drops what it does not recognize and resumes\n# anyway, with no diagnostic; a version it does not know is now a loud refusal instead.\n# Version 1 is the first VERSIONED format. An absent key means a pre-versioning snapshot, which stays\n# readable through `LEGACY_CONFIG_SNAPSHOT_DEFAULTS` — that historical contract is unchanged.\nCONFIG_SNAPSHOT_SCHEMA = 1\nCONFIG_SNAPSHOT_SCHEMA_KEY = \"config_snapshot_schema\"\n\n\nclass ConfigSnapshotVersionError(ValueError):\n \"\"\"A config snapshot was written by a NEWER binary than the one reading it.\"\"\"\n\n\n# Pre-versioned snapshots are full Settings dumps, so absence is a reliable per-field version marker.\n# Keep their historical effective behavior when newer product defaults become active: re-entry must not\n# silently add paid calls, interventions, concurrency or a different selection policy to an old run.\n# CLAUDE REVIEW: [LOGIC] this map is incomplete against its own contract: several later-added flags\n# that default ON and add paid LLM work or change candidate generation (foresight/foresight_panel/\n# foresight_agentic/foresight_verify, concept_pivot, graded_novelty, the cross_run_* bundle,\n# memora_llm, comparative_lessons/lessons_every) are absent here, so any snapshot written BEFORE such\n# a field existed resumes with the paid product default ACTIVE — exactly the \"silently add paid\n# calls ... to an old run\" the paragraph above says re-entry must never do.\nLEGACY_CONFIG_SNAPSHOT_DEFAULTS: dict[str, object] = {\n \"parallel_build\": 1,\n \"eval_parallel\": None,\n \"llm_parallel\": None,\n \"train_monitor\": False,\n \"train_monitor_interval_s\": 600.0,\n \"train_monitor_kill\": False,\n \"train_monitor_kill_confidence\": 0.8,\n \"asha_live\": False,\n \"asha_live_kill\": False,\n \"asha_live_quantile\": 0.5,\n \"asha_live_min_siblings\": 3,\n # Before this operator ceiling existed, the sandbox's 24-hour process limit was authoritative.\n \"max_eval_timeout\": 24 * 3600.0,\n \"watchdog_reflection\": False,\n \"card_driven_selection\": False,\n \"speculation_depth\": 0,\n \"speculation_gate_receipt\": None,\n \"concurrent_research_repeat\": False,\n \"concurrent_research_interval_s\": 1800.0,\n # Zero means unlimited; 40 was the first bounded default, and is latent while repeat is disabled.\n \"concurrent_research_max_calls\": 40,\n \"concurrent_consolidate\": False,\n}\n\n\ndef migrate_config_snapshot(data: dict) -> dict:\n \"\"\"Return an effective copy of one legacy/current snapshot without rewriting evidence bytes.\"\"\"\n if not isinstance(data, dict):\n raise TypeError(\"config snapshot must be a JSON object\")\n migrated = dict(data)\n for key, value in LEGACY_CONFIG_SNAPSHOT_DEFAULTS.items():\n migrated.setdefault(key, value)\n return migrated\n\n\ndef config_snapshot_schema(data: dict) -> int:\n \"\"\"The snapshot's declared format version. 0 = pre-versioning (the key did not exist yet).\n\n A malformed value is treated as UNREADABLE rather than as 0: a snapshot whose own version marker\n is corrupt cannot be assumed to be the oldest format.\"\"\"\n raw = data.get(CONFIG_SNAPSHOT_SCHEMA_KEY, 0) if isinstance(data, dict) else 0\n if isinstance(raw, bool) or not isinstance(raw, int) or raw < 0:\n raise ConfigSnapshotVersionError(\n f\"config snapshot has a malformed {CONFIG_SNAPSHOT_SCHEMA_KEY}: {raw!r}\")\n return raw\n\n\ndef settings_from_snapshot(data: dict) -> Settings:\n \"\"\"Build re-entry Settings from a masked snapshot under its historical missing-field contract.\n\n Fails closed on a snapshot written by a NEWER binary. `Settings` is `extra=\"ignore\"`, so such a\n snapshot would otherwise load with every unrecognized control silently dropped — the run would\n resume on the same event history under different paid/concurrency/selection semantics, with\n nothing to show why. Refusing is the only honest answer: this binary cannot know what it is\n dropping. Older and pre-versioning snapshots keep loading exactly as before.\"\"\"\n found = config_snapshot_schema(data)\n if found > CONFIG_SNAPSHOT_SCHEMA:\n raise ConfigSnapshotVersionError(\n f\"this run's config.snapshot.json declares format v{found}, but this build understands \"\n f\"at most v{CONFIG_SNAPSHOT_SCHEMA}. It was written by a newer LoopLab; resuming here \"\n \"would silently drop settings this build does not know. Upgrade LoopLab to resume it.\")\n migrated = migrate_config_snapshot(data)\n migrated.pop(\"llm_api_key\", None)\n migrated.pop(CONFIG_SNAPSHOT_SCHEMA_KEY, None) # a document marker, never a Settings field\n return Settings(**migrated)\n"}, "d61ea57c48feb6f9341a41fc4d41b6b792070ee5": {"t": 1785768750.751284, "n": 112278, "h": "c722e42eab7f6804", "c": "\"\"\"Engine config: the schema (what settings exist). (I0, ADR-11)\n\nOne pydantic-settings class declaring every engine knob, its default, and its docs. The loader —\nhow a run file / CLI / env becomes a `Settings` — is `looplab.core.appconfig`, which also owns\nthe config-precedence order. A resolved, secret-masked snapshot is written next to each run for\nreproducibility. (No real secrets in P0, but the masking discipline is in place.)\n\"\"\"\nfrom __future__ import annotations\n\nimport re\nimport types\nimport typing\nfrom pathlib import Path\n\nfrom pydantic import Field, SecretStr, field_validator, model_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n# Single source of the LLM first-byte (response-headers) default — see core/llm.py.\nfrom looplab.core.llm import DEFAULT_HEADER_TIMEOUT_S\n\n# Default home for cross-run memory + the knowledge base (shared across every run). Both are ON by\n# default now (a user asked for it) so a fresh install accumulates + consults knowledge with no setup;\n# set the env var to \"\" to disable, or to a path to relocate. `~/.looplab/` keeps them out of any one\n# project + shared across projects.\n_LL_HOME = Path.home() / \".looplab\"\n\n# What one `llm_profiles` entry may contain. Deliberately closed: an unrecognized field is a typo the\n# operator would otherwise never see fail, and the map is a public artifact (config.snapshot.json,\n# HTTP, LOOPLAB_*), so it must never accrete free-form keys.\n_PROFILE_FIELDS = frozenset({\"model\", \"base_url\", \"temperature\", \"api_key_env\"})\n# A variable name that `runtime/sandbox.py::SECRET_ENV` will recognize as holding a secret and strip\n# from generated code's environment. Duplicated here, not imported: layering forbids `core` from\n# importing `runtime`. `tests/test_secret_env_pattern.py` holds the two in agreement.\n_SECRET_ENV_NAME = re.compile(\n r\"\\A(?=[A-Z][A-Z0-9_]{0,63}\\Z)(?=.*(KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL)).*\\Z\")\n\n\ndef _is_numeric_annotation(ann) -> bool:\n \"\"\"True when a field's annotation admits int/float but NOT bool.\n\n Used by `Settings._reject_bool_for_numeric_fields`: pydantic resolves annotations at class build,\n so `model_fields[...].annotation` is a real type object here even under\n `from __future__ import annotations`. Optionals are numeric when every non-None member is\n (`int | None` yes, `int | str` no), so a boolean can never slip in through a union arm.\"\"\"\n if ann is bool:\n return False # a bool field legitimately takes a bool\n if ann is int or ann is float:\n return True\n args = [a for a in typing.get_args(ann) if a is not type(None)]\n origin = typing.get_origin(ann)\n if args and origin in (typing.Union, types.UnionType):\n return all(_is_numeric_annotation(a) for a in args)\n return False\n\n# Autonomous roles that may be granted per-setting write access (see Settings.agent_control).\n# strategist = A7 meta-controller (run-wide); boss = run-chat operator-proxy (run-wide);\n# researcher = per-experiment proposer (per-node sizing, e.g. eval timeout for a heavy model).\nAGENT_ROLES = (\"strategist\", \"boss\", \"researcher\")\n\n# A foresight score is computed for every acted-on proposal. Keep this lower than the generic\n# verifier's emergency ceiling so a typo cannot multiply paid synchronous calls across a run.\nMAX_FORESIGHT_VERIFY_SAMPLES = 8\n\n\n# Layer-2 parallelism renamed two public settings without removing the durable legacy fields. Keep\n# the equivalence in one place: config-source precedence, governance migration, and live strategy\n# application must all agree which names describe the same authority/value.\nPARALLELISM_ALIASES: dict[str, str] = {\n \"max_parallel\": \"eval_parallel\",\n \"parallel_build\": \"llm_parallel\",\n}\n\n\ndef parallelism_aliases(setting: str) -> tuple[str, ...]:\n \"\"\"Return every spelling in ``setting``'s alias family, canonical name first.\"\"\"\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if setting in (legacy, canonical):\n return canonical, legacy\n return (setting,)\n\n\ndef canonicalize_parallelism_source(\n values: dict, *, promote_build_to_llm_parallel: bool = False,\n) -> dict:\n \"\"\"Promote legacy parallel keys *within one config-precedence source*.\n\n Applying this only after file/CLI/env were flattened would let a lower-priority canonical env\n value shadow a higher-priority legacy file value (or a file canonical value shadow a CLI legacy\n override), so callers with sub-layers normalize each layer independently.\n\n ``max_parallel``/``eval_parallel`` are true aliases (pure eval width, no extra semantics) and are\n always promoted. ``parallel_build``/``llm_parallel`` are NOT: a positive ``llm_parallel`` also flips\n on the finite shared LLM broker (``orchestrator._startup_llm_total``), while legacy ``parallel_build``\n means \"legacy build width, unbounded overlap, no shared total\". So promoting a legacy-only\n ``parallel_build`` layer (e.g. just ``LOOPLAB_PARALLEL_BUILD=1`` in env) would silently serialize\n every provider call — contradicting llm_broker.py's docstring, orchestrator's \"legacy-only preserves\n unbounded overlap\" note, the broker-neutral live-control path, and the configuration.md ``llm_parallel``\n row (\"falls back ... without enabling a shared total\"). Config/startup loads therefore keep broker\n opt-in tied to an EXPLICITLY-spelled canonical ``llm_parallel``; the orchestrator's own width fallback\n (``llm_parallel if not None else parallel_build``) still carries the legacy build width. Only the\n Strategist live-control path (``canonicalize_strategy_parallelism``) opts into\n ``promote_build_to_llm_parallel``, where ``parallel_build`` is a deliberate full alias of the\n canonical knob (see strategy.py ``_apply_strategy``).\n \"\"\"\n if not isinstance(values, dict):\n return values\n out = dict(values)\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if canonical == \"llm_parallel\" and not promote_build_to_llm_parallel:\n continue\n # None is the durable legacy mode for llm_parallel, not a missing value. A normal snapshot\n # contains llm_parallel:null plus parallel_build:1; promoting null to 1 would unexpectedly enable\n # the global one-call broker. Promote aliases only when the canonical key is absent, and pin\n # snapshot round-trip parity. Canonicalize inside each precedence layer, never across the\n # flattened result, so the alias migration cannot invert CLI > file > env precedence.\n if canonical not in out and out.get(legacy) is not None:\n out[canonical] = out[legacy]\n return out\n\n\ndef flatten_parallelism_layers(layers) -> dict:\n \"\"\"Flatten same-tier precedence layers (LOWEST priority first) and resolve the legacy pair.\n\n PRECEDENCE REPAIR for the non-promoted `parallel_build`/`llm_parallel` pair. Declining to promote\n in `canonicalize_parallelism_source` is right (a legacy-only layer must not flip on the shared\n broker), but it let a LOWER-priority canonical value survive a HIGHER-priority legacy override:\n file `llm_parallel: 2` + CLI `-s parallel_build=8` flattened to BOTH keys, and the orchestrator\n prefers `llm_parallel`, so the operator's explicit CLI width was silently discarded. When a higher\n layer names the legacy width over a lower layer's canonical one, mask with the LEGACY SENTINEL\n (`llm_parallel=None` is the durable \"legacy mode — build width from parallel_build, no shared\n total\"), so the higher layer wins with the semantics it asked for.\n\n The mask lives HERE, not in the per-layer canonicalizer, because only a flatten knows the layer\n ORDER. Stamping it per layer meant any layer naming `parallel_build` emitted `llm_parallel=None`\n even when NO layer in the tier set the canonical key — and since pydantic-settings ranks init\n kwargs above env, that sentinel then silently overrode a `LOOPLAB_LLM_PARALLEL` the operator had\n exported as a deliberate provider rate/cost ceiling. Precedence is per FIELD: a tier that never\n spelled `llm_parallel` has not set it, and it must fall through to env/.env/defaults — which is\n exactly what `build_settings`' docstring promises (\"the merged dict wins only where it actually\n sets a value\").\n \"\"\"\n merged: dict = {}\n legacy_at = canonical_at = -1\n for index, layer in enumerate(layers):\n if not isinstance(layer, dict):\n continue\n merged.update(layer)\n if layer.get(\"parallel_build\") is not None:\n legacy_at = index\n if \"llm_parallel\" in layer:\n canonical_at = index\n # Only when this tier DID set the canonical key, at a strictly lower layer than the legacy one.\n if canonical_at >= 0 and legacy_at > canonical_at:\n merged[\"llm_parallel\"] = None\n return merged\n\n# Settings whose semantics are committed by ``run_started`` and restored from the folded event log\n# on every re-entry. Changing one only in ``config.snapshot.json`` would mix incompatible evidence\n# (a different holdout split) or make the live producer disagree with replay's selection policy.\n# The HTTP config editor and the engine both consume this one contract; keep deliberately mutable\n# controls such as ``trust_gate`` out (that field has its own durable change event).\nRUN_START_PINNED_FIELDS = frozenset({\n \"card_driven_selection\",\n \"speculation_depth\",\n \"holdout_fraction\",\n \"holdout_select\",\n \"select_verifier\",\n \"select_verifier_samples\",\n \"verifier_ci_tie\",\n})\n\n\ndef run_start_pinned_settings(state) -> dict:\n \"\"\"Return the effective Settings names committed by a folded run-start record.\n\n Pre-pin legacy logs have no recorded holdout fraction, so their snapshot remains authoritative\n for both holdout knobs. Verifier fields, however, have always been re-pinned from the legacy-safe\n fold defaults whenever a valid ``run_started`` identity exists.\n \"\"\"\n if not getattr(state, \"run_id\", \"\"):\n return {}\n values = {\n # Layer 3 changes which queue owns macro-action selection. Once a run starts, replay and\n # resume must keep the same owner even if the snapshot or ambient default changes later.\n \"card_driven_selection\": bool(getattr(state, \"card_driven_selection\", False)),\n # Layer 5 overlaps Card production with evaluation only when this pinned treatment is\n # positive. Old logs omit it and therefore retain the exact serial build/eval spine.\n \"speculation_depth\": int(getattr(state, \"speculation_depth\", 0)),\n \"select_verifier\": bool(getattr(state, \"select_verifier_tiebreak\", False)),\n \"select_verifier_samples\": int(getattr(state, \"select_verifier_samples\", 3)),\n \"verifier_ci_tie\": bool(getattr(state, \"verifier_ci_tie\", False)),\n }\n holdout_fraction = getattr(state, \"holdout_fraction\", None)\n if holdout_fraction is not None:\n values.update({\n \"holdout_fraction\": holdout_fraction,\n \"holdout_select\": bool(getattr(state, \"holdout_select\", False)),\n })\n if not values.keys() <= RUN_START_PINNED_FIELDS:\n raise RuntimeError(\"folded run-start pinned settings contract drifted\")\n return values\n\n# --- Run profiles (config-first presets) -------------------------------------------------------\n# A `profile` is a NAMED BUNDLE of setting defaults — nothing more. It only fills fields the user\n# did NOT set explicitly (via file / --set / LOOPLAB_* env / init-kwargs), so any explicit knob\n# always wins (see `_apply_profile`). Most engine intelligence lives behind individual flags. The\n# PART IV/V cross-run + concept-graph machinery (concept_pivot, graded_novelty, cross_run_concepts /\n# _advisory / _structured_claims / _curation / _read_tools, fingerprint_universal, foresight_verify)\n# now ships ON in the product `Settings` default. That is an explicit experimental product choice,\n# not a claim that a frozen A/B or workload-wide cost/quality gate has cleared: the bundle can steer\n# prompts and candidate generation, change graded-novelty admission / pre-execution choices, and add\n# paid LLM work when its client and memory prerequisites are present. The bare-library `EngineOptions`\n# default stays lean (see tests/test_options_divergence.py — product side is the aggressive one) so a\n# toy `Engine(...)` in a test doesn't fire cross-run LLM work unasked. The trust/confirm/ablate QUALITY\n# bundle still ships OFF and is turned on by `thorough`. Every value here is reachable by hand — the\n# profile is a convenience, never a hidden mode.\n# keep this rationale behavioral and evidence-based; do not relabel read-only storage\n# access or proposal-only governance as a no-op for agent behavior, cost, or proposal admission.\n#\n# `thorough` deliberately touches only QUALITY/TRUST machinery, not spend: it does NOT raise\n# max_nodes / eval_parallel (those are cost knobs the user owns). It enables multi-seed confirmation\n# (demote seed-lucky leaders), the reward-hack / leakage / critic monitors AND flips them from\n# advisory to *gating* (`trust_gate=\"gate\"` — a\n# flagged win can no longer be selected as best), ablation-driven refinement, and the prompt cues\n# (complexity / budget / failure-reflection / watchdog-reflection). Agentic novelty and the experimental\n# Part IV/V bundle are already product defaults, not profile changes. Effect is large; marginal cost is a\n# few extra evals.\nPROFILES: dict[str, dict] = {\n \"default\": {}, # explicit no-op alias for \"ship-as-is\" (== omitting profile)\n \"fast\": {}, # today's lean defaults, named for symmetry with `thorough`\n \"thorough\": {\n \"confirm_top_k\": 3,\n \"confirm_seeds\": 3,\n # novelty_gate intentionally NOT enabled here: duplicate-detection is the agentic Researcher's\n # job (it reads past experiments + find_analogous_across_runs and DECIDES), not an algorithmic\n # param-distance / embedding auto-reject that mis-fired (it nudged a good fresh idea onto a\n # twice-dead lineage — live node 61). Search stays as tools; the LLM judges novelty.\n \"operator_bandit\": True, # P4: adaptive operator mix over folded yields (GreedyTree)\n \"reward_hack_detect\": True,\n \"code_leakage_detect\": True,\n \"critic_check\": True,\n \"trust_gate\": \"gate\", # detectors stop gating a flagged node from WINNING (not just audit)\n \"ablate_every\": 3,\n \"complexity_cue\": True,\n \"budget_aware\": True,\n \"failure_reflection\": True,\n \"watchdog_reflection\": True, # feed recent live-watchdog (train-monitor/ASHA) flags to proposals\n \"reflection_priors\": True, # no-op unless memory_dir is set (cross-run priors)\n },\n}\n\n\n# The shipped agent-governance matrix (who may change which setting at runtime). Defined as a module\n# constant so the Engine's direct-construction default matches this shipped default exactly (the\n# EngineOptions \"Engine() == shipped defaults\" invariant) instead of diverging to an all-locked map.\nDEFAULT_AGENT_CONTROL: dict[str, list[str]] = {\n \"timeout\": [\"researcher\", \"strategist\"],\n # Layer-2 canonical names are the presented/default governance surface. `_agent_may` falls back\n # to an old snapshot's legacy grant only when its canonical counterpart is absent, so removing\n # duplicate defaults preserves resume authority while an explicit canonical revocation still wins.\n \"eval_parallel\": [\"strategist\"],\n \"llm_parallel\": [\"strategist\"],\n # Strategy-only pseudo-setting: the closed per-lane allocation inside the canonical LLM total.\n # It is not a launch Settings field; operators pin it through `set_strategy` and the Strategist\n # may reallocate it only while this grant is present.\n \"llm_lane_limits\": [\"strategist\"],\n \"max_nodes\": [\"strategist\", \"boss\"],\n \"max_eval_seconds\": [\"strategist\", \"boss\"],\n \"policy\": [\"strategist\"],\n # The policy NAME and its PARAMS are gated independently in `_apply_strategy`; grant both by default\n # so the Strategist that may switch to (say) MCTS may also apply the `c`/`eta` it decided — else its\n # tuned params are silently dropped and the recorded `active_strategy` diverges from the live engine.\n \"policy_params\": [\"strategist\"],\n \"n_seeds\": [\"strategist\"],\n \"ablate_every\": [\"strategist\"],\n \"merge_mode\": [\"strategist\"],\n \"complexity_cue\": [\"strategist\"],\n \"ablate_code_blocks\": [\"strategist\"],\n \"prefer_sweep\": [\"strategist\"],\n \"novelty_stance\": [\"strategist\"],\n \"developer\": [\"strategist\"],\n \"fidelity\": [\"strategist\", \"researcher\"],\n}\n\n\ndef default_agent_control() -> dict[str, list[str]]:\n \"\"\"A FRESH deep-ish copy of the shipped governance matrix (each role list copied so a caller can\n mutate its own map without touching the module constant). The SINGLE constructor of the default,\n called by both `Settings.agent_control`'s default_factory AND the Engine's direct-construction\n default — so \"Engine() == shipped defaults\" can't drift if the copy depth ever needs to change\n (e.g. nested values requiring copy.deepcopy), instead of two verbatim copy expressions.\"\"\"\n return {k: list(v) for k, v in DEFAULT_AGENT_CONTROL.items()}\n\n\nclass Settings(BaseSettings):\n \"\"\"The engine settings schema (every knob a run accepts).\n\n Timeout family — six distinct knobs, each owned by a different subsystem:\n - `timeout`: per-eval wall-clock budget for ONE experiment's evaluation (engine/eval).\n - `max_eval_timeout`: hard ceiling for a governed per-node Researcher timeout override.\n - `llm_timeout`: LLM request idle timeout — inter-token stall limit in stream mode\n (core.llm OpenAICompatibleClient).\n - `llm_header_timeout`: LLM first-byte (response-headers) window for stream attempts (core.llm).\n - `agent_time_budget_s`: wall-clock ceiling across a tool-using agent loop's turns (agents).\n - `dep_install_timeout`: per-package budget for auto-installing a missing dep (env self-prep).\n\n Config-source precedence is owned by `looplab.core.appconfig` (the loader) — see its module\n docstring for the one canonical order.\n \"\"\"\n # Config precedence: see appconfig.py — the loader owns the full order. The `.env` is read\n # so `looplab run`/`looplab ui` pick up LOOPLAB_LLM_BASE_URL etc. without exporting them by hand\n # (keys MUST carry the LOOPLAB_ prefix, same as the env vars). utf-8-sig tolerates a BOM from\n # Windows editors. The test suite disables this (tests/conftest.py) so a dev's real .env in the\n # repo root can't leak into assertions.\n model_config = SettingsConfigDict(\n env_prefix=\"LOOPLAB_\",\n env_file=\".env\",\n env_file_encoding=\"utf-8-sig\",\n extra=\"ignore\",\n allow_inf_nan=False,\n )\n\n @classmethod\n def settings_customise_sources(\n cls, settings_cls, init_settings, env_settings, dotenv_settings,\n file_secret_settings):\n \"\"\"Preserve source precedence across canonical/legacy parallelism names.\n\n Pydantic ranks init > env > dotenv correctly, but it ranks field names rather than semantic\n alias families. Promoting a legacy value inside each source before the merge makes an old\n explicit snapshot/file value beat a lower-priority canonical env value. ``appconfig`` also\n promotes its file/typed/--set sub-layers before combining them into the init source.\n \"\"\"\n def _with_parallel_aliases(source):\n return lambda: canonicalize_parallelism_source(source())\n\n return (\n _with_parallel_aliases(init_settings),\n _with_parallel_aliases(env_settings),\n _with_parallel_aliases(dotenv_settings),\n _with_parallel_aliases(file_secret_settings),\n )\n\n # Run profile (config-first preset, see PROFILES above). \"default\"/\"fast\" = today's lean\n # defaults; \"thorough\" turns the built quality/trust machinery ON in one word. A profile only\n # fills fields the user did NOT set — every knob it touches stays individually overridable.\n profile: str = \"default\"\n\n # Bounds (review C/⚪): a non-positive node/seed budget or timeout is never valid. Upper bounds are\n # equally load-bearing — the UI /start path writes these straight into the engine's env, and an\n # unbounded `eval_parallel` (or legacy `max_parallel`; e.g. 100000 from a crafted preflight) would\n # fan out that many sandboxes → resource exhaustion. Reject at config time, not mid-run. Ceilings\n # are generous; tests top out near 50.\n # Pydantic's lax mode coerces a JSON boolean into any numeric field (`true` -> 1), so a UI/API type\n # error used to silently collapse a run budget to ONE node instead of returning 422. Rejected for\n # EVERY numeric field by `_reject_bool_for_numeric_fields` below, not just the ones bounded here.\n n_seeds: int = Field(default=3, ge=1, le=1024)\n max_nodes: int = Field(default=8, ge=1, le=1_000_000)\n # legacy evaluation-width input. `eval_parallel` below is canonical and wins when set;\n # keep this field for old env/config/snapshots and direct Engine call sites. A width > 1 admits\n # concurrent evals, whose resource reservations may be CPU-only or span one/more GPUs — there is no\n # one-node/one-GPU promise. `max_parallel=0` is launch-time AUTO (detected GPU count, at least 1).\n max_parallel: int = Field(default=1, ge=0, le=1024)\n # legacy build-width input. `llm_parallel` below is canonical and also controls the\n # shared provider-call total when positive. A width > 1 uses fresh pooled researcher/developer pairs;\n # it says nothing about the later eval's CPU/GPU reservation. Startup `0` derives build fan-out from\n # resolved eval width; a live Strategist/operator zero settles to 1. Without role_factory it clamps to 1.\n parallel_build: int = Field(default=1, ge=0, le=64)\n # Hypothesis-card Kanban re-architecture (docs/23, Layer 2). Two INDEPENDENT concurrency axes,\n # decoupled from each other so cost (LLM work) can be tuned separately from throughput (GPU evals).\n # These are the CANONICAL names going forward; `max_parallel`/`parallel_build` above are kept as thin\n # back-compat aliases (env vars / snapshots / ~100 Engine(...) call sites depend on the exact names,\n # so they cannot be renamed — CLAUDE.md). Resolution (orchestrator.__init__): a NEW name, when set,\n # WINS over its legacy alias; None => fall back to the legacy field => byte-identical to today.\n # `eval_parallel`: concurrent EVALS (the GPU/experiment consumer). None -> max_parallel; 0 = AUTO\n # (one experiment per detected GPU). This is the in-Run eval-concurrency ceiling; the resource\n # scheduler bin-packs declared footprints separately. Sibling local GPU-owning Runs serialize\n # through the conservative host-pool lease; external execution needs its own admission boundary.\n # `llm_parallel`: total concurrent LLM provider calls + BUILD fan-out. None -> legacy\n # parallel_build semantics without a shared total; 0 = startup AUTO (= resolved eval_parallel\n # for build fan-out, historical research overlap unbounded). A positive canonical value enables\n # the named-lane broker the Strategist can reallocate at cadence.\n eval_parallel: int | None = Field(default=None, ge=0, le=1024)\n llm_parallel: int | None = Field(default=None, ge=0, le=64)\n # Training-log monitor (I-series watchdog family): a per-eval background observer that tails the live\n # training log while a (often multi-hour) declared stage runs in a worker thread. ON in the product\n # surface (Settings). Its alert event is fold-ignored and cannot directly change lifecycle/champion;\n # with watchdog_reflection on, that alert is nevertheless advice in a later Researcher\n # prompt and can change future proposals. It only fires on the command-eval path with an LLM client;\n # bare-library EngineOptions stays OFF. See test_options_divergence.\n # `_interval_s` is the BASE tick cadence (the effective one adapts to the per-experiment budget).\n train_monitor: bool = True\n train_monitor_interval_s: float = Field(default=600.0, gt=0)\n # Phase 3 — INTERVENTION (opt-in, separate from observation): let the monitor tree-kill a training the\n # LLM judges 'broken' (diverged / silent CPU fallback / not learning) EARLY, instead of burning the\n # whole budget. Off by default — the observer only WATCHES unless this is on. A kill only fires on a\n # 'broken' verdict (a plateau is 'watch', never killed) with confidence >= the threshold; the node then\n # fails normally (reason='monitor_broken'), so replay reconstructs it from that one terminal event.\n train_monitor_kill: bool = False\n train_monitor_kill_confidence: float = Field(default=0.8, ge=0.0, le=1.0)\n # ASHA live-curve watchdog (sibling of the training monitor): reads the latest INTERMEDIATE value of\n # the objective metric off the live log (reusing the eval's OWN metric reader). Finished-endpoint rank\n # remains advisory. An opt-in KILL additionally requires an operator-declared metric.resource_key and\n # enough sibling observations at exactly that resource; absent comparable evidence cannot stop a run.\n # Records a fold-ignored `asha_rank` diagnostic + trace span; the library default is OFF (off == today).\n asha_live: bool = True\n # Opt-in INTERVENTION (separate from the advisory signal): let the watchdog tree-kill a node whose\n # intermediate metric stays below the SAME-RESOURCE bar past a short grace window. Off by default —\n # it only surfaces endpoint rank unless this is on. A kill fails normally (asha_underperforming).\n asha_live_kill: bool = False\n # The bar sits at `asha_live_quantile` along a WORST->BEST ordering of the finished siblings' finals:\n # 0.5 = the median (a node worse than the median peer flags); a SMALLER value is more conservative —\n # it lowers the bar toward the WORST finished peer, so only a node worse than nearly all peers flags\n # (0.0 = worse than the worst). `min_siblings` is the floor of finished peers required before it ranks\n # at all, so it never acts on too little evidence.\n asha_live_quantile: float = Field(default=0.5, ge=0.0, le=1.0)\n asha_live_min_siblings: int = Field(default=3, ge=1)\n timeout: float = Field(default=30.0, gt=0)\n # Hard ceiling for the Researcher's governed per-node `Idea.eval_timeout`. One hour preserves the\n # existing heavy/neural-net authoring example while staying well below the sandbox's defensive\n # 24-hour subprocess ceiling. This is operator-owned run config: agents may request less or more,\n # but the accepted action is always clamped here after the `agent_control.timeout` permission gate.\n max_eval_timeout: float = Field(default=3600.0, gt=0, le=24 * 3600.0)\n # Intra-node sweep: a sweep node runs a whole grid in one process, so it gets this multiple of\n # `timeout` as its wall-clock budget (solution.py path; RepoTasks use their per-profile timeout).\n sweep_timeout_mult: float = Field(default=8.0, ge=1)\n # Eval STALL watchdog CAP (seconds): a stage that is completely SILENT on stdout/stderr for this\n # long — while still alive and BELOW its wall-clock deadline — is tree-killed early with a STALLED\n # marker (a hung dataloader/deadlock dies in minutes instead of burning a multi-hour timeout). The\n # per-stage window is `min(this, the stage's own timeout)`, so a short scorer's deadline fires first.\n # `0` DISABLES the watchdog entirely (only the hard deadline applies) — for a legitimately quiet\n # non-Python stage (block-buffered stdout, a script that logs only to its own file). Threaded into\n # the eval and surfaced to the Developer so its code can emit periodic progress to stay alive.\n eval_stall_timeout_s: float = Field(default=1800.0, ge=0)\n # Sandbox tier (ADR-13): \"trusted_local\" (subprocess, no Docker) for the CLI;\n # \"untrusted\" (Docker --network none, shared-kernel runtime) for hosted/multi-tenant UI;\n # \"hostile\" (untrusted + a true-isolation OCI runtime, gVisor `runsc` by default / Kata) for\n # running untrusted code. See runtime/sandbox.py::make_sandbox for the tier selection.\n trust_mode: str = \"trusted_local\"\n # Image for the untrusted command-eval tier (RepoTask, Phase 4): the framework's deps\n # should be baked in (the container runs --network none, so a pip setup can't fetch).\n docker_image: str = \"python:3.12-slim\"\n # Resource caps for the untrusted/hostile Docker tier (passed to `docker run --memory/--cpus`).\n # The default 4g bound protects other tenants from an OOM, but a model-training MLE-bench eval\n # routinely needs more — raise it (e.g. \"16g\") for such runs. \"\" disables the cap (unbounded).\n # Ignored by the trusted_local (subprocess) tier.\n sandbox_memory: str = \"4g\"\n sandbox_cpus: str = \"\"\n # Best-effort host-OOM guard for the TRUSTED-LOCAL (subprocess) tier. When set (e.g. \"8g\"), each\n # eval child gets an RLIMIT_AS address-space cap so a runaway trainer hits MemoryError instead of\n # OOM-killing the whole host (and the engine). POSIX only; a no-op on Windows. Default \"\" = OFF:\n # RLIMIT_AS bounds VIRTUAL memory, and CUDA/torch reserve tens of GB of virtual without using it,\n # so a default cap would spuriously kill GPU evals — leave it off for those and use the Docker\n # tier's real --memory cgroup bound (sandbox_memory) instead. Ideal for CPU/numpy/sklearn runs.\n sandbox_memory_local: str = \"\"\n # Best-effort disk-fill guard for the TRUSTED-LOCAL tier (P1-5): an RLIMIT_FSIZE cap on the size of\n # any single file an eval child writes (e.g. \"2g\"), so a runaway that writes an unbounded file gets\n # SIGXFSZ instead of filling the host disk. POSIX only. Default \"\" = OFF (large model checkpoints\n # need big files); set it for tasks that write only small artifacts.\n sandbox_fsize_local: str = \"\"\n # Search policy (ADR-2): \"greedy\" | \"evolutionary\" | \"mcts\" | \"asha\".\n policy: str = \"greedy\"\n # Ablation-driven refinement (I7): every N improves, ablate the best to find the\n # highest-impact parameter and refine it. 0 = off. (GreedyTree only.)\n ablate_every: int = 0\n # A0a (MLE-STAR): when ablating, treat each generated pipeline *code block* (not just a\n # numeric param) as an ablation unit — neutralize a block, measure the metric delta, refine\n # the highest-impact block. Off => the classic numeric-param ablation. Config-first knob.\n ablate_code_blocks: bool = False\n # A0b: real merge/ensembling. \"mean\" = legacy mean-param merge; \"ensemble\" = the Developer\n # writes a code-recombination ensemble over the two parents' solutions (verified: agent-proposed\n # ensembling 37.9%->43.9%). \"auto\" (default) = ensemble whenever the Developer actually\n # generates code (LLM/agent backends; declared via `is_code_generating`), mean for the\n # templated/toy developers where a code ensemble is meaningless — the evidence says code\n # recombination is the strongest single merge (removing it costs ~9 pp), so it is the default\n # wherever it can work. Falls back to mean when the Developer can't ensemble.\n merge_mode: str = \"auto\"\n # A0d (AIRA): inject a dynamic complexity hint into the draft/improve prompt keyed on the\n # node's child count (few children -> keep minimal; many -> escalate to ensembling/HPO).\n complexity_cue: bool = False\n # I1 (CAAFE-style) LLM feature-engineering: instruct the proposer/developer to add\n # semantically-meaningful engineered features (ratios, interactions, aggregations) as code. The\n # existing cross-validation eval + best-selection is the CV GATE — a feature set that doesn't\n # improve CV is never selected (feature engineering is non-universal, so this gate is mandatory).\n # Tabular tasks (those exposing column data). Off by default.\n feature_engineering: bool = False\n # A5: surface the REMAINING eval-compute budget into the proposal prompt so the Researcher can\n # reason over how much compute is left (explore broad while flush, exploit/cheapen when low).\n # No-op unless a max_eval_seconds budget is set.\n budget_aware: bool = False\n # A4 (LATS-style): feed a summary of the most recent FAILED branches (operator + error reason)\n # back into the proposal prompt so the proposer reflects on and avoids repeating them. ON by\n # default: it is SELECTIVE by construction (injects only when recent failures exist — the\n # evidenced form of reflection; reflecting at every step shows no gains, arXiv:2506.12928).\n failure_reflection: bool = True\n # Feed the LIVE-WATCHDOG observations (train-monitor health verdicts + ASHA intermediate-rank flags)\n # of recent experiments back into the proposal prompt, so the Researcher avoids re-proposing a config\n # whose TRAINING was already observed to be weak — even when the watchdog kills are off (default) and\n # the node ran to completion (those diagnostics are fold-ignored, so failure_reflection never sees\n # them). ON by default and SELECTIVE like failure_reflection (injects only when a recent flag exists).\n watchdog_reflection: bool = True\n # C3 deep test-driven repair: hand the Developer the failure taxonomy + a structured \"reproduce\n # then fix\" directive on debug, not just the raw stderr tail. ON by default (product surface); the\n # conservative library default in EngineOptions stays off, per that module's contract.\n deep_repair: bool = True\n # === Inline crash repair ==============================================================\n # Hybrid in-node crash repair: when an LLM-generated node CRASHES at runtime (mechanical errors\n # — bad import, removed kwarg, typo), the agent triages it and may repair the code IN PLACE within\n # the same eval (cheap; does NOT consume max_nodes and does NOT add a node to the search tree).\n # Semantic failures still flow to a new idea/debug node via the policy. ON by default; set False\n # to restore the prior behavior (every crash waits for a budgeted inter-node debug node).\n inline_repair: bool = True\n # Max in-place repair attempts per node before it fails normally. Default 0 = UNLIMITED: the same\n # agent keeps fixing + re-evaluating IN THE SAME node until it produces a clean metric — no new\n # node just to retry. Runaway is prevented not by a count but by the anti-stuck guard\n # (`inline_repair_stuck_repeat`): when the SAME error signature repeats with no progress, or the\n # agent's crash-triage says \"abandon\", the node fails. Set a positive N to cap the retries instead.\n inline_repair_attempts: int = Field(default=0, ge=0)\n # Anti-stuck: abandon in-node repair when the SAME error recurs this many times in a row (no\n # progress). Keeps \"unlimited\" repair from looping forever on an unfixable error.\n inline_repair_stuck_repeat: int = Field(default=4, ge=2)\n # Which failure reasons (_failure_reason: crash|timeout|oom|setup|no_metric|drift) are eligible for\n # inline repair. Default: mechanical crashes, timeouts AND OOM-kills — a timeout/OOM means the code\n # was too slow / too memory-hungry for the budget (or the pod's cgroup limit), not that the idea is\n # wrong, so the agent repairs it by reducing compute/memory (fewer estimators/epochs/folds/seeds, a\n # lighter model, a smaller batch, subsampling) instead of letting the node die unrecovered. Env\n # override expects a JSON array.\n inline_repair_reasons: tuple[str, ...] = (\"crash\", \"timeout\", \"oom\")\n # Cost ceiling for in-node repair of a MULTI-STAGE eval: the max number of FULL pipeline re-runs\n # (i.e. re-trains from the first stage) the inline-repair loop may do before abandoning to the\n # inter-node debug operator. A late-stage failure (a broken `score`/eval script that didn't touch\n # `train`) REUSES the completed train checkpoint and re-runs only from the failed stage — that is\n # CHEAP and NOT counted here; only a repair that changes an EARLIER stage's code (train.py/loss.py/…)\n # forces a full re-train, which each costs the whole training time. Without this bound, a repair that\n # keeps changing training code could burn many full trains (the anti-stuck guard is error-signature\n # based, not cost based). 0 = unlimited (legacy behavior). Single-command evals ignore it.\n inline_repair_retrain_cap: int = Field(default=2, ge=0)\n # Environment self-prep: when a solution crashes purely because a KNOWN library isn't installed\n # (ModuleNotFoundError), the engine pip-installs it into the eval interpreter and re-runs — so a\n # torch/XGBoost/CatBoost idea (e.g. a GRU model) actually runs on a fresh box instead of being\n # thrown away as `idea_rejected`. Trusted_local tier only (the untrusted/hostile Docker tiers run\n # --network none). A typo'd or local helper module is NOT installed (that's a code bug to repair).\n auto_install_deps: bool = True\n # Per-package wall-clock budget for an auto-install (seconds). Generous: large wheels like torch\n # can take minutes to download/build.\n dep_install_timeout: float = Field(default=900.0, gt=0)\n # --- Agent governance: who may change a setting at runtime (per-setting allow-list of roles) ---\n # Roles: \"strategist\" (A7 meta-controller; run-wide), \"boss\" (run-chat operator-proxy; run-wide),\n # \"researcher\" (per-experiment proposer; PER-NODE — e.g. set a longer eval timeout for a neural-net\n # idea). A setting ABSENT from this map is LOCKED — no agent may change it, only a human via the\n # snapshot/UI. The sole conditional exception is Strategy-only `card_scoring`: Card mode grants\n # it implicitly without changing the default flag-off snapshot, while an explicit [] revokes it.\n # The gate is ENFORCED at every AGENT seam via `_agent_may` — the strategist's whole\n # control surface (policy/operators/novelty_stance/prefer_sweep/developer/fidelity/timeout/\n # eval_parallel/llm_parallel) is checked in `_apply_strategy`, so removing a role from a knob locks it,\n # not just greys out the UI pill. The boss/max_* grants document boss-relevant knobs, but a\n # `budget_extend` is a HUMAN control intent (the boss action-builder can only emit `add_nodes`), so\n # it is applied as-is — the human always wins via the UI/snapshot. Defaults are conservative: resource/\n # search-shape knobs the agents already reason about; provider infra (llm_model/base_url/api_key,\n # docker_image) stays locked. The UI shows S/B/R pills per setting. `timeout` granted to the\n # researcher IS the \"auto\" per-node mode — the researcher sizes each experiment's wall-clock\n # (Idea.eval_timeout); the config `timeout` is the fallback for nodes it doesn't size. Env override\n # expects a JSON object. (`fidelity`/`novelty_stance`/`prefer_sweep`/`developer` are governance keys\n # for the strategist's per-node dials — not 1:1 Settings fields, but gated the same way.) The default\n # lives in the module-level DEFAULT_AGENT_CONTROL so the Engine's direct-construction default matches.\n agent_control: dict[str, list[str]] = Field(default_factory=default_agent_control)\n # C1 (Agentless localization): for repo tasks, rank the source files most relevant to the most\n # recent failure (traceback + identifiers) and surface them in the proposal/repair prompt so the\n # Developer edits the right place. Off by default; repo tasks only.\n localize_faults: bool = False\n # A2 surrogate-guided proposal (BO-lite): fit a k-NN surrogate over (params->metric) and propose\n # by acquisition instead of random/hill-climb. Numeric-bounds tasks only; bootstraps via the\n # base Researcher. Off by default. `surrogate_explore` = UCB-style exploration weight.\n surrogate_proposer: bool = False\n surrogate_explore: float = 0.1\n # E2 researcher panel: generate K candidate ideas per proposal and keep the one ranked best by a\n # cheap empirical surrogate over the (params->metric) history (NOT an LLM-judge). 1 = off.\n researcher_panel: int = 1\n # A1 ASHA / successive-halving: reduction factor (keep top 1/eta per rung) and rung budget.\n asha_eta: int = Field(default=3, ge=2)\n # Rung-0 width for ASHA/BOHB (the wide base of cheap drafts). 0 = use n_seeds (default,\n # preserving prior behavior); set >0 to seed a wider/narrower base independent of n_seeds.\n asha_rung_nodes: int = Field(default=0, ge=0)\n # A6 proxy/predictive scoring: cheaply rank a candidate's potential from early-stage signals\n # and skip a full eval for the doomed bottom fraction. 0.0 = off (no candidate skipped).\n proxy_scoring: bool = False\n proxy_kill_fraction: float = Field(default=0.0, ge=0.0, le=0.9)\n # === Novelty / dedup ==================================================================\n # E1 novelty/dedup gate: reject near-duplicate proposals (normalized param-space distance below\n # `novelty_epsilon`) by deterministically nudging them off the duplicate — stops the search\n # wasting evals re-trying the same idea. Audit event `novelty_rejected`. Off by default.\n # Novelty/dedup MODE selector: how a fresh proposal is checked against already-tried experiments.\n # \"off\" — no explicit gate; the agentic Researcher's own read-the-history judgment is trusted.\n # \"algo\" — the deterministic gate: numeric param-distance (`novelty_epsilon`) + optional embedding\n # similarity (`novelty_semantic`). Cheap, no LLM call, but can't explain itself.\n # \"llm\" — an LLM adjudicates (reads the real experiments via tools) whether the idea is a\n # near-duplicate and, if so, asks the Researcher once more for something different. One\n # extra LLM call per proposal — highest quality, follows the \"let the LLM decide\" line.\n novelty_mode: str = \"llm\"\n # Legacy sub-toggles, honored by the \"algo\" mode (and back-compat: novelty_gate=True forces \"algo\"):\n novelty_gate: bool = False\n novelty_epsilon: float = 0.05\n # T5 semantic novelty (active whenever the deterministic gate runs — novelty_mode=algo,\n # novelty_gate=true which is the legacy alias for algo, OR the Strategist novelty stance is\n # \"explore\"; a no-op only under novelty_mode=llm/off with a non-explore stance): reject a proposal\n # whose idea TEXT embeds within\n # `novelty_semantic_threshold` cosine of an existing node's. OFF BY DEFAULT: novelty is the agentic\n # Researcher's call, made by READING the actual prior experiments (read_experiment /\n # find_analogous_across_runs / list_experiments), NOT an embedding-similarity auto-reject that can't\n # explain itself and mis-fires on paraphrases. Semantic/param search stays available as TOOLS in the\n # Researcher's context; the LLM decides whether an idea is a true duplicate.\n novelty_semantic: bool = False\n novelty_semantic_threshold: float = Field(default=0.92, ge=0.5, le=1.0)\n # T10: debug-lineage depth bound for every policy — how many error-feedback repairs a failing\n # lineage gets before it is abandoned. The old default (1) abandoned lineages after ONE repair;\n # multi-turn/deeper debugging is a verified lever (AIRA2 ReAct debug +5.5 percentile points).\n debug_depth: int = Field(default=2, ge=1)\n # P4 operator bandit (GreedyTree): replace the fixed merge/ablate cadences with a\n # deterministic UCB over per-operator yield (Δmetric per eval-second, folded from the run).\n # Off by default (the cadences are well-tested and the bandit has no direct published\n # ablation); `thorough` turns it on.\n operator_bandit: bool = False\n # P1 hypothesis ledger: ask the Researcher to state the one-line hypothesis each experiment tests,\n # register deep-research directions as hypotheses, and track them to a verdict on the board. ON by\n # default. The board is shown to the Researcher and can be foresight-ordered, so it can\n # steer later proposals even though it never directly re-ranks already-evaluated nodes. Set False\n # to drop the prompt nudge + registration.\n track_hypotheses: bool = True\n # E4/M2/M3 reflection-memory -> priors (gradient-free cross-run meta-learning): at run end distill\n # the winner into `<memory_dir>/meta_notes.jsonl` AND structured lessons (incl. NEGATIVE results:\n # tested/abandoned hypotheses + failure themes) into `lessons.jsonl` with a task fingerprint; at run\n # start, inject exact-task notes + fingerprint-matched lessons from SIMILAR tasks into the proposal\n # prompt. ON by default — but a NO-OP until `memory_dir` is set (that's where cross-run memory lives).\n reflection_priors: bool = True\n # M6 comparative lessons (MARS \"comparative reflective memory\", doc 13 §7 item 2): distill\n # credit-assigned lessons from PAIRS of solutions — which SPECIFIC change made a child beat\n # (or regress from) its parent, and what fixed a failure — on top of the one-shot run-end\n # reflection. At most one extra LLM call per distillation (all pairs batched); offline a\n # deterministic param-diff credit stands in. Gated on reflection_priors + memory_dir like the\n # rest of cross-run memory — and since memory_dir has a default (~/.looplab/memory), this is\n # ACTIVE out of the box; clear memory_dir to disable cross-run memory wholesale.\n comparative_lessons: bool = True\n # M6 mid-run cadences (doc 13 §7 item 5 — the AgentRxiv live-share pattern): every N created\n # nodes, WRITE comparative lessons to the shared cross-run store during the run (lessons_every\n # — one batched LLM call per firing) and RE-READ the store so lessons distilled by CONCURRENT\n # runs reach this run's proposals (lessons_refresh_every; file re-read, no LLM call, skipped\n # when the store is unchanged). ON by default (every 4 nodes) and, like comparative_lessons\n # above, live out of the box because memory_dir defaults on; 0 = off — run-end write /\n # run-start read only, the pre-M6 behavior.\n lessons_every: int = Field(default=4, ge=0)\n lessons_refresh_every: int = Field(default=4, ge=0)\n # B3 output redaction: mask credentials (known key shapes + high-entropy tokens) in the\n # stdout/stderr tail before it is persisted to the event log / spans / UI — a leaked secret in a\n # print()/traceback must not land in the durable log. Off by default; recommend on for untrusted.\n redact_output: bool = False\n # B5 reward-hacking detector: a host-side monitor that flags suspicious wins (grader/answer-key\n # access, runtime writes to frozen files, suspiciously-perfect metrics) as a `reward_hack_suspected`\n # audit event in the Trust panel. Off by default. Whether a flag CHANGES selection is governed by\n # `trust_gate` below (default: audit-only, never changes selection).\n reward_hack_detect: bool = False\n # Trust enforcement (T2): what a reward-hack / data-leakage flag actually DOES to selection.\n # \"audit\" (default) = surface it in the Trust panel, never change selection (today's behavior);\n # \"gate\" = a flagged node can no longer be selected as BEST **and isn't bred/confirmed from**\n # (breed_excluded — see replay._apply_trust_gate / models.breedable_nodes), but stays\n # FEASIBLE so it still counts for diversity/audit — closes the \"a hacked/leaky node can\n # win OR seed its lineage\" hole;\n # \"block\" = additionally mark it fully INFEASIBLE (removed from feasible_nodes entirely).\n # Deliberately gates only high-precision CHEATING/LEAKAGE signals. Broad `critic:*` findings stay\n # advisory; `critic:hardcoded_metric` is the one narrow high-precision critic exception\n # and gates because it requires a literal metric with no computed assignment anywhere.\n trust_gate: str = \"audit\"\n # I3 data-centric: static code-leakage scan of each evaluated solution (fit-before-split,\n # fit-on-test) surfaced into the Trust panel alongside reward-hack flags. Off by default.\n code_leakage_detect: bool = False\n # 4.4 sandbox instrumentation (needs reward_hack_detect): after each eval, flag a RUNTIME write\n # to a protected/frozen file (an answer key or grader tampered mid-run) — behavioral evidence a\n # static code scan misses. ON by default; only fires when the reward-hack detector is on.\n workdir_audit: bool = True\n # D8 decoupled research Verifier: check every Deep-Research memo's CLAIMS against their cited\n # evidence (node ids / urls) before recording — synthesis is the documented weak link (Kosmos\n # 57.9% accurate). Deterministic layer always; an LLM rubric pass when the DeepResearcher has a\n # client. The verdict cannot change this run's node/champion, but finalize uses aligned supported\n # verdicts as the evidence gate for persisted D8 cross-run claims; unavailable/misaligned stays\n # unverified. ON by default (no-op with no memo/claims).\n research_verify: bool = True\n # C4 independent critic: an execution-free critic of each solution (stub / hardcoded-metric /\n # params-ignored; on host-graded tasks the metric checks become a submission-output check)\n # surfaced in the Trust panel. Broad findings are advisory; `critic:hardcoded_metric` can gate under\n # trust_gate=gate/block. Off by default.\n critic_check: bool = False\n # A7 Strategist (NEW, user-requested): optional LLM/rule meta-controller that picks the search\n # policy/allocator + operator mix + fidelity (+ Developer backend) per situation. Config-first:\n # every choice the Strategist makes is also a direct knob. Defaults to \"agent\" so the agent adapts\n # the search policy/operators/fidelity per situation; set \"off\" for static config-driven search.\n # \"agent\" = a tool-using Strategist that READS the run/data/sibling-runs/KB/memory (B1-guarded)\n # before deciding, instead of the single-shot \"llm\" call over aggregate stats.\n strategist_backend: str = \"agent\" # \"off\" | \"rule\" | \"llm\" | \"agent\" — default AGENTIC: the\n # Strategist READS the run/data/sibling-runs/KB/memory with tools before deciding, not a single-shot\n # call over aggregate stats. \"llm\" = the old non-agentic single-shot; \"rule\"/\"off\" = no LLM.\n strategist_every: int = Field(default=3, ge=1) # consult cadence (created nodes)\n # PART V (F1): concept CLASSIFIER re-tag + consolidation cadence, DECOUPLED from strategist_every. The\n # LLM concept map is heavier and slower-moving than a strategy consult, so it refreshes on its own\n # (sparser) interval. Researcher-authored idea.concepts still fold immediately at node_created — this\n # only paces the classifier-evidence + consolidation refresh (and the concept-coverage pivot snapshot).\n concept_retag_every: int = Field(default=30, ge=1)\n # === Confirmation & holdout ===========================================================\n # Multi-seed confirmation (I12, ADR-15): confirm the top-k under N seeds before\n # finishing. 0 disables (default). Only meaningful when eval has variance.\n confirm_top_k: int = 0\n confirm_seeds: int = 0\n # D1 seed-holdout discipline: the FIRST seed the confirm phase uses. Search evals run with the\n # implicit seed 0 (LOOPLAB_EVAL_SEED unset -> \"0\"), so a base of 1 keeps every confirm split\n # DISJOINT from anything the search optimized against — the confirm metric is then a\n # generalization signal, not a re-measurement (AIRA: selecting on a signal the search saw\n # overfits by 9-13 pp). Set 0 to restore the legacy overlapping seeds 0..N-1.\n confirm_seed_base: int = Field(default=1, ge=0)\n # D1 holdout-gated promotion (B6, Arbor-style): for host-graded tasks, reserve this fraction of\n # the held-out labels as a FINAL holdout partition the search never sees — every search/confirm\n # eval is scored on the remaining rows only, and at finish the val-top-k are re-scored on the\n # holdout partition (free: the predictions already exist). 0.0 = off (legacy full-label scoring).\n holdout_fraction: float = Field(default=0.25, ge=0.0, le=0.9)\n # Champion selection prefers the HOLDOUT metric among the nodes that have one (the val-top-k).\n # Recorded in run_started so replay applies the same rule; old logs fold to False (legacy pick).\n # The per-node val-holdout `generalization_gap` folds into the Trust panel either way.\n holdout_select: bool = True\n # How many val-leaders get a holdout evaluation at finish (host-graded tasks).\n holdout_top_k: int = Field(default=3, ge=1)\n # R1-c / Part IV — calibrated §12-verifier tie-break in best-selection. When a fresh node's robust\n # metric EXACTLY TIES an already-eligible node, the engine runs the §12 verifier (grounded on each\n # node's realized result, `selection_criteria`) and records a soundness score per node. The tie-break\n # then applies across the WHOLE selection path: the mean pick breaks a robust_metric tie by the score,\n # and — when `holdout_select` is on — the holdout-slot ranking prefers the sounder node so it isn't\n # denied a holdout eval, and the final holdout override breaks a holdout_metric tie by it too. Strictly\n # a TIE-BREAK — it can NEVER move a node ahead of a strictly-better metric (§21.7 advisory-never-\n # overrides). Best-effort: verification is LAZY (only fires on an actual tie) and bounded per cadence,\n # so a large/late tie stays on deterministic metric+id order unless one atomic treatment scores every\n # member. Recorded in run_started so\n # replay applies the same rule; old logs fold to False (legacy pick). Opt-in (default off); needs a\n # reachable LLM client. Enable only after validating calibration offline with `verifier.calibrate`.\n # See trust/verifier.py + core/fitness.py.\n select_verifier: bool = False\n # R1-d (§21.19): widen the verifier tie-break from EXACT-metric to a conservative STATISTICAL tie.\n # The band is capped by both the metric leader's SE and the pooled SE-of-difference, so challenger\n # variance cannot widen it and a significant difference is never a tie (§21.7). Off -> exact only.\n verifier_ci_tie: bool = False\n # Verifier sample count for `select_verifier` (the §12 repeated-sampling expectation). 3 tames the\n # single-shot variance; 1 = single-shot (cheaper, noisier).\n select_verifier_samples: int = Field(default=3, ge=1, le=32)\n # Budget (I13): hard wall-clock ceiling; the run aborts cleanly when exceeded.\n max_seconds: float | None = None\n # Eval-compute budget (#2): hard ceiling on cumulative time spent INSIDE evals (training\n # runs), separate from wall-clock. Survives resume (summed from the event log). The real\n # guard against a silent multi-hour sweep when an eval is a minutes-hours training run.\n max_eval_seconds: float | None = None\n # Cross-run memory (I19, ADR-10): if set, the best result of each run is stored as\n # a case here, and the cases become retrievable knowledge for future runs.\n memory_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"memory\"))\n # HITL (I21, ADR-11): pause for human approval of the final best before finishing.\n require_approval: bool = False\n # Diversity archive (I22): niche bucket width in parameter space.\n archive_resolution: float = 1.0\n # Coverage read-model (narrowing signal): at the strategist cadence, compute a breadth summary\n # (themes / param-niches / theme entropy / dominant-theme fraction) from the folded run, record\n # it as a `coverage_snapshot` audit event, and surface it into the Strategist's decision context.\n # Deterministic, cheap, and purely additive context — no search-behavior change on its own. On by\n # default (like the rest of the always-on situational context). See search/coverage.py.\n coverage_context: bool = True\n # PART IV Phase 2a live steering (§21.11/§21.13). When on, the `concept_retag_every` cadence (NOT\n # `strategist_every` — the producer gates on `_should_consult_concepts`, which uses the seed boundary\n # + `concept_retag_every`, default 30) records a\n # concept-graph coverage + uncovered-region snapshot (deterministic heuristic tagger over the\n # task-type skeleton) and, on an `explore` stance, the Researcher's novelty hint names the exact\n # uncovered regions (\"0 coverage in {negatives/external-mining, distillation} — go there\") instead\n # of the vague \"broaden\". ON by default in the product Settings (ce4a379), EngineOptions off: it\n # only enriches an already-mode-gated explore hint, never forces exploration, and no-ops for a task\n # with no curated concept skeleton. The snapshot is auditable, but its prompt cue can change future\n # candidate generation; it does not directly rewrite best-metric selection. See search/concept_graph.py.\n # DESIGN NOTE (2026-07-17 critique): now ON unconditionally, but a toy / non-ML task yields EMPTY concept\n # tags while still paying the per-node LLM concept cost (~10 calls/node with the full Part IV/V bundle).\n # Consider AUTO-QUIESCING: if the heuristic tagger returns empty for the first N nodes (no concept signal),\n # skip the LLM concept work for the run — keeps it on for ML tasks, free on toy/degenerate ones.\n concept_pivot: bool = True\n # PART IV Phase 2b — D3 graded novelty into the LIVE novelty gate (§21.4/§21.13). When on and the\n # task has a curated concept skeleton, the novelty gate first grades a proposal over the concept\n # graph: a level-4 \"same direction, DIFFERENT implementation\" or a level-5 \"re-opens a\n # wrongly-abandoned FAILED direction\" proposal is ALLOWED through unchanged — the flat LLM/semantic\n # dedup gate can't tell \"this DCL tweak\" from \"the whole DCL branch\" and would wrongly reject a\n # legitimate variant or never re-open a sound-but-killed direction (the node_63 archetype). Levels\n # 1/2/3 (identical / near-dup / prior-run) still fall through to the existing gate. Deterministic\n # (heuristic tagger, no LLM), audit event `novelty_graded`, replay-safe. ON by default in the product\n # Settings (ce4a379), EngineOptions off; no-ops for a task with no skeleton. See search/graded_novelty.py.\n graded_novelty: bool = True\n # PART IV Phase 2b — D7 capability-expansion forced-jump DIRECTIVE (§21.8/§21.13, issue #7). When on\n # and the concept-graph cadence detects action-space LOCK-IN (the search has stayed inside one D5\n # branch for a long consecutive streak) on an `explore` stance, the Researcher's novelty hint\n # ESCALATES from \"broaden\" to \"expand the ACTION SPACE — build the missing infra (external mining,\n # a new data pipeline, a different eval), do NOT swap another {locked} lever.\" The same\n # gate also stamps the proposal's authoritative operator as `expand`; that node competes normally\n # under SearchFitness and its separate operator yield is measured. It does not force a winner.\n # Reads the 2a concept-coverage snapshot, so it needs `concept_pivot` to record one. OPT-IN\n # (default off). See engine/proposal_cues.py + search/lock_in.py.\n capability_expansion: bool = False\n # PART IV cross-run Step 0 (§21.20.12/§21.20.13). Universal task-fingerprint tokenization: drop the\n # ASCII-only `[a-z0-9]` allowlist on goal keywords so a non-Latin goal (Russian, CJK, …) is NOT\n # silently dropped from its cross-run fingerprint (today such a goal keys only on kind/dir/metric and\n # never reaches SIMILAR-task priors/lessons/cases). Uses `[^\\W_]+`/`.casefold()` — same splitting as\n # before, any script. ON by default in the product Settings (ce4a379); the bare-library EngineOptions\n # default stays off, so a run pinned to it won't silently re-key a portfolio mid-flight. See engine/memory.py.\n fingerprint_universal: bool = True\n # PART IV cross-run Step 2 (§21.20). Fill graded-novelty's `prior_concepts` from the cross-run\n # ConceptCapsuleStore: at run end write a per-run concept capsule (the shipped `node_concepts` tags +\n # best outcome, keyed by task_fingerprint) to `memory_dir`; when a later SIMILAR run proposes an idea\n # whose concept was tried before, SURFACE the prior outcome as a `cross_run_prior` audit event (never\n # reject — D3 level 3). Audit-only, off the selection path; ON by default in the product Settings\n # (ce4a379), EngineOptions off. Needs a `memory_dir` to share capsules.\n # See engine/memory.py (ConceptCapsuleStore) + engine/novelty.py.\n # PREREQUISITES (this flag is NOT standalone): the WRITE needs per-run concept tags\n # (`node_concepts`, produced by `concept_pivot`/F1 tagging) — no tags => no capsule; the SURFACE path\n # runs inside the graded-novelty precheck, so it also needs `graded_novelty` on. With only this flag +\n # `memory_dir` it persists/surfaces nothing. Enable `concept_pivot` + `graded_novelty` alongside it.\n # A non-Latin (Russian/CJK) portfolio should ALSO set `fingerprint_universal`, else the capsule's goal\n # tokens are dropped and its fingerprint over-matches on kind/dir/metric alone (the novelty direction\n # gate narrows this, but goal-word similarity is still lost).\n cross_run_concepts: bool = True\n # PART V (B): run-base + node-DELTA concept authoring. When on, once the first evaluated node has\n # authored concepts the engine seeds RunState.run_base_concepts from them (a one-shot EV_RUN_CONCEPTS,\n # idempotent because it only fires while the base is empty), and the Researcher's proposal hint tells it\n # to author only `concepts_added`/`concepts_removed` vs the run base + its parent — minimal per-node\n # annotations that inherit down the DAG (a swap propagates). Off -> every node authors its full concept\n # set as before. Product-on; the bare EngineOptions mirror defaults off (test_options_divergence).\n concept_run_base: bool = True\n # PART IV cross-run Step 5 advisory (§21.20.5). Fold the bounded cross-run CONTEXT PACK — evidence-\n # grounded claims with BOTH support and counter-evidence (Step 4) + a portfolio-coverage line (Step 3) —\n # into the Researcher's proposal prompt, exactly like the E4 cross-run prior note. Advisory ONLY: it is\n # prompt-grounding: it can change the Researcher's later candidates, but does not directly rewrite\n # best-metric selection (§21.7). Reads `memory_dir` (lessons.jsonl + concept_capsules.jsonl); \"\" when\n # empty. ON by explicit experimental product choice; no frozen A/B validation is claimed. The\n # bare-library EngineOptions default stays off (engine/options.py, frozen in\n # test_options_divergence). See engine/proposal_cues.py.\n cross_run_advisory: bool = True\n # PART IV cross-run §21.20.13 (full CR of the lean fuzzy claim merge). Switch the claim read-model to the\n # SCOPE+POLARITY-safe STRUCTURED claim key (engine/claim_key.py): claims from different tasks never\n # merge, opposite polarity (\"X helps\" vs \"X never helps\") is surfaced as a CONTRADICTION instead of being\n # collapsed, paraphrase/inflection variants group by exact structured key (no transitive over-merge), and\n # operator governance is scope-precise (a decision in task A cannot reach a same-worded claim in task B).\n # Affects the `cross_run_advisory` context pack only; ON by default in the product Settings (ce4a379);\n # the bare-library EngineOptions default stays off (engine/options.py). See engine/claims.py.\n cross_run_structured_claims: bool = True\n # PART IV cross-run §22.4 (AGENTIC taxonomy steward). At finalize, when an LLM client is available, let\n # the concept steward (engine/concept_steward.py) review the freshly-updated portfolio concept graph and\n # PROPOSE a curation — merge duplicate slugs / split conflated ones / purge noise. Proposals are LOGGED to\n # `concept_curation_log.jsonl` for operator ratification (via the serve/CLI governance surface); the LLM\n # never mutates fold state. The calls run synchronously during finalize, so they add latency and may add\n # paid inference; a caught steward failure does not prevent terminal completion or trigger a retry.\n # ON by explicit experimental product choice when `memory_dir` + an LLM backend are present; the\n # bare-library EngineOptions default stays off (engine/options.py). See engine/lessons.py.\n cross_run_curation: bool = True\n # Deprecated compatibility flag. Steward output is untrusted and finalize is proposal-only regardless\n # of this value; retained so old snapshots still validate and logs can disclose that auto was requested.\n cross_run_curation_auto: bool = False\n # Role backend (ADR-7/14): \"toy\" (offline optimizer) | \"llm\" (live model). Validated in\n # `_check_trust_gate` alongside the other enum-ish strings — every consumer tests `== \"llm\"`\n # exactly (cli/__init__.py, adapters/tasks.py), so an untyped `--set`/file/env typo (or a mis-cased\n # \"LLM\") used to fall through to the OFFLINE toy roles and quietly produce a run that never called\n # the model. Left as `str`, not `Literal`, so old snapshots still validate through the same path.\n backend: str = \"toy\"\n # Developer backend (ADR-7): \"default\" (templated/LLM from the task) or an external\n # CLI coding agent: \"opencode\" | \"aider\" | \"goose\" | \"continue\".\n developer_backend: str = \"default\"\n # C2 best-of-N: generate N candidate implementations per node and keep the best by an\n # execution-free reward (static validity + metric-print). 1 = off. In-house LLM developer only\n # (not external agents — ADR-7 cost rule). The top SWE-bench reliability lever for weak models.\n best_of_n: int = 1\n # D10 list-wise best-of-N selection: when the top static-scorers TIE, break the tie with a\n # comparative LLM selection over the candidates presented together (+~3 pts vs pointwise on\n # GAIA, arXiv:2506.12928). The static score stays the primary filter — the LLM is a weak\n # comparative prior, never the sole oracle. ON by default (no-op when best_of_n==1).\n best_of_n_listwise: bool = True\n # === Foresight / predict-before-execute ===============================================\n # FOREAGENT predict-before-execute (arXiv:2601.05930): use the LLM as an IMPLICIT WORLD MODEL to\n # predict — WITHOUT executing — which candidate / hypothesis will score best, primed with a\n # Verified Data Analysis Report (data profile / brief) + the accumulated experiment memory. Master\n # switch, ON by default; a no-op wherever there is nothing to rank (best_of_n==1 and\n # foresight_panel==1) or no LLM client. See `looplab/search/foresight.py`.\n foresight: bool = True\n # Hypothesis foresight breadth: generate K candidate ideas per proposal and keep the one PREDICTED\n # best pre-execution by the world model — it ranks the STRUCTURAL / text ideas the numeric k-NN\n # surrogate (researcher_panel) is blind to, primed with the data profile + memory (the synergy).\n # >1 enables it (LLM backend only; needs `foresight` on); 2 = on by default at modest cost, 1 = off.\n foresight_panel: int = 2\n # AGENTIC foresight: run the ranking (hypothesis-board prioritization + K-idea pick) as a TOOL-USING\n # loop that can pull actual experiment results / data facts before deciding, instead of a one-shot\n # prediction from a pre-baked report. ON by default (needs foresight + a client; falls back to the\n # one-shot predictor on any hiccup). A few extra LLM calls per proposal; set False for the cheaper\n # single-call ranker.\n foresight_agentic: bool = True\n # §1 foresight confidence gate: the minimum predicted confidence at which a predict-before-execute\n # pick is ACTED ON. Below it the ranker abstains — the K-idea panel falls back to the first\n # proposal, best-of-N falls through to the D10 tie-break — instead of committing a low-confidence\n # choice. 0.0 (default) = OFF (act on every pick, the historical behavior); raise toward ~0.5 to\n # make the world model defer when it isn't sure. Pairs with the foresight track record (§1) the\n # predictor is now primed with. Range 0.0-1.0.\n foresight_min_confidence: float = 0.0\n # PART IV Phase 2c — replace the world model's SELF-REPORTED confidence (measured Pearson≈0 with the\n # realized outcome, §21.12) with a CALIBRATED §12-verifier score. When on and a client is available,\n # after the K-idea ranker picks the predicted-best candidate the §12 verifier scores it (grounded +\n # repeated + criteria-decomposed: `foresight_criteria` — likely to improve the objective, and\n # sound/feasible) and THAT calibrated score becomes the confidence the `foresight_min_confidence` gate\n # and the telemetry use. Degrades to the self-reported confidence without a client or on any verifier\n # error (the telemetry records which source was used). ON by default in the product Settings (ce4a379),\n # EngineOptions off; a few extra LLM calls per acted-on proposal. See looplab/trust/verifier.py + search/foresight.py.\n # REVISIT (cost/quality, not yet resolved): a live classification run measured ~10 LLM calls & ~3.8 min/node\n # on deepseek-v4-flash with the full Part IV/V default bundle; the calibrated verifier is part of that per-node\n # tax. Kept ON deliberately — it raises decision quality (self-reported foresight confidence has ≈0 Pearson\n # with realized outcome), and lowering quality to save calls is the wrong trade. TODO: run a frozen quality/cost\n # A/B (verifier-on vs -off) to quantify the recall@objective gain per extra call before considering a default flip.\n foresight_verify: bool = True\n # Verifier sample count for `foresight_verify` (the §12 repeated-sampling expectation on a no-logprob\n # backend). 3 tames the single-shot variance §21.12 measured; 1 = single-shot (cheaper, noisier).\n # RESUME-critical: this value is persisted in config.snapshot.json, so a hard `le` schema bound would\n # make an old run started with a larger LOOPLAB_FORESIGHT_VERIFY_SAMPLES un-resumable (Settings(**snapshot)\n # would raise on `looplab resume` / finalize-recovery / serve replay). CLAMP into [1, MAX] via a validator\n # instead of rejecting — the runtime cap is preserved without breaking the snapshots/env-compat rule.\n foresight_verify_samples: int = 3\n\n @field_validator(\"foresight_verify_samples\")\n @classmethod\n def _clamp_foresight_verify_samples(cls, v: int) -> int:\n # Coerce a persisted/env value into [1, MAX] rather than reject (see field note above).\n try:\n v = int(v)\n except (TypeError, ValueError):\n return 3\n return max(1, min(v, MAX_FORESIGHT_VERIFY_SAMPLES))\n\n # T7 LLM response cache: serve an identical DETERMINISTIC (temperature 0) request from an\n # in-process content-addressed cache instead of re-hitting the model — cuts cost on\n # retry/panel/verify flows. NEVER caches sampling calls (temp>0: best-of-N / panel / novelty\n # re-propose must vary). Off by default (most role calls run at temperature>0 anyway).\n llm_cache: bool = False\n agent_cmd: str | None = None # override the agent's launcher (path / wrapper)\n # External-agent validation (ADR-7): wrap a CLI-agent Developer with a validator that\n # audits each output (no-op/syntax/crash/timeout), retries with feedback, and falls\n # back to the in-house LLM Developer. Off -> use the raw agent output unchecked.\n validate_agent: bool = True\n agent_max_retries: int = 1 # re-prompts of the agent on an invalid result\n # Patch-gated multi-file agent (ADR-7 Rule 3): run the CLI agent in a git worktree and\n # accept only edits whose paths match `agent_surface` (reject-not-strip out-of-surface\n # touches). Lets the agent create helper modules, not just solution.py. Degrades to\n # whole-file readback if git is unavailable.\n agent_patch_gate: bool = True\n agent_surface: list[str] = [\"*.py\"] # edit-surface allow-list (globs)\n # Trust policy for an agent-authored eval/metric adapter (RepoTask onboarding, Phase 3):\n # \"ratify_freeze\" waits for human approval, \"autonomous\" accepts the proposed spec without that\n # gate, and \"ratify_freeze_drift\" also enforces the configured independent drift cross-check.\n eval_trust_mode: str = \"ratify_freeze\"\n # RepoTask node seeding policy (run-wide fallback; a task/editable `seed_mode` overrides):\n # \"auto\" (default) copies git-tracked files when the editable is a git repo (so a tree bloated\n # with untracked artifacts isn't deep-copied per node), else a full copy; \"tracked\" forces\n # code-only; \"all\" forces a full recursive copy (legacy). Genesis authors per-task from the\n # user's words; this is the global default when unspecified.\n seed_mode: str = \"auto\"\n llm_model: str = \"qwen3:8b\"\n # === LLM / transport ==================================================================\n llm_base_url: str = \"http://localhost:11434/v1\" # Ollama OpenAI-compatible endpoint\n llm_temperature: float = 0.6\n # H3 per-role model presets (5090 recipe): optionally run the Researcher and Developer on\n # DIFFERENT models/endpoints (e.g. Developer=Qwen3-Coder-30B for code, a fast model for breadth).\n # Blank = use the shared llm_model/llm_base_url. Generalizes the role-backend swap to per-role.\n researcher_model: str | None = None\n developer_model: str | None = None\n researcher_base_url: str | None = None\n developer_base_url: str | None = None\n # Per-role sampling temperature (§4.1): the Researcher wants breadth (higher temp = more diverse\n # ideas), the Developer wants determinism (lower temp = fewer syntax slips), the Strategist a\n # steady hand. Each overrides the shared `llm_temperature` for that role only; None = use the\n # shared value (byte-identical to before). Flat + nullable, mirroring the per-role model fields.\n researcher_temperature: float | None = None\n developer_temperature: float | None = None\n strategist_temperature: float | None = None\n # The Strategist had per-role TEMPERATURE but no model/endpoint of its own, so in split mode it\n # always ran on the shared llm_model however the other roles were pointed. These close that gap\n # with the same names/shape as the pair above; blank = its profile, else the shared value.\n strategist_model: str | None = None\n strategist_base_url: str | None = None\n # === Connection profiles (multi-provider) =============================================\n # A \"profile\" is a NAMED connection: {model, base_url, temperature, api_key_env, provider}. Roles\n # point at one by name through `role_profiles`; `llm_profile` is the default for every role that\n # doesn't. All three are EMPTY by default and the resolver short-circuits to the shared\n # llm_model/llm_base_url/llm_api_key path when they are — an operator running one model never\n # needs to know the word \"profile\".\n #\n # A profile stores `api_key_env`, the NAME of an environment variable, never the key itself. That\n # is ADR-11's \"a secret is a reference, not a value\" turned from a convention into a data type:\n # the whole profile map is then safe to write into config.snapshot.json, hand back over HTTP and\n # render into LOOPLAB_* — there is nothing in it to mask. It is also what makes per-role\n # CREDENTIALS expressible at all: two roles on one provider can carry different keys, which a\n # key-per-endpoint map could not say.\n llm_profiles: dict[str, dict] = Field(default_factory=dict)\n llm_profile: str | None = None\n role_profiles: dict[str, str] = Field(default_factory=dict)\n # Unified self-driving agent: one LLM identity that plays Researcher + Developer (+ Strategist)\n # across pipeline stages, choosing its own model/toolset per stage. ON by default — the agent\n # drives the loop (incl. crash triage: repair/abandon/reject_idea) and, via\n # `agent_drives_actions`, picks the next macro action within a pure legal-action gate. Set both\n # to False for the legacy byte-identical split-role behavior. (No-op unless backend=\"llm\".)\n unified_agent: bool = True\n agent_drives_actions: bool = True\n # Layer 3 Card queue ownership. False preserves the existing policy/unified-pilot action path.\n # True is pinned in run_started because changing the selector on resume would mix two search\n # treatments in one run. When both this and agent_drives_actions are true, Card selection wins.\n card_driven_selection: bool = False\n # Layer 5 bounded speculative Card buffer. Zero is a hard OFF switch and preserves the\n # historical alternating build/eval spine; positive values are pinned by run_started so a\n # resume cannot silently mix search treatments after a snapshot/default edit.\n speculation_depth: int = Field(default=0, ge=0, le=64)\n # Local evidence receipt produced by ``looplab speculation-gate``. This is deliberately absent\n # from the curated Settings UI: positive-depth execution is a calibrated rollout decision, not a\n # casual tuning knob. The Engine validates the receipt against the current scorer,\n # implementation, GPU inventory and raw paired-run evidence before it enables speculation.\n speculation_gate_receipt: str | None = Field(default=None, max_length=4096)\n # Per-stage model/endpoint overrides for the unified agent. Recognized keys (see\n # tasks.build_unified_agent): `propose` (researcher), `implement`/`repair` (developer),\n # `strategy`, `pilot`. Unlisted keys are ignored. Empty = the per-role researcher_*/developer_*\n # models, then the shared llm_model. Explicit map wins. No-op unless `unified_agent` is true.\n agent_stage_models: dict = {}\n agent_stage_base_urls: dict = {}\n llm_parser: str = \"tool_call\"\n # Reasoning/thinking toggle sent IN the request (providers differ — see `llm.reasoning_body`):\n # Qwen3 on vLLM/SGLang -> chat_template_kwargs.enable_thinking (bool)\n # OpenAI/Ollama-v1/DeepSeek -> reasoning_effort (low|medium|high|none)\n # `llm_reasoning`: \"\" = send nothing (server default — current behavior); off = actively disable;\n # on = enable at default depth; low|medium|high = enable at that effort.\n # Defaults to \"high\" so the agent reasons before proposing/repairing/triaging (better code, fewer\n # crashes); set \"\" to send nothing (server default) or \"off\" to disable. The model's chain-of-thought\n # is CAPTURED either way (reasoning_content field or inline <think>). NOTE: to get a SEPARATE\n # reasoning_content field from SGLang/vLLM, the server also needs `--reasoning-parser qwen3`.\n llm_reasoning: str = \"high\"\n llm_reasoning_style: str = \"auto\" # auto | qwen | effort | none — how to shape the request param\n llm_reasoning_extra: dict = {} # raw fields merged into the body (escape hatch, e.g. Anthropic thinking)\n # H1: drive structured calls with the endpoint's constrained decoding (vLLM/SGLang guided_json +\n # response_format json_schema) so weak models can't emit invalid JSON. Off for Ollama (default).\n llm_guided_json: bool = False\n # Stream every LLM request (SSE) and reassemble it, so the request `timeout` is an INTER-TOKEN\n # idle timeout rather than a whole-request deadline: a long-but-alive generation is never cut off\n # (tokens keep resetting the timer), while a genuinely STALLED endpoint (no token for `timeout` s)\n # is detected and retried on a fresh connection — the fix for silent multi-minute hangs. Set False\n # to use one blocking read (old per-op timeout semantics) if an endpoint streams badly.\n llm_stream: bool = True\n # LLM IDLE timeout (seconds). Stream mode: the INTER-TOKEN stall limit — a steady generation is\n # never cut off, only a silent endpoint. Non-stream: bounds the whole read. Raise for endpoints\n # with a long prefill on huge prompts; lower to fail fast on a flaky shared endpoint.\n llm_timeout: float = Field(default=180.0, gt=0)\n # First-byte (response-headers) window for STREAM attempts, seconds: an SSE response sends its\n # headers on admission, so no-headers ≈ a black-holed request — fail over to a fresh connection\n # fast instead of waiting the full idle timeout. Clamped to llm_timeout. Non-stream attempts are\n # NOT bounded by this (their headers arrive only after the whole generation). The default mirrors\n # `DEFAULT_HEADER_TIMEOUT_S` in looplab/core/llm.py is the single source (imported above).\n llm_header_timeout: float = Field(default=DEFAULT_HEADER_TIMEOUT_S, gt=0)\n # Honour HTTP(S)_PROXY / NO_PROXY / SSL_CERT_FILE env vars for LLM calls. Default False: the\n # transport connects DIRECTLY (many internal/OLAP endpoints are reachable only by bypassing an\n # ambient proxy — a picked-up proxy yields \"connection refused\"). Set true when the endpoint is\n # reachable ONLY via a corporate proxy or needs a custom CA bundle from the environment.\n llm_trust_env: bool = False\n # H4/C2: compact the growing agentic tool-call history once it exceeds this many chars (auto_summary\n # LLM-summarizes the stale middle; else middle-truncate). Sized to the model's real context window so\n # files read earlier STAY in context instead of being compacted away and re-read: ~1,000,000 chars ≈\n # 250k tokens (≈4 chars/token), matching the configured deepseek-v4-flash (≥200k tokens confirmed).\n # The old 120k-char (~30k-token) default was ~8× too aggressive and drove a read-thrash loop. 0 = off.\n context_budget_chars: int = 1_000_000\n # M5: the Researcher's always-on experiments digest budget (chars). 0 = AUTO — scales with the\n # run size (60 chars/node, bounded [1200, 6000]) instead of one flat cap for an 8-node toy run\n # and a 200-node benchmark run alike. Set a positive value to pin it.\n digest_char_cap: int = Field(default=0, ge=0)\n # D11 compression model slot (open_deep_research's four-slot pattern): a dedicated CHEAP model\n # for agent-history summarization (C2 auto-summary), so compression doesn't pay the main\n # model's price. Blank = use each loop's own model (legacy). `compressor_base_url` blank =\n # the shared llm_base_url.\n compressor_model: str | None = None\n compressor_base_url: str | None = None\n # === Agentic tool-loop ================================================================\n # Agentic tool-loop limits — apply to EVERY tool-using agent (the LLM Researcher, the unified\n # agent's pilot + crash-triage, Deep-Research, the run-chat Boss, the genesis repo-scout, and the\n # cross-run report synthesizer). The loop lets the model call read-only tools across turns before\n # emitting its structured result. Both default to UNLIMITED so the agent takes as many turns / as\n # long as it needs — it is never cut off mid-reasoning; set a positive value here (or per-run, or\n # in the UI settings) to bound latency/cost. These used to be hardcoded per call site (e.g. the\n # boss at max_turns=3 / 45s), which silently dropped a slow reasoning model to a no-op reply.\n agent_max_turns: int = 0 # max tool turns before the emit is forced (0 = unlimited)\n agent_time_budget_s: float = 0.0 # wall-clock ceiling across the loop's turns (0 = no cap)\n # G · Soft convergence SAFETY NET (not a research cap). Two thresholds on the agent's tool-turn count:\n # at `agent_emit_after` the loop NUDGES the agent once (\"you've investigated enough — emit now\"); at\n # `agent_emit_force` it FORCES the emit from what was gathered. Set GENEROUSLY — a focused researcher\n # may legitimately read many files/experiments before it can propose a grounded idea; these only catch\n # a genuine runaway that keeps issuing DIFFERENT calls forever (which stuck-detection, keyed on\n # repeats, misses). NB: the old 499-read runaway (node 63) was 79% REDUNDANT re-reads caused by a\n # broken read_file — fixing read_file (pagination) is the real cure; this is just insurance. 0 = off.\n agent_emit_after: int = 300\n agent_emit_force: int = 500\n # B1 · No-progress / stuck detection — the safety net that makes \"unlimited turns\" safe. The\n # turn/time ceilings above are only BACKSTOPS; this is what actually stops a runaway loop, on the\n # cheapest signal: when the model repeats the SAME tool call (or ping-pongs between two, or keeps\n # hitting the SAME error) with no progress, the loop forces the final emit and finishes. ON by\n # default; reading DIFFERENT files or running ONE long command never trips it. (OpenHands-style.)\n agent_stuck_detection: bool = True\n agent_stuck_repeat: int = 4 # identical calls in a row that count as \"stuck\" (>=2)\n agent_stuck_alternate: int = 4 # ping-pong cycles between two calls that count as \"stuck\" (>=2)\n # C1 · Self-plan (TodoWrite-style): expose an `update_plan` tool so a long-running agent keeps its\n # OWN working TODO and re-surfaces it every `agent_plan_reinject_every` turns — keeps the goal in\n # view across a long loop so the agent \"writes its own context\" (Claude Code style). ON by default.\n agent_self_plan: bool = True\n agent_plan_reinject_every: int = 5\n # C2 · Auto-summary (ON by default): when the tool-loop history grows long, LLM-summarize the\n # stale middle instead of only middle-truncating it. The trigger is `context_budget_chars` if set,\n # else a built-in high-water mark (context_budget.DEFAULT_SUMMARY_CHARS ≈ 120k chars) so short\n # loops are untouched. Falls back to truncation on any summarizer error; one extra model call per\n # over-budget turn.\n agent_auto_summary: bool = True\n # C4 · Repo-developer plan decomposition. A big feature attempted in ONE developer session made a\n # non-converging reasoning model loop indefinitely — it kept writing + exploring without ever\n # emitting `done` (10k+ LLM calls / hours; agentic stuck-detection can't catch VARIED exploration,\n # only literal repeats). Fix: the developer first proposes an ordered plan of ATOMIC steps; a\n # multi-step plan is executed step-by-step, each in a FRESH bounded session with only that step's\n # scope + the files accumulated so far, syntax-validated per write. Every session stays\n # small-context and convergent. ON by default for repo tasks (a 1-step plan == the old single pass).\n developer_plan_decompose: bool = True\n developer_plan_min_steps: int = 2 # a proposed plan with >= this many steps runs step-by-step\n developer_plan_max_steps: int = 8 # cap on plan length (a runaway planner can't spawn 100 steps)\n # Hard per-session backstop for the repo developer — the ONE write-heavy agent that can run away\n # even with stuck-detection on (varied writes/reads never trip the repeat/ping-pong signal). Bounds\n # the plan phase, EACH step, and the single-session fallback. Unlike the global agent_* limits\n # (0 = unlimited, meant for read-only agents), the developer always gets a FINITE ceiling so a\n # model that never emits `done` fails cleanly with the code it wrote so far, instead of looping.\n developer_session_max_turns: int = 500\n developer_session_time_budget_s: float = 1200.0 # 20 min wall-clock per developer session\n # Phase-handoff summaries. Each LLM phase in a node build (Researcher·propose → Developer·stages →\n # plan → implement) ends with ONE extra LLM call that distills its transcript — the repo structure\n # it mapped, files/data confirmed, decisions made — into a brief injected into the NEXT phase (even\n # across the role boundary). The next phase TRUSTS that instead of re-reading the same files, which\n # cuts the biggest source of tool-call thrash (stages/plan/implement each re-exploring the repo).\n # One summary call per phase in exchange for many fewer read calls downstream. ON by default.\n phase_handoff_summary: bool = True\n # Observability (ADR-17): capture each LLM call's full prompt + completion into the active\n # span (spans.jsonl) so the UI can show exactly what the model read and wrote per node.\n # Diagnostics only — `replay.fold` never reads spans. Default on for local single-user; set\n # LOOPLAB_TRACE_LLM_IO=0 to suppress (e.g. to keep spans.jsonl small or avoid storing prompts).\n trace_llm_io: bool = True\n # Run-introspection tools (ON by default): make the LLM Researcher (and DeepResearcher) a\n # tool-using agent that can read its OWN experiments (list/read/find-analogous/themes/code) and\n # the task data (schema/profile/asset) mid-loop, instead of seeing only best+parent. Advisory —\n # never changes best-selection. Off = the legacy single-shot Researcher (richer digest still added).\n researcher_tools: bool = True\n # Cross-run introspection: give the agentic Researcher / pilot read-only tools to look at\n # SIBLING runs (same task_id, same run-root) — list them, read an experiment / its code, and\n # find analogous configs across runs — so a run can build on what neighbouring runs already\n # learned instead of rediscovering it. Advisory; never changes best-selection. Needs the run's\n # own dir wired through (no-op for unit-built roles). Off = the legacy single-run view only.\n cross_run_tools: bool = True\n # Read-only access to EVERY run on this machine ACROSS ALL TASKS (not just same-task siblings):\n # the Developer/Researcher get list_all_runs + read_run_code + read_run_experiment so they can read\n # the code + result of ANY past experiment anywhere and reuse an approach. Broader than\n # `cross_run_tools` (same-task only); the agent decides when a foreign run is relevant. Advisory;\n # never changes best-selection. Needs the run's own dir wired through (no-op for unit-built roles).\n all_runs_tools: bool = True\n # PART V §22 — read-only CROSS-RUN KNOWLEDGE tool for the reasoning roles (Researcher, Strategist,\n # deep-research, and a Developer-scoped variant). Adds `cross_run_prior_attempts` / `cross_run_claims`\n # / `cross_run_atlas` over the §21.20 read-models (concept overview + claim assessments + atlas), read\n # from `memory_dir` (lessons.jsonl + concept_capsules.jsonl). Read-only is a storage-authority\n # boundary, not a behavioral no-op: retrieved content can steer later proposals, and a tool-using\n # reasoning loop can consume additional model calls. The tools never mutate cross-run truth (facts\n # are engine-written, verdicts operator-ratified, §22.4) or directly select a winner. ON by explicit\n # experimental product choice, EngineOptions off; stores only exist once Part-IV features have run.\n cross_run_read_tools: bool = True\n # Agentic retrieval (ADR-16): if set, the LLM Researcher gets grep/kb_search/read\n # tools over this directory of markdown notes and chooses when to use them.\n knowledge_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"knowledge\"))\n # T4 real embeddings: model id for an OpenAI-compatible `/embeddings` endpoint used for\n # kb_search / case retrieval. Blank (default) = the dependency-free lexical `hash_embed` (today's\n # behavior). Set e.g. \"nomic-embed-text\" (Ollama) or \"text-embedding-3-small\" for SEMANTIC\n # retrieval; a misconfigured/offline endpoint degrades back to hash_embed at call time (never\n # crashes a run). `embed_base_url` blank = reuse `llm_base_url`; the shared `llm_api_key` is used.\n embed_model: str | None = None\n embed_base_url: str | None = None\n # Harmonic memory (Memora, ICML'26 — idea import). When ON, cross-run cases and knowledge notes are\n # indexed by a short ABSTRACTION + cue ANCHORS instead of their raw text, near-duplicate memories are\n # CONSOLIDATED into one entry under a matching abstraction, and `kb_search`/case retrieval EXPAND\n # through the top hits' anchors to surface related-but-not-similar memories. ON by default. Set\n # `memora=false` to restore the pre-Memora raw-text index. Never a source of truth — abstractions\n # live only in the derived, rebuildable retrieval index (never the event log or canonical cases.jsonl).\n memora: bool = True\n # Write abstractions with the wired chat model (richer than the deterministic lexical fallback). ON\n # by default; results are CACHED by content hash (see `memora_cache`) so a re-built index doesn't\n # re-call the model on unchanged notes/cases, and any endpoint failure degrades to lexical at call\n # time — so an offline box just gets lexical abstractions, never a crash. Set false to force lexical.\n memora_llm: bool = True\n # === Cross-run memory / Memora ========================================================\n memora_anchors: int = 6 # max cue anchors kept per memory\n # Cosine similarity at/above which a new case is CONSOLIDATED into an existing one (same evolving\n # topic) rather than stored as a separate entry. Only used when `memora` is on.\n memora_consolidate_threshold: float = 0.86\n # Where the LLM-abstraction cache lives (JSON). Blank (default) derives it from `memory_dir`\n # (`<memory_dir>/memora_cache.json`) or else `knowledge_dir` (`<knowledge_dir>/.memora_cache.json`);\n # with neither set the cache is in-memory only (per process). No-op unless `memora_llm` is on.\n memora_cache: str | None = None\n # E3 literature-grounded ideation (network-OPTIONAL): give the agentic Researcher an arXiv search\n # tool to ground ideas in real techniques. Off by default (egress is unreliable on some boxes);\n # fails gracefully if the network is blocked.\n literature_search: bool = False\n # Deep-Research stage (network-OPTIONAL): give the DeepResearcher a general web search/fetch\n # tool (DuckDuckGo) on top of arXiv to read across results + the web before steering the next\n # batch. Off by default (egress is unreliable on some boxes); fails gracefully when blocked.\n web_search: bool = False\n # Cadence for the Deep-Research stage: run it automatically every N created nodes. Zero disables\n # only the cadence; manual `deep_research` and Strategist `request_research` triggers still fire.\n # Default 3: deep research analyzes the accumulating results and steers the next batch (its\n # directions become hints + open hypotheses); paired with `concurrent_research` it overlaps the\n # think with the GPU-bound eval, so the cadence costs LLM tokens but no wall-clock on the search.\n deep_research_every: int = 3\n # Overlap a DUE deep-research \"think\" with the GPU-bound eval instead of running it in its own\n # serial step (the agent is otherwise idle while a node trains). research() computes from a state\n # snapshot; the joined background task records only allowlisted, order-tolerant\n # research/hint/hypothesis events as soon as the memo finishes. ON by default: the LLM is typically\n # remote (no GPU contention with eval), so overlapping hides latency behind long training.\n concurrent_research: bool = True\n # Concurrent-research REPEAT — don't idle a multi-DAY eval. The overlapped \"think\" fires ONCE per\n # window by default; ON here, it RE-RUNS on an adaptive time cadence for the WHOLE eval window\n # (a two-day training must not leave the reasoning agents idle after one memo). Self-paced: it\n # only records a memo whose CONTENT is new (identical re-runs are skipped, so the log/board don't\n # bloat) and backs off geometrically as the analysis converges, capped so it always re-checks.\n # The appended records never rewrite the current metric champion, but their hints/open hypotheses\n # deliberately steer later proposals and replay reconstructs that advice. The library default is\n # one-shot (== today, byte-identical); this product default turns the repetition on.\n concurrent_research_repeat: bool = True\n # Base cadence (seconds) between repeated concurrent-research passes. Research is EXPENSIVE\n # (multi-turn LLM + web/arXiv), so this is a FLOOR, not a ceiling: the effective cadence is\n # max(this, ~5% of the per-experiment time budget) — a two-day eval is re-researched about hourly,\n # a short eval not at all (the first tick outlasts it). Only matters when the repeat is on.\n concurrent_research_interval_s: float = Field(default=1800.0, gt=0)\n # Per-eval-window backstop on repeated-research LLM calls (0 = cadence-only, no cap). The adaptive\n # cadence + convergence backoff are the primary budget control; this bounds a pathological\n # never-converging window. Past it the loop stops calling the LLM (the health monitor still runs).\n concurrent_research_max_calls: int = Field(default=40, ge=0)\n # Concurrent hypothesis-board CONSOLIDATION during the eval window. Repeated research keeps ADDING\n # near-duplicate directions to the open board (each memo registers up to 5 as open hypotheses), so a\n # long eval's board bloats before the between-nodes merge runs. This dedups/merges it on the SAME\n # background loop so the board stays clean for the next proposal. In Card-driven mode the same merge\n # changes ownership/readiness, so it is deferred to the joined main-task cadence instead. Needs the\n # reflect client + `track_hypotheses`.\n # Only active while `concurrent_research` runs the overlap loop. Library default off (== today).\n concurrent_consolidate: bool = True\n # Cadence for the agent-authored run report: regenerate the conclusion-first narrative every N\n # created nodes (0 = off; it still regenerates on a manual `report_refresh` from the UI). The\n # deterministic report always renders from the node set regardless of this knob.\n report_every: int = 3\n # Agent Skills (I18, ADR-9): dir of SKILL.md the Researcher can list/load as tools.\n skills_dir: str | None = None\n # Prompt store (I18, ADR-8): dir of editable, hot-reloaded role prompt .md files.\n prompt_dir: str | None = None\n # API key as a reference, never serialized as a value (ADR-11). Local servers\n # ignore it; default to a placeholder.\n llm_api_key: SecretStr | None = None\n\n @model_validator(mode=\"before\")\n @classmethod\n def _reject_bool_for_numeric_fields(cls, data):\n \"\"\"A JSON boolean is a TYPE ERROR for a numeric setting, not the number 1.\n\n Pydantic runs in lax mode here (`model_config` sets no `strict`), and `isinstance(True, int)`\n is True — so `{\"max_nodes\": true}` from a UI/API type slip validated cleanly as a budget of\n ONE node, and `{\"eval_parallel\": true}` collapsed the width to one. The run then looked\n configured and simply did almost nothing, with no 422 and nothing in the snapshot to show why.\n Rejecting at the boundary covers every numeric field at once, including the many that carry no\n `Field(ge=...)` bound of their own to trip over.\n \"\"\"\n if not isinstance(data, dict):\n return data\n for key, val in data.items():\n if type(val) is bool and _is_numeric_annotation(\n getattr(cls.model_fields.get(key), \"annotation\", None)):\n raise ValueError(f\"{key} must be a number, not a boolean (got {val!r})\")\n return data\n\n @model_validator(mode=\"before\")\n @classmethod\n def _apply_profile(cls, data):\n \"\"\"Expand `profile` into a bundle of setting defaults BEFORE validation.\n\n pydantic-settings hands this validator the fully-merged input from ALL explicit sources\n (init kwargs + LOOPLAB_* env + .env), with fields the user left unset simply ABSENT (they\n fall to field defaults afterward). So a profile value is applied only when its key is not\n already present — i.e. any explicit file/CLI/env setting wins over the profile, and the\n profile wins over the bare field default. This makes `thorough` a true convenience preset,\n never an override of what the user asked for.\"\"\"\n if not isinstance(data, dict):\n return data\n prof = data.get(\"profile\", \"default\")\n # env may deliver the key as \"profile\" (env_prefix stripped) — normalize to a str\n prof = str(prof).strip().lower() if prof is not None else \"default\"\n overrides = PROFILES.get(prof)\n if overrides is None:\n raise ValueError(f\"unknown profile {prof!r}; known: {sorted(PROFILES)}\")\n for key, val in overrides.items():\n if key not in data: # explicit source (file/CLI/env) always wins\n data[key] = val\n return data\n\n @model_validator(mode=\"after\")\n def _check_trust_gate(self):\n if self.trust_gate not in (\"audit\", \"gate\", \"block\"):\n raise ValueError(\n f\"trust_gate must be audit|gate|block, got {self.trust_gate!r}\")\n if self.merge_mode not in (\"auto\", \"mean\", \"ensemble\"):\n raise ValueError(\n f\"merge_mode must be auto|mean|ensemble, got {self.merge_mode!r}\")\n # novelty_mode drives the dedup/novelty gate; an out-of-set value (e.g. a mis-cased\n # LOOPLAB_NOVELTY_MODE=LLM, or \"on\") used to fall through _apply_novelty_gate silently as a\n # NO-OP — turning the gate off with no diagnostic. Fail loudly at config time like trust_gate/\n # merge_mode do. (Other enum-ish string fields — seed_mode/eval_trust_mode/strategist_backend —\n # share the pattern; left unvalidated here only because their full value sets are less settled.)\n if self.novelty_mode not in (\"off\", \"algo\", \"llm\"):\n raise ValueError(\n f\"novelty_mode must be off|algo|llm, got {self.novelty_mode!r}\")\n # The remaining enum-ish string fields (arch-review §5 P3): a typo used to be accepted at\n # construction and only fail-safe/later-loud downstream (e.g. a mis-cased strategist_backend\n # silently ran the default). Fail loudly here like the fields above.\n if self.strategist_backend not in (\"off\", \"rule\", \"llm\", \"agent\"):\n raise ValueError(\n f\"strategist_backend must be off|rule|llm|agent, got {self.strategist_backend!r}\")\n if self.eval_trust_mode not in (\"ratify_freeze\", \"autonomous\", \"ratify_freeze_drift\"):\n raise ValueError(\n \"eval_trust_mode must be ratify_freeze|autonomous|ratify_freeze_drift, \"\n f\"got {self.eval_trust_mode!r}\")\n if self.seed_mode not in (\"auto\", \"tracked\", \"all\"):\n raise ValueError(f\"seed_mode must be auto|tracked|all, got {self.seed_mode!r}\")\n if self.backend not in (\"toy\", \"llm\"):\n raise ValueError(f\"backend must be toy|llm, got {self.backend!r}\")\n self._check_llm_profiles()\n return self\n\n def _check_llm_profiles(self) -> None:\n \"\"\"Validate the connection-profile maps LOUDLY, at construction.\n\n Assignment is not validated anywhere in this project, and these two maps are the kind of\n thing a typo makes into a silent no-op: an unknown role name would simply never be read, and\n a reference to a missing profile would fall back to the shared model while the operator\n believed a second provider was in play. Both are errors here instead.\n\n The `api_key_env` rules exist for a specific reason. It must LOOK like a secret to\n `runtime/sandbox.py::SECRET_ENV`, because that is the filter that strips the operator's keys\n out of the environment handed to generated candidate code — a key parked in a variable named\n `MY_AUTH` would be invisible to it and ride straight into the sandbox. (The pattern is\n duplicated below rather than imported: layering forbids `core` from importing `runtime`.\n `tests/test_secret_env_pattern.py` pins the two together.) And a literal key inside a profile\n is refused outright: the profile map is written verbatim into config.snapshot.json and served\n over HTTP precisely because it is supposed to contain no secrets.\"\"\"\n from looplab.core.llm import LLM_ROLE_KEYS\n if not isinstance(self.llm_profiles, dict):\n raise ValueError(\"llm_profiles must be a map of profile name -> profile\")\n for name, entry in self.llm_profiles.items():\n if not isinstance(entry, dict):\n raise ValueError(f\"llm_profiles[{name!r}] must be an object\")\n # BEFORE the unknown-field check: none of these is a known field, so checking them\n # afterwards could never fire and someone who pasted a live key was told it was a typo.\n for banned in (\"api_key\", \"key\", \"token\", \"secret\"):\n if banned in entry:\n raise ValueError(\n f\"llm_profiles[{name!r}] must not contain {banned!r}: a profile stores \"\n \"api_key_env (the NAME of an environment variable), never a key value — \"\n \"the whole map is written to config.snapshot.json and served over HTTP\")\n unknown = set(entry) - _PROFILE_FIELDS\n if unknown:\n raise ValueError(\n f\"llm_profiles[{name!r}] has unknown field(s) {sorted(unknown)}; \"\n f\"known: {sorted(_PROFILE_FIELDS)}\")\n env = entry.get(\"api_key_env\")\n if env is not None and not _SECRET_ENV_NAME.match(str(env)):\n raise ValueError(\n f\"llm_profiles[{name!r}].api_key_env={env!r} is not a usable variable name: it \"\n \"must be UPPER_SNAKE and contain KEY/SECRET/TOKEN/PASSWORD/PASSWD/CREDENTIAL, so \"\n \"the sandbox's secret-name filter strips it from generated code's environment\")\n temp = entry.get(\"temperature\")\n if temp is not None and (isinstance(temp, bool) or not isinstance(temp, (int, float))):\n raise ValueError(f\"llm_profiles[{name!r}].temperature must be a number\")\n if not isinstance(self.role_profiles, dict):\n raise ValueError(\"role_profiles must be a map of role name -> profile name\")\n for role, profile in self.role_profiles.items():\n if role not in LLM_ROLE_KEYS:\n raise ValueError(\n f\"role_profiles has unknown role {role!r}; known: {sorted(LLM_ROLE_KEYS)}\")\n if profile not in self.llm_profiles:\n raise ValueError(\n f\"role_profiles[{role!r}] names profile {profile!r}, which is not in llm_profiles\")\n if self.llm_profile is not None and self.llm_profile not in self.llm_profiles:\n raise ValueError(\n f\"llm_profile={self.llm_profile!r} is not in llm_profiles\")\n\n def masked_snapshot(self) -> dict:\n # Snapshots are a JSON contract (they are written verbatim to config.snapshot.json), not a\n # Python-object dump. In particular tuple-valued schema fields such as\n # ``inline_repair_reasons`` must become JSON arrays before callers hash the runtime envelope;\n # otherwise the strict speculation-scope canonicalizer sees a different/non-JSON preimage\n # from the one that ``json.dumps`` eventually persists.\n d = self.model_dump(mode=\"json\")\n if self.llm_api_key is not None:\n d[\"llm_api_key\"] = \"***\"\n else:\n d[\"llm_api_key\"] = None\n # The snapshot's own FORMAT version — not a Settings field (it is never settable by env or\n # file, and it describes the document, not the run). `settings_from_snapshot` fails closed on\n # a version it does not understand; see CONFIG_SNAPSHOT_SCHEMA.\n d[CONFIG_SNAPSHOT_SCHEMA_KEY] = CONFIG_SNAPSHOT_SCHEMA\n return d\n\n\n# The config snapshot's document-format version, written by `masked_snapshot` and enforced by\n# `settings_from_snapshot`. Bump it when a snapshot written by this binary would be MISREAD by an\n# older one — i.e. when a new field changes paid, concurrency or selection behaviour and silently\n# defaulting it would resume the same event history under different semantics. `Settings` is\n# `extra=\"ignore\"`, so without this an older binary drops what it does not recognize and resumes\n# anyway, with no diagnostic; a version it does not know is now a loud refusal instead.\n# Version 1 is the first VERSIONED format. An absent key means a pre-versioning snapshot, which stays\n# readable through `LEGACY_CONFIG_SNAPSHOT_DEFAULTS` — that historical contract is unchanged.\nCONFIG_SNAPSHOT_SCHEMA = 1\nCONFIG_SNAPSHOT_SCHEMA_KEY = \"config_snapshot_schema\"\n\n\nclass ConfigSnapshotVersionError(ValueError):\n \"\"\"A config snapshot was written by a NEWER binary than the one reading it.\"\"\"\n\n\n# Pre-versioned snapshots are full Settings dumps, so absence is a reliable per-field version marker.\n# Keep their historical effective behavior when newer product defaults become active: re-entry must not\n# silently add paid calls, interventions, concurrency or a different selection policy to an old run.\nLEGACY_CONFIG_SNAPSHOT_DEFAULTS: dict[str, object] = {\n \"parallel_build\": 1,\n \"eval_parallel\": None,\n \"llm_parallel\": None,\n \"train_monitor\": False,\n \"train_monitor_interval_s\": 600.0,\n \"train_monitor_kill\": False,\n \"train_monitor_kill_confidence\": 0.8,\n \"asha_live\": False,\n \"asha_live_kill\": False,\n \"asha_live_quantile\": 0.5,\n \"asha_live_min_siblings\": 3,\n # Before this operator ceiling existed, the sandbox's 24-hour process limit was authoritative.\n \"max_eval_timeout\": 24 * 3600.0,\n \"watchdog_reflection\": False,\n \"card_driven_selection\": False,\n \"speculation_depth\": 0,\n \"speculation_gate_receipt\": None,\n \"concurrent_research_repeat\": False,\n \"concurrent_research_interval_s\": 1800.0,\n # Zero means unlimited; 40 was the first bounded default, and is latent while repeat is disabled.\n \"concurrent_research_max_calls\": 40,\n \"concurrent_consolidate\": False,\n}\n\n\ndef migrate_config_snapshot(data: dict) -> dict:\n \"\"\"Return an effective copy of one legacy/current snapshot without rewriting evidence bytes.\"\"\"\n if not isinstance(data, dict):\n raise TypeError(\"config snapshot must be a JSON object\")\n migrated = dict(data)\n for key, value in LEGACY_CONFIG_SNAPSHOT_DEFAULTS.items():\n migrated.setdefault(key, value)\n return migrated\n\n\ndef config_snapshot_schema(data: dict) -> int:\n \"\"\"The snapshot's declared format version. 0 = pre-versioning (the key did not exist yet).\n\n A malformed value is treated as UNREADABLE rather than as 0: a snapshot whose own version marker\n is corrupt cannot be assumed to be the oldest format.\"\"\"\n raw = data.get(CONFIG_SNAPSHOT_SCHEMA_KEY, 0) if isinstance(data, dict) else 0\n if isinstance(raw, bool) or not isinstance(raw, int) or raw < 0:\n raise ConfigSnapshotVersionError(\n f\"config snapshot has a malformed {CONFIG_SNAPSHOT_SCHEMA_KEY}: {raw!r}\")\n return raw\n\n\ndef settings_from_snapshot(data: dict) -> Settings:\n \"\"\"Build re-entry Settings from a masked snapshot under its historical missing-field contract.\n\n Fails closed on a snapshot written by a NEWER binary. `Settings` is `extra=\"ignore\"`, so such a\n snapshot would otherwise load with every unrecognized control silently dropped — the run would\n resume on the same event history under different paid/concurrency/selection semantics, with\n nothing to show why. Refusing is the only honest answer: this binary cannot know what it is\n dropping. Older and pre-versioning snapshots keep loading exactly as before.\"\"\"\n found = config_snapshot_schema(data)\n if found > CONFIG_SNAPSHOT_SCHEMA:\n raise ConfigSnapshotVersionError(\n f\"this run's config.snapshot.json declares format v{found}, but this build understands \"\n f\"at most v{CONFIG_SNAPSHOT_SCHEMA}. It was written by a newer LoopLab; resuming here \"\n \"would silently drop settings this build does not know. Upgrade LoopLab to resume it.\")\n migrated = migrate_config_snapshot(data)\n migrated.pop(\"llm_api_key\", None)\n migrated.pop(CONFIG_SNAPSHOT_SCHEMA_KEY, None) # a document marker, never a Settings field\n return Settings(**migrated)\n"}, "3721e47ccc786b1327e65ab12cedb89114bcea08": {"t": 1785768752.5524309, "n": 111030, "h": "7f1ef267580e0652", "c": "\"\"\"Engine config: the schema (what settings exist). (I0, ADR-11)\n\nOne pydantic-settings class declaring every engine knob, its default, and its docs. The loader —\nhow a run file / CLI / env becomes a `Settings` — is `looplab.core.appconfig`, which also owns\nthe config-precedence order. A resolved, secret-masked snapshot is written next to each run for\nreproducibility. (No real secrets in P0, but the masking discipline is in place.)\n\"\"\"\nfrom __future__ import annotations\n\nimport re\nimport types\nimport typing\nfrom pathlib import Path\n\nfrom pydantic import Field, SecretStr, field_validator, model_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n# Single source of the LLM first-byte (response-headers) default — see core/llm.py.\nfrom looplab.core.llm import DEFAULT_HEADER_TIMEOUT_S\n\n# Default home for cross-run memory + the knowledge base (shared across every run). Both are ON by\n# default now (a user asked for it) so a fresh install accumulates + consults knowledge with no setup;\n# set the env var to \"\" to disable, or to a path to relocate. `~/.looplab/` keeps them out of any one\n# project + shared across projects.\n_LL_HOME = Path.home() / \".looplab\"\n\n# What one `llm_profiles` entry may contain. Deliberately closed: an unrecognized field is a typo the\n# operator would otherwise never see fail, and the map is a public artifact (config.snapshot.json,\n# HTTP, LOOPLAB_*), so it must never accrete free-form keys.\n_PROFILE_FIELDS = frozenset({\"model\", \"base_url\", \"temperature\", \"api_key_env\"})\n# A variable name that `runtime/sandbox.py::SECRET_ENV` will recognize as holding a secret and strip\n# from generated code's environment. Duplicated here, not imported: layering forbids `core` from\n# importing `runtime`. `tests/test_secret_env_pattern.py` holds the two in agreement.\n_SECRET_ENV_NAME = re.compile(\n r\"\\A(?=[A-Z][A-Z0-9_]{0,63}\\Z)(?=.*(KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL)).*\\Z\")\n\n\ndef _is_numeric_annotation(ann) -> bool:\n \"\"\"True when a field's annotation admits int/float but NOT bool.\n\n Used by `Settings._reject_bool_for_numeric_fields`: pydantic resolves annotations at class build,\n so `model_fields[...].annotation` is a real type object here even under\n `from __future__ import annotations`. Optionals are numeric when every non-None member is\n (`int | None` yes, `int | str` no), so a boolean can never slip in through a union arm.\"\"\"\n if ann is bool:\n return False # a bool field legitimately takes a bool\n if ann is int or ann is float:\n return True\n args = [a for a in typing.get_args(ann) if a is not type(None)]\n origin = typing.get_origin(ann)\n if args and origin in (typing.Union, types.UnionType):\n return all(_is_numeric_annotation(a) for a in args)\n return False\n\n# Autonomous roles that may be granted per-setting write access (see Settings.agent_control).\n# strategist = A7 meta-controller (run-wide); boss = run-chat operator-proxy (run-wide);\n# researcher = per-experiment proposer (per-node sizing, e.g. eval timeout for a heavy model).\nAGENT_ROLES = (\"strategist\", \"boss\", \"researcher\")\n\n# A foresight score is computed for every acted-on proposal. Keep this lower than the generic\n# verifier's emergency ceiling so a typo cannot multiply paid synchronous calls across a run.\nMAX_FORESIGHT_VERIFY_SAMPLES = 8\n\n\n# Layer-2 parallelism renamed two public settings without removing the durable legacy fields. Keep\n# the equivalence in one place: config-source precedence, governance migration, and live strategy\n# application must all agree which names describe the same authority/value.\nPARALLELISM_ALIASES: dict[str, str] = {\n \"max_parallel\": \"eval_parallel\",\n \"parallel_build\": \"llm_parallel\",\n}\n\n\ndef parallelism_aliases(setting: str) -> tuple[str, ...]:\n \"\"\"Return every spelling in ``setting``'s alias family, canonical name first.\"\"\"\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if setting in (legacy, canonical):\n return canonical, legacy\n return (setting,)\n\n\ndef canonicalize_parallelism_source(\n values: dict, *, promote_build_to_llm_parallel: bool = False,\n) -> dict:\n \"\"\"Promote legacy parallel keys *within one config-precedence source*.\n\n Applying this only after file/CLI/env were flattened would let a lower-priority canonical env\n value shadow a higher-priority legacy file value (or a file canonical value shadow a CLI legacy\n override), so callers with sub-layers normalize each layer independently.\n\n ``max_parallel``/``eval_parallel`` are true aliases (pure eval width, no extra semantics) and are\n always promoted. ``parallel_build``/``llm_parallel`` are NOT: a positive ``llm_parallel`` also flips\n on the finite shared LLM broker (``orchestrator._startup_llm_total``), while legacy ``parallel_build``\n means \"legacy build width, unbounded overlap, no shared total\". So promoting a legacy-only\n ``parallel_build`` layer (e.g. just ``LOOPLAB_PARALLEL_BUILD=1`` in env) would silently serialize\n every provider call — contradicting llm_broker.py's docstring, orchestrator's \"legacy-only preserves\n unbounded overlap\" note, the broker-neutral live-control path, and the configuration.md ``llm_parallel``\n row (\"falls back ... without enabling a shared total\"). Config/startup loads therefore keep broker\n opt-in tied to an EXPLICITLY-spelled canonical ``llm_parallel``; the orchestrator's own width fallback\n (``llm_parallel if not None else parallel_build``) still carries the legacy build width. Only the\n Strategist live-control path (``canonicalize_strategy_parallelism``) opts into\n ``promote_build_to_llm_parallel``, where ``parallel_build`` is a deliberate full alias of the\n canonical knob (see strategy.py ``_apply_strategy``).\n \"\"\"\n if not isinstance(values, dict):\n return values\n out = dict(values)\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if canonical == \"llm_parallel\" and not promote_build_to_llm_parallel:\n continue\n # None is the durable legacy mode for llm_parallel, not a missing value. A normal snapshot\n # contains llm_parallel:null plus parallel_build:1; promoting null to 1 would unexpectedly enable\n # the global one-call broker. Promote aliases only when the canonical key is absent, and pin\n # snapshot round-trip parity. Canonicalize inside each precedence layer, never across the\n # flattened result, so the alias migration cannot invert CLI > file > env precedence.\n if canonical not in out and out.get(legacy) is not None:\n out[canonical] = out[legacy]\n # PRECEDENCE REPAIR for the non-promoted pair. Skipping promotion above is right (a legacy-only\n # layer must not flip on the shared broker), but it also let a LOWER-priority canonical value\n # survive a HIGHER-priority legacy override: file `llm_parallel: 2` + CLI `-s parallel_build=8`\n # flattened to BOTH keys, and the orchestrator prefers `llm_parallel`, so the operator's explicit\n # CLI width was silently discarded — exactly the inversion `build_settings`' docstring says this\n # per-layer canonicalization exists to prevent. Mask it with the LEGACY SENTINEL instead of\n # promoting: `llm_parallel=None` is the durable \"legacy mode — take the build width from\n # parallel_build, no shared total\", so the higher layer wins with the semantics it asked for.\n if (not promote_build_to_llm_parallel and \"llm_parallel\" not in out\n and out.get(\"parallel_build\") is not None):\n out[\"llm_parallel\"] = None\n return out\n\n# Settings whose semantics are committed by ``run_started`` and restored from the folded event log\n# on every re-entry. Changing one only in ``config.snapshot.json`` would mix incompatible evidence\n# (a different holdout split) or make the live producer disagree with replay's selection policy.\n# The HTTP config editor and the engine both consume this one contract; keep deliberately mutable\n# controls such as ``trust_gate`` out (that field has its own durable change event).\nRUN_START_PINNED_FIELDS = frozenset({\n \"card_driven_selection\",\n \"speculation_depth\",\n \"holdout_fraction\",\n \"holdout_select\",\n \"select_verifier\",\n \"select_verifier_samples\",\n \"verifier_ci_tie\",\n})\n\n\ndef run_start_pinned_settings(state) -> dict:\n \"\"\"Return the effective Settings names committed by a folded run-start record.\n\n Pre-pin legacy logs have no recorded holdout fraction, so their snapshot remains authoritative\n for both holdout knobs. Verifier fields, however, have always been re-pinned from the legacy-safe\n fold defaults whenever a valid ``run_started`` identity exists.\n \"\"\"\n if not getattr(state, \"run_id\", \"\"):\n return {}\n values = {\n # Layer 3 changes which queue owns macro-action selection. Once a run starts, replay and\n # resume must keep the same owner even if the snapshot or ambient default changes later.\n \"card_driven_selection\": bool(getattr(state, \"card_driven_selection\", False)),\n # Layer 5 overlaps Card production with evaluation only when this pinned treatment is\n # positive. Old logs omit it and therefore retain the exact serial build/eval spine.\n \"speculation_depth\": int(getattr(state, \"speculation_depth\", 0)),\n \"select_verifier\": bool(getattr(state, \"select_verifier_tiebreak\", False)),\n \"select_verifier_samples\": int(getattr(state, \"select_verifier_samples\", 3)),\n \"verifier_ci_tie\": bool(getattr(state, \"verifier_ci_tie\", False)),\n }\n holdout_fraction = getattr(state, \"holdout_fraction\", None)\n if holdout_fraction is not None:\n values.update({\n \"holdout_fraction\": holdout_fraction,\n \"holdout_select\": bool(getattr(state, \"holdout_select\", False)),\n })\n if not values.keys() <= RUN_START_PINNED_FIELDS:\n raise RuntimeError(\"folded run-start pinned settings contract drifted\")\n return values\n\n# --- Run profiles (config-first presets) -------------------------------------------------------\n# A `profile` is a NAMED BUNDLE of setting defaults — nothing more. It only fills fields the user\n# did NOT set explicitly (via file / --set / LOOPLAB_* env / init-kwargs), so any explicit knob\n# always wins (see `_apply_profile`). Most engine intelligence lives behind individual flags. The\n# PART IV/V cross-run + concept-graph machinery (concept_pivot, graded_novelty, cross_run_concepts /\n# _advisory / _structured_claims / _curation / _read_tools, fingerprint_universal, foresight_verify)\n# now ships ON in the product `Settings` default. That is an explicit experimental product choice,\n# not a claim that a frozen A/B or workload-wide cost/quality gate has cleared: the bundle can steer\n# prompts and candidate generation, change graded-novelty admission / pre-execution choices, and add\n# paid LLM work when its client and memory prerequisites are present. The bare-library `EngineOptions`\n# default stays lean (see tests/test_options_divergence.py — product side is the aggressive one) so a\n# toy `Engine(...)` in a test doesn't fire cross-run LLM work unasked. The trust/confirm/ablate QUALITY\n# bundle still ships OFF and is turned on by `thorough`. Every value here is reachable by hand — the\n# profile is a convenience, never a hidden mode.\n# keep this rationale behavioral and evidence-based; do not relabel read-only storage\n# access or proposal-only governance as a no-op for agent behavior, cost, or proposal admission.\n#\n# `thorough` deliberately touches only QUALITY/TRUST machinery, not spend: it does NOT raise\n# max_nodes / eval_parallel (those are cost knobs the user owns). It enables multi-seed confirmation\n# (demote seed-lucky leaders), the reward-hack / leakage / critic monitors AND flips them from\n# advisory to *gating* (`trust_gate=\"gate\"` — a\n# flagged win can no longer be selected as best), ablation-driven refinement, and the prompt cues\n# (complexity / budget / failure-reflection / watchdog-reflection). Agentic novelty and the experimental\n# Part IV/V bundle are already product defaults, not profile changes. Effect is large; marginal cost is a\n# few extra evals.\nPROFILES: dict[str, dict] = {\n \"default\": {}, # explicit no-op alias for \"ship-as-is\" (== omitting profile)\n \"fast\": {}, # today's lean defaults, named for symmetry with `thorough`\n \"thorough\": {\n \"confirm_top_k\": 3,\n \"confirm_seeds\": 3,\n # novelty_gate intentionally NOT enabled here: duplicate-detection is the agentic Researcher's\n # job (it reads past experiments + find_analogous_across_runs and DECIDES), not an algorithmic\n # param-distance / embedding auto-reject that mis-fired (it nudged a good fresh idea onto a\n # twice-dead lineage — live node 61). Search stays as tools; the LLM judges novelty.\n \"operator_bandit\": True, # P4: adaptive operator mix over folded yields (GreedyTree)\n \"reward_hack_detect\": True,\n \"code_leakage_detect\": True,\n \"critic_check\": True,\n \"trust_gate\": \"gate\", # detectors stop gating a flagged node from WINNING (not just audit)\n \"ablate_every\": 3,\n \"complexity_cue\": True,\n \"budget_aware\": True,\n \"failure_reflection\": True,\n \"watchdog_reflection\": True, # feed recent live-watchdog (train-monitor/ASHA) flags to proposals\n \"reflection_priors\": True, # no-op unless memory_dir is set (cross-run priors)\n },\n}\n\n\n# The shipped agent-governance matrix (who may change which setting at runtime). Defined as a module\n# constant so the Engine's direct-construction default matches this shipped default exactly (the\n# EngineOptions \"Engine() == shipped defaults\" invariant) instead of diverging to an all-locked map.\nDEFAULT_AGENT_CONTROL: dict[str, list[str]] = {\n \"timeout\": [\"researcher\", \"strategist\"],\n # Layer-2 canonical names are the presented/default governance surface. `_agent_may` falls back\n # to an old snapshot's legacy grant only when its canonical counterpart is absent, so removing\n # duplicate defaults preserves resume authority while an explicit canonical revocation still wins.\n \"eval_parallel\": [\"strategist\"],\n \"llm_parallel\": [\"strategist\"],\n # Strategy-only pseudo-setting: the closed per-lane allocation inside the canonical LLM total.\n # It is not a launch Settings field; operators pin it through `set_strategy` and the Strategist\n # may reallocate it only while this grant is present.\n \"llm_lane_limits\": [\"strategist\"],\n \"max_nodes\": [\"strategist\", \"boss\"],\n \"max_eval_seconds\": [\"strategist\", \"boss\"],\n \"policy\": [\"strategist\"],\n # The policy NAME and its PARAMS are gated independently in `_apply_strategy`; grant both by default\n # so the Strategist that may switch to (say) MCTS may also apply the `c`/`eta` it decided — else its\n # tuned params are silently dropped and the recorded `active_strategy` diverges from the live engine.\n \"policy_params\": [\"strategist\"],\n \"n_seeds\": [\"strategist\"],\n \"ablate_every\": [\"strategist\"],\n \"merge_mode\": [\"strategist\"],\n \"complexity_cue\": [\"strategist\"],\n \"ablate_code_blocks\": [\"strategist\"],\n \"prefer_sweep\": [\"strategist\"],\n \"novelty_stance\": [\"strategist\"],\n \"developer\": [\"strategist\"],\n \"fidelity\": [\"strategist\", \"researcher\"],\n}\n\n\ndef default_agent_control() -> dict[str, list[str]]:\n \"\"\"A FRESH deep-ish copy of the shipped governance matrix (each role list copied so a caller can\n mutate its own map without touching the module constant). The SINGLE constructor of the default,\n called by both `Settings.agent_control`'s default_factory AND the Engine's direct-construction\n default — so \"Engine() == shipped defaults\" can't drift if the copy depth ever needs to change\n (e.g. nested values requiring copy.deepcopy), instead of two verbatim copy expressions.\"\"\"\n return {k: list(v) for k, v in DEFAULT_AGENT_CONTROL.items()}\n\n\nclass Settings(BaseSettings):\n \"\"\"The engine settings schema (every knob a run accepts).\n\n Timeout family — six distinct knobs, each owned by a different subsystem:\n - `timeout`: per-eval wall-clock budget for ONE experiment's evaluation (engine/eval).\n - `max_eval_timeout`: hard ceiling for a governed per-node Researcher timeout override.\n - `llm_timeout`: LLM request idle timeout — inter-token stall limit in stream mode\n (core.llm OpenAICompatibleClient).\n - `llm_header_timeout`: LLM first-byte (response-headers) window for stream attempts (core.llm).\n - `agent_time_budget_s`: wall-clock ceiling across a tool-using agent loop's turns (agents).\n - `dep_install_timeout`: per-package budget for auto-installing a missing dep (env self-prep).\n\n Config-source precedence is owned by `looplab.core.appconfig` (the loader) — see its module\n docstring for the one canonical order.\n \"\"\"\n # Config precedence: see appconfig.py — the loader owns the full order. The `.env` is read\n # so `looplab run`/`looplab ui` pick up LOOPLAB_LLM_BASE_URL etc. without exporting them by hand\n # (keys MUST carry the LOOPLAB_ prefix, same as the env vars). utf-8-sig tolerates a BOM from\n # Windows editors. The test suite disables this (tests/conftest.py) so a dev's real .env in the\n # repo root can't leak into assertions.\n model_config = SettingsConfigDict(\n env_prefix=\"LOOPLAB_\",\n env_file=\".env\",\n env_file_encoding=\"utf-8-sig\",\n extra=\"ignore\",\n allow_inf_nan=False,\n )\n\n @classmethod\n def settings_customise_sources(\n cls, settings_cls, init_settings, env_settings, dotenv_settings,\n file_secret_settings):\n \"\"\"Preserve source precedence across canonical/legacy parallelism names.\n\n Pydantic ranks init > env > dotenv correctly, but it ranks field names rather than semantic\n alias families. Promoting a legacy value inside each source before the merge makes an old\n explicit snapshot/file value beat a lower-priority canonical env value. ``appconfig`` also\n promotes its file/typed/--set sub-layers before combining them into the init source.\n \"\"\"\n def _with_parallel_aliases(source):\n return lambda: canonicalize_parallelism_source(source())\n\n return (\n _with_parallel_aliases(init_settings),\n _with_parallel_aliases(env_settings),\n _with_parallel_aliases(dotenv_settings),\n _with_parallel_aliases(file_secret_settings),\n )\n\n # Run profile (config-first preset, see PROFILES above). \"default\"/\"fast\" = today's lean\n # defaults; \"thorough\" turns the built quality/trust machinery ON in one word. A profile only\n # fills fields the user did NOT set — every knob it touches stays individually overridable.\n profile: str = \"default\"\n\n # Bounds (review C/⚪): a non-positive node/seed budget or timeout is never valid. Upper bounds are\n # equally load-bearing — the UI /start path writes these straight into the engine's env, and an\n # unbounded `eval_parallel` (or legacy `max_parallel`; e.g. 100000 from a crafted preflight) would\n # fan out that many sandboxes → resource exhaustion. Reject at config time, not mid-run. Ceilings\n # are generous; tests top out near 50.\n # Pydantic's lax mode coerces a JSON boolean into any numeric field (`true` -> 1), so a UI/API type\n # error used to silently collapse a run budget to ONE node instead of returning 422. Rejected for\n # EVERY numeric field by `_reject_bool_for_numeric_fields` below, not just the ones bounded here.\n n_seeds: int = Field(default=3, ge=1, le=1024)\n max_nodes: int = Field(default=8, ge=1, le=1_000_000)\n # legacy evaluation-width input. `eval_parallel` below is canonical and wins when set;\n # keep this field for old env/config/snapshots and direct Engine call sites. A width > 1 admits\n # concurrent evals, whose resource reservations may be CPU-only or span one/more GPUs — there is no\n # one-node/one-GPU promise. `max_parallel=0` is launch-time AUTO (detected GPU count, at least 1).\n max_parallel: int = Field(default=1, ge=0, le=1024)\n # legacy build-width input. `llm_parallel` below is canonical and also controls the\n # shared provider-call total when positive. A width > 1 uses fresh pooled researcher/developer pairs;\n # it says nothing about the later eval's CPU/GPU reservation. Startup `0` derives build fan-out from\n # resolved eval width; a live Strategist/operator zero settles to 1. Without role_factory it clamps to 1.\n parallel_build: int = Field(default=1, ge=0, le=64)\n # Hypothesis-card Kanban re-architecture (docs/23, Layer 2). Two INDEPENDENT concurrency axes,\n # decoupled from each other so cost (LLM work) can be tuned separately from throughput (GPU evals).\n # These are the CANONICAL names going forward; `max_parallel`/`parallel_build` above are kept as thin\n # back-compat aliases (env vars / snapshots / ~100 Engine(...) call sites depend on the exact names,\n # so they cannot be renamed — CLAUDE.md). Resolution (orchestrator.__init__): a NEW name, when set,\n # WINS over its legacy alias; None => fall back to the legacy field => byte-identical to today.\n # `eval_parallel`: concurrent EVALS (the GPU/experiment consumer). None -> max_parallel; 0 = AUTO\n # (one experiment per detected GPU). This is the in-Run eval-concurrency ceiling; the resource\n # scheduler bin-packs declared footprints separately. Sibling local GPU-owning Runs serialize\n # through the conservative host-pool lease; external execution needs its own admission boundary.\n # `llm_parallel`: total concurrent LLM provider calls + BUILD fan-out. None -> legacy\n # parallel_build semantics without a shared total; 0 = startup AUTO (= resolved eval_parallel\n # for build fan-out, historical research overlap unbounded). A positive canonical value enables\n # the named-lane broker the Strategist can reallocate at cadence.\n eval_parallel: int | None = Field(default=None, ge=0, le=1024)\n llm_parallel: int | None = Field(default=None, ge=0, le=64)\n # Training-log monitor (I-series watchdog family): a per-eval background observer that tails the live\n # training log while a (often multi-hour) declared stage runs in a worker thread. ON in the product\n # surface (Settings). Its alert event is fold-ignored and cannot directly change lifecycle/champion;\n # with watchdog_reflection on, that alert is nevertheless advice in a later Researcher\n # prompt and can change future proposals. It only fires on the command-eval path with an LLM client;\n # bare-library EngineOptions stays OFF. See test_options_divergence.\n # `_interval_s` is the BASE tick cadence (the effective one adapts to the per-experiment budget).\n train_monitor: bool = True\n train_monitor_interval_s: float = Field(default=600.0, gt=0)\n # Phase 3 — INTERVENTION (opt-in, separate from observation): let the monitor tree-kill a training the\n # LLM judges 'broken' (diverged / silent CPU fallback / not learning) EARLY, instead of burning the\n # whole budget. Off by default — the observer only WATCHES unless this is on. A kill only fires on a\n # 'broken' verdict (a plateau is 'watch', never killed) with confidence >= the threshold; the node then\n # fails normally (reason='monitor_broken'), so replay reconstructs it from that one terminal event.\n train_monitor_kill: bool = False\n train_monitor_kill_confidence: float = Field(default=0.8, ge=0.0, le=1.0)\n # ASHA live-curve watchdog (sibling of the training monitor): reads the latest INTERMEDIATE value of\n # the objective metric off the live log (reusing the eval's OWN metric reader). Finished-endpoint rank\n # remains advisory. An opt-in KILL additionally requires an operator-declared metric.resource_key and\n # enough sibling observations at exactly that resource; absent comparable evidence cannot stop a run.\n # Records a fold-ignored `asha_rank` diagnostic + trace span; the library default is OFF (off == today).\n asha_live: bool = True\n # Opt-in INTERVENTION (separate from the advisory signal): let the watchdog tree-kill a node whose\n # intermediate metric stays below the SAME-RESOURCE bar past a short grace window. Off by default —\n # it only surfaces endpoint rank unless this is on. A kill fails normally (asha_underperforming).\n asha_live_kill: bool = False\n # The bar sits at `asha_live_quantile` along a WORST->BEST ordering of the finished siblings' finals:\n # 0.5 = the median (a node worse than the median peer flags); a SMALLER value is more conservative —\n # it lowers the bar toward the WORST finished peer, so only a node worse than nearly all peers flags\n # (0.0 = worse than the worst). `min_siblings` is the floor of finished peers required before it ranks\n # at all, so it never acts on too little evidence.\n asha_live_quantile: float = Field(default=0.5, ge=0.0, le=1.0)\n asha_live_min_siblings: int = Field(default=3, ge=1)\n timeout: float = Field(default=30.0, gt=0)\n # Hard ceiling for the Researcher's governed per-node `Idea.eval_timeout`. One hour preserves the\n # existing heavy/neural-net authoring example while staying well below the sandbox's defensive\n # 24-hour subprocess ceiling. This is operator-owned run config: agents may request less or more,\n # but the accepted action is always clamped here after the `agent_control.timeout` permission gate.\n max_eval_timeout: float = Field(default=3600.0, gt=0, le=24 * 3600.0)\n # Intra-node sweep: a sweep node runs a whole grid in one process, so it gets this multiple of\n # `timeout` as its wall-clock budget (solution.py path; RepoTasks use their per-profile timeout).\n sweep_timeout_mult: float = Field(default=8.0, ge=1)\n # Eval STALL watchdog CAP (seconds): a stage that is completely SILENT on stdout/stderr for this\n # long — while still alive and BELOW its wall-clock deadline — is tree-killed early with a STALLED\n # marker (a hung dataloader/deadlock dies in minutes instead of burning a multi-hour timeout). The\n # per-stage window is `min(this, the stage's own timeout)`, so a short scorer's deadline fires first.\n # `0` DISABLES the watchdog entirely (only the hard deadline applies) — for a legitimately quiet\n # non-Python stage (block-buffered stdout, a script that logs only to its own file). Threaded into\n # the eval and surfaced to the Developer so its code can emit periodic progress to stay alive.\n eval_stall_timeout_s: float = Field(default=1800.0, ge=0)\n # Sandbox tier (ADR-13): \"trusted_local\" (subprocess, no Docker) for the CLI;\n # \"untrusted\" (Docker --network none, shared-kernel runtime) for hosted/multi-tenant UI;\n # \"hostile\" (untrusted + a true-isolation OCI runtime, gVisor `runsc` by default / Kata) for\n # running untrusted code. See runtime/sandbox.py::make_sandbox for the tier selection.\n trust_mode: str = \"trusted_local\"\n # Image for the untrusted command-eval tier (RepoTask, Phase 4): the framework's deps\n # should be baked in (the container runs --network none, so a pip setup can't fetch).\n docker_image: str = \"python:3.12-slim\"\n # Resource caps for the untrusted/hostile Docker tier (passed to `docker run --memory/--cpus`).\n # The default 4g bound protects other tenants from an OOM, but a model-training MLE-bench eval\n # routinely needs more — raise it (e.g. \"16g\") for such runs. \"\" disables the cap (unbounded).\n # Ignored by the trusted_local (subprocess) tier.\n sandbox_memory: str = \"4g\"\n sandbox_cpus: str = \"\"\n # Best-effort host-OOM guard for the TRUSTED-LOCAL (subprocess) tier. When set (e.g. \"8g\"), each\n # eval child gets an RLIMIT_AS address-space cap so a runaway trainer hits MemoryError instead of\n # OOM-killing the whole host (and the engine). POSIX only; a no-op on Windows. Default \"\" = OFF:\n # RLIMIT_AS bounds VIRTUAL memory, and CUDA/torch reserve tens of GB of virtual without using it,\n # so a default cap would spuriously kill GPU evals — leave it off for those and use the Docker\n # tier's real --memory cgroup bound (sandbox_memory) instead. Ideal for CPU/numpy/sklearn runs.\n sandbox_memory_local: str = \"\"\n # Best-effort disk-fill guard for the TRUSTED-LOCAL tier (P1-5): an RLIMIT_FSIZE cap on the size of\n # any single file an eval child writes (e.g. \"2g\"), so a runaway that writes an unbounded file gets\n # SIGXFSZ instead of filling the host disk. POSIX only. Default \"\" = OFF (large model checkpoints\n # need big files); set it for tasks that write only small artifacts.\n sandbox_fsize_local: str = \"\"\n # Search policy (ADR-2): \"greedy\" | \"evolutionary\" | \"mcts\" | \"asha\".\n policy: str = \"greedy\"\n # Ablation-driven refinement (I7): every N improves, ablate the best to find the\n # highest-impact parameter and refine it. 0 = off. (GreedyTree only.)\n ablate_every: int = 0\n # A0a (MLE-STAR): when ablating, treat each generated pipeline *code block* (not just a\n # numeric param) as an ablation unit — neutralize a block, measure the metric delta, refine\n # the highest-impact block. Off => the classic numeric-param ablation. Config-first knob.\n ablate_code_blocks: bool = False\n # A0b: real merge/ensembling. \"mean\" = legacy mean-param merge; \"ensemble\" = the Developer\n # writes a code-recombination ensemble over the two parents' solutions (verified: agent-proposed\n # ensembling 37.9%->43.9%). \"auto\" (default) = ensemble whenever the Developer actually\n # generates code (LLM/agent backends; declared via `is_code_generating`), mean for the\n # templated/toy developers where a code ensemble is meaningless — the evidence says code\n # recombination is the strongest single merge (removing it costs ~9 pp), so it is the default\n # wherever it can work. Falls back to mean when the Developer can't ensemble.\n merge_mode: str = \"auto\"\n # A0d (AIRA): inject a dynamic complexity hint into the draft/improve prompt keyed on the\n # node's child count (few children -> keep minimal; many -> escalate to ensembling/HPO).\n complexity_cue: bool = False\n # I1 (CAAFE-style) LLM feature-engineering: instruct the proposer/developer to add\n # semantically-meaningful engineered features (ratios, interactions, aggregations) as code. The\n # existing cross-validation eval + best-selection is the CV GATE — a feature set that doesn't\n # improve CV is never selected (feature engineering is non-universal, so this gate is mandatory).\n # Tabular tasks (those exposing column data). Off by default.\n feature_engineering: bool = False\n # A5: surface the REMAINING eval-compute budget into the proposal prompt so the Researcher can\n # reason over how much compute is left (explore broad while flush, exploit/cheapen when low).\n # No-op unless a max_eval_seconds budget is set.\n budget_aware: bool = False\n # A4 (LATS-style): feed a summary of the most recent FAILED branches (operator + error reason)\n # back into the proposal prompt so the proposer reflects on and avoids repeating them. ON by\n # default: it is SELECTIVE by construction (injects only when recent failures exist — the\n # evidenced form of reflection; reflecting at every step shows no gains, arXiv:2506.12928).\n failure_reflection: bool = True\n # Feed the LIVE-WATCHDOG observations (train-monitor health verdicts + ASHA intermediate-rank flags)\n # of recent experiments back into the proposal prompt, so the Researcher avoids re-proposing a config\n # whose TRAINING was already observed to be weak — even when the watchdog kills are off (default) and\n # the node ran to completion (those diagnostics are fold-ignored, so failure_reflection never sees\n # them). ON by default and SELECTIVE like failure_reflection (injects only when a recent flag exists).\n watchdog_reflection: bool = True\n # C3 deep test-driven repair: hand the Developer the failure taxonomy + a structured \"reproduce\n # then fix\" directive on debug, not just the raw stderr tail. ON by default (product surface); the\n # conservative library default in EngineOptions stays off, per that module's contract.\n deep_repair: bool = True\n # === Inline crash repair ==============================================================\n # Hybrid in-node crash repair: when an LLM-generated node CRASHES at runtime (mechanical errors\n # — bad import, removed kwarg, typo), the agent triages it and may repair the code IN PLACE within\n # the same eval (cheap; does NOT consume max_nodes and does NOT add a node to the search tree).\n # Semantic failures still flow to a new idea/debug node via the policy. ON by default; set False\n # to restore the prior behavior (every crash waits for a budgeted inter-node debug node).\n inline_repair: bool = True\n # Max in-place repair attempts per node before it fails normally. Default 0 = UNLIMITED: the same\n # agent keeps fixing + re-evaluating IN THE SAME node until it produces a clean metric — no new\n # node just to retry. Runaway is prevented not by a count but by the anti-stuck guard\n # (`inline_repair_stuck_repeat`): when the SAME error signature repeats with no progress, or the\n # agent's crash-triage says \"abandon\", the node fails. Set a positive N to cap the retries instead.\n inline_repair_attempts: int = Field(default=0, ge=0)\n # Anti-stuck: abandon in-node repair when the SAME error recurs this many times in a row (no\n # progress). Keeps \"unlimited\" repair from looping forever on an unfixable error.\n inline_repair_stuck_repeat: int = Field(default=4, ge=2)\n # Which failure reasons (_failure_reason: crash|timeout|oom|setup|no_metric|drift) are eligible for\n # inline repair. Default: mechanical crashes, timeouts AND OOM-kills — a timeout/OOM means the code\n # was too slow / too memory-hungry for the budget (or the pod's cgroup limit), not that the idea is\n # wrong, so the agent repairs it by reducing compute/memory (fewer estimators/epochs/folds/seeds, a\n # lighter model, a smaller batch, subsampling) instead of letting the node die unrecovered. Env\n # override expects a JSON array.\n inline_repair_reasons: tuple[str, ...] = (\"crash\", \"timeout\", \"oom\")\n # Cost ceiling for in-node repair of a MULTI-STAGE eval: the max number of FULL pipeline re-runs\n # (i.e. re-trains from the first stage) the inline-repair loop may do before abandoning to the\n # inter-node debug operator. A late-stage failure (a broken `score`/eval script that didn't touch\n # `train`) REUSES the completed train checkpoint and re-runs only from the failed stage — that is\n # CHEAP and NOT counted here; only a repair that changes an EARLIER stage's code (train.py/loss.py/…)\n # forces a full re-train, which each costs the whole training time. Without this bound, a repair that\n # keeps changing training code could burn many full trains (the anti-stuck guard is error-signature\n # based, not cost based). 0 = unlimited (legacy behavior). Single-command evals ignore it.\n inline_repair_retrain_cap: int = Field(default=2, ge=0)\n # Environment self-prep: when a solution crashes purely because a KNOWN library isn't installed\n # (ModuleNotFoundError), the engine pip-installs it into the eval interpreter and re-runs — so a\n # torch/XGBoost/CatBoost idea (e.g. a GRU model) actually runs on a fresh box instead of being\n # thrown away as `idea_rejected`. Trusted_local tier only (the untrusted/hostile Docker tiers run\n # --network none). A typo'd or local helper module is NOT installed (that's a code bug to repair).\n auto_install_deps: bool = True\n # Per-package wall-clock budget for an auto-install (seconds). Generous: large wheels like torch\n # can take minutes to download/build.\n dep_install_timeout: float = Field(default=900.0, gt=0)\n # --- Agent governance: who may change a setting at runtime (per-setting allow-list of roles) ---\n # Roles: \"strategist\" (A7 meta-controller; run-wide), \"boss\" (run-chat operator-proxy; run-wide),\n # \"researcher\" (per-experiment proposer; PER-NODE — e.g. set a longer eval timeout for a neural-net\n # idea). A setting ABSENT from this map is LOCKED — no agent may change it, only a human via the\n # snapshot/UI. The sole conditional exception is Strategy-only `card_scoring`: Card mode grants\n # it implicitly without changing the default flag-off snapshot, while an explicit [] revokes it.\n # The gate is ENFORCED at every AGENT seam via `_agent_may` — the strategist's whole\n # control surface (policy/operators/novelty_stance/prefer_sweep/developer/fidelity/timeout/\n # eval_parallel/llm_parallel) is checked in `_apply_strategy`, so removing a role from a knob locks it,\n # not just greys out the UI pill. The boss/max_* grants document boss-relevant knobs, but a\n # `budget_extend` is a HUMAN control intent (the boss action-builder can only emit `add_nodes`), so\n # it is applied as-is — the human always wins via the UI/snapshot. Defaults are conservative: resource/\n # search-shape knobs the agents already reason about; provider infra (llm_model/base_url/api_key,\n # docker_image) stays locked. The UI shows S/B/R pills per setting. `timeout` granted to the\n # researcher IS the \"auto\" per-node mode — the researcher sizes each experiment's wall-clock\n # (Idea.eval_timeout); the config `timeout` is the fallback for nodes it doesn't size. Env override\n # expects a JSON object. (`fidelity`/`novelty_stance`/`prefer_sweep`/`developer` are governance keys\n # for the strategist's per-node dials — not 1:1 Settings fields, but gated the same way.) The default\n # lives in the module-level DEFAULT_AGENT_CONTROL so the Engine's direct-construction default matches.\n agent_control: dict[str, list[str]] = Field(default_factory=default_agent_control)\n # C1 (Agentless localization): for repo tasks, rank the source files most relevant to the most\n # recent failure (traceback + identifiers) and surface them in the proposal/repair prompt so the\n # Developer edits the right place. Off by default; repo tasks only.\n localize_faults: bool = False\n # A2 surrogate-guided proposal (BO-lite): fit a k-NN surrogate over (params->metric) and propose\n # by acquisition instead of random/hill-climb. Numeric-bounds tasks only; bootstraps via the\n # base Researcher. Off by default. `surrogate_explore` = UCB-style exploration weight.\n surrogate_proposer: bool = False\n surrogate_explore: float = 0.1\n # E2 researcher panel: generate K candidate ideas per proposal and keep the one ranked best by a\n # cheap empirical surrogate over the (params->metric) history (NOT an LLM-judge). 1 = off.\n researcher_panel: int = 1\n # A1 ASHA / successive-halving: reduction factor (keep top 1/eta per rung) and rung budget.\n asha_eta: int = Field(default=3, ge=2)\n # Rung-0 width for ASHA/BOHB (the wide base of cheap drafts). 0 = use n_seeds (default,\n # preserving prior behavior); set >0 to seed a wider/narrower base independent of n_seeds.\n asha_rung_nodes: int = Field(default=0, ge=0)\n # A6 proxy/predictive scoring: cheaply rank a candidate's potential from early-stage signals\n # and skip a full eval for the doomed bottom fraction. 0.0 = off (no candidate skipped).\n proxy_scoring: bool = False\n proxy_kill_fraction: float = Field(default=0.0, ge=0.0, le=0.9)\n # === Novelty / dedup ==================================================================\n # E1 novelty/dedup gate: reject near-duplicate proposals (normalized param-space distance below\n # `novelty_epsilon`) by deterministically nudging them off the duplicate — stops the search\n # wasting evals re-trying the same idea. Audit event `novelty_rejected`. Off by default.\n # Novelty/dedup MODE selector: how a fresh proposal is checked against already-tried experiments.\n # \"off\" — no explicit gate; the agentic Researcher's own read-the-history judgment is trusted.\n # \"algo\" — the deterministic gate: numeric param-distance (`novelty_epsilon`) + optional embedding\n # similarity (`novelty_semantic`). Cheap, no LLM call, but can't explain itself.\n # \"llm\" — an LLM adjudicates (reads the real experiments via tools) whether the idea is a\n # near-duplicate and, if so, asks the Researcher once more for something different. One\n # extra LLM call per proposal — highest quality, follows the \"let the LLM decide\" line.\n novelty_mode: str = \"llm\"\n # Legacy sub-toggles, honored by the \"algo\" mode (and back-compat: novelty_gate=True forces \"algo\"):\n novelty_gate: bool = False\n novelty_epsilon: float = 0.05\n # T5 semantic novelty (active whenever the deterministic gate runs — novelty_mode=algo,\n # novelty_gate=true which is the legacy alias for algo, OR the Strategist novelty stance is\n # \"explore\"; a no-op only under novelty_mode=llm/off with a non-explore stance): reject a proposal\n # whose idea TEXT embeds within\n # `novelty_semantic_threshold` cosine of an existing node's. OFF BY DEFAULT: novelty is the agentic\n # Researcher's call, made by READING the actual prior experiments (read_experiment /\n # find_analogous_across_runs / list_experiments), NOT an embedding-similarity auto-reject that can't\n # explain itself and mis-fires on paraphrases. Semantic/param search stays available as TOOLS in the\n # Researcher's context; the LLM decides whether an idea is a true duplicate.\n novelty_semantic: bool = False\n novelty_semantic_threshold: float = Field(default=0.92, ge=0.5, le=1.0)\n # T10: debug-lineage depth bound for every policy — how many error-feedback repairs a failing\n # lineage gets before it is abandoned. The old default (1) abandoned lineages after ONE repair;\n # multi-turn/deeper debugging is a verified lever (AIRA2 ReAct debug +5.5 percentile points).\n debug_depth: int = Field(default=2, ge=1)\n # P4 operator bandit (GreedyTree): replace the fixed merge/ablate cadences with a\n # deterministic UCB over per-operator yield (Δmetric per eval-second, folded from the run).\n # Off by default (the cadences are well-tested and the bandit has no direct published\n # ablation); `thorough` turns it on.\n operator_bandit: bool = False\n # P1 hypothesis ledger: ask the Researcher to state the one-line hypothesis each experiment tests,\n # register deep-research directions as hypotheses, and track them to a verdict on the board. ON by\n # default. The board is shown to the Researcher and can be foresight-ordered, so it can\n # steer later proposals even though it never directly re-ranks already-evaluated nodes. Set False\n # to drop the prompt nudge + registration.\n track_hypotheses: bool = True\n # E4/M2/M3 reflection-memory -> priors (gradient-free cross-run meta-learning): at run end distill\n # the winner into `<memory_dir>/meta_notes.jsonl` AND structured lessons (incl. NEGATIVE results:\n # tested/abandoned hypotheses + failure themes) into `lessons.jsonl` with a task fingerprint; at run\n # start, inject exact-task notes + fingerprint-matched lessons from SIMILAR tasks into the proposal\n # prompt. ON by default — but a NO-OP until `memory_dir` is set (that's where cross-run memory lives).\n reflection_priors: bool = True\n # M6 comparative lessons (MARS \"comparative reflective memory\", doc 13 §7 item 2): distill\n # credit-assigned lessons from PAIRS of solutions — which SPECIFIC change made a child beat\n # (or regress from) its parent, and what fixed a failure — on top of the one-shot run-end\n # reflection. At most one extra LLM call per distillation (all pairs batched); offline a\n # deterministic param-diff credit stands in. Gated on reflection_priors + memory_dir like the\n # rest of cross-run memory — and since memory_dir has a default (~/.looplab/memory), this is\n # ACTIVE out of the box; clear memory_dir to disable cross-run memory wholesale.\n comparative_lessons: bool = True\n # M6 mid-run cadences (doc 13 §7 item 5 — the AgentRxiv live-share pattern): every N created\n # nodes, WRITE comparative lessons to the shared cross-run store during the run (lessons_every\n # — one batched LLM call per firing) and RE-READ the store so lessons distilled by CONCURRENT\n # runs reach this run's proposals (lessons_refresh_every; file re-read, no LLM call, skipped\n # when the store is unchanged). ON by default (every 4 nodes) and, like comparative_lessons\n # above, live out of the box because memory_dir defaults on; 0 = off — run-end write /\n # run-start read only, the pre-M6 behavior.\n lessons_every: int = Field(default=4, ge=0)\n lessons_refresh_every: int = Field(default=4, ge=0)\n # B3 output redaction: mask credentials (known key shapes + high-entropy tokens) in the\n # stdout/stderr tail before it is persisted to the event log / spans / UI — a leaked secret in a\n # print()/traceback must not land in the durable log. Off by default; recommend on for untrusted.\n redact_output: bool = False\n # B5 reward-hacking detector: a host-side monitor that flags suspicious wins (grader/answer-key\n # access, runtime writes to frozen files, suspiciously-perfect metrics) as a `reward_hack_suspected`\n # audit event in the Trust panel. Off by default. Whether a flag CHANGES selection is governed by\n # `trust_gate` below (default: audit-only, never changes selection).\n reward_hack_detect: bool = False\n # Trust enforcement (T2): what a reward-hack / data-leakage flag actually DOES to selection.\n # \"audit\" (default) = surface it in the Trust panel, never change selection (today's behavior);\n # \"gate\" = a flagged node can no longer be selected as BEST **and isn't bred/confirmed from**\n # (breed_excluded — see replay._apply_trust_gate / models.breedable_nodes), but stays\n # FEASIBLE so it still counts for diversity/audit — closes the \"a hacked/leaky node can\n # win OR seed its lineage\" hole;\n # \"block\" = additionally mark it fully INFEASIBLE (removed from feasible_nodes entirely).\n # Deliberately gates only high-precision CHEATING/LEAKAGE signals. Broad `critic:*` findings stay\n # advisory; `critic:hardcoded_metric` is the one narrow high-precision critic exception\n # and gates because it requires a literal metric with no computed assignment anywhere.\n trust_gate: str = \"audit\"\n # I3 data-centric: static code-leakage scan of each evaluated solution (fit-before-split,\n # fit-on-test) surfaced into the Trust panel alongside reward-hack flags. Off by default.\n code_leakage_detect: bool = False\n # 4.4 sandbox instrumentation (needs reward_hack_detect): after each eval, flag a RUNTIME write\n # to a protected/frozen file (an answer key or grader tampered mid-run) — behavioral evidence a\n # static code scan misses. ON by default; only fires when the reward-hack detector is on.\n workdir_audit: bool = True\n # D8 decoupled research Verifier: check every Deep-Research memo's CLAIMS against their cited\n # evidence (node ids / urls) before recording — synthesis is the documented weak link (Kosmos\n # 57.9% accurate). Deterministic layer always; an LLM rubric pass when the DeepResearcher has a\n # client. The verdict cannot change this run's node/champion, but finalize uses aligned supported\n # verdicts as the evidence gate for persisted D8 cross-run claims; unavailable/misaligned stays\n # unverified. ON by default (no-op with no memo/claims).\n research_verify: bool = True\n # C4 independent critic: an execution-free critic of each solution (stub / hardcoded-metric /\n # params-ignored; on host-graded tasks the metric checks become a submission-output check)\n # surfaced in the Trust panel. Broad findings are advisory; `critic:hardcoded_metric` can gate under\n # trust_gate=gate/block. Off by default.\n critic_check: bool = False\n # A7 Strategist (NEW, user-requested): optional LLM/rule meta-controller that picks the search\n # policy/allocator + operator mix + fidelity (+ Developer backend) per situation. Config-first:\n # every choice the Strategist makes is also a direct knob. Defaults to \"agent\" so the agent adapts\n # the search policy/operators/fidelity per situation; set \"off\" for static config-driven search.\n # \"agent\" = a tool-using Strategist that READS the run/data/sibling-runs/KB/memory (B1-guarded)\n # before deciding, instead of the single-shot \"llm\" call over aggregate stats.\n strategist_backend: str = \"agent\" # \"off\" | \"rule\" | \"llm\" | \"agent\" — default AGENTIC: the\n # Strategist READS the run/data/sibling-runs/KB/memory with tools before deciding, not a single-shot\n # call over aggregate stats. \"llm\" = the old non-agentic single-shot; \"rule\"/\"off\" = no LLM.\n strategist_every: int = Field(default=3, ge=1) # consult cadence (created nodes)\n # PART V (F1): concept CLASSIFIER re-tag + consolidation cadence, DECOUPLED from strategist_every. The\n # LLM concept map is heavier and slower-moving than a strategy consult, so it refreshes on its own\n # (sparser) interval. Researcher-authored idea.concepts still fold immediately at node_created — this\n # only paces the classifier-evidence + consolidation refresh (and the concept-coverage pivot snapshot).\n concept_retag_every: int = Field(default=30, ge=1)\n # === Confirmation & holdout ===========================================================\n # Multi-seed confirmation (I12, ADR-15): confirm the top-k under N seeds before\n # finishing. 0 disables (default). Only meaningful when eval has variance.\n confirm_top_k: int = 0\n confirm_seeds: int = 0\n # D1 seed-holdout discipline: the FIRST seed the confirm phase uses. Search evals run with the\n # implicit seed 0 (LOOPLAB_EVAL_SEED unset -> \"0\"), so a base of 1 keeps every confirm split\n # DISJOINT from anything the search optimized against — the confirm metric is then a\n # generalization signal, not a re-measurement (AIRA: selecting on a signal the search saw\n # overfits by 9-13 pp). Set 0 to restore the legacy overlapping seeds 0..N-1.\n confirm_seed_base: int = Field(default=1, ge=0)\n # D1 holdout-gated promotion (B6, Arbor-style): for host-graded tasks, reserve this fraction of\n # the held-out labels as a FINAL holdout partition the search never sees — every search/confirm\n # eval is scored on the remaining rows only, and at finish the val-top-k are re-scored on the\n # holdout partition (free: the predictions already exist). 0.0 = off (legacy full-label scoring).\n holdout_fraction: float = Field(default=0.25, ge=0.0, le=0.9)\n # Champion selection prefers the HOLDOUT metric among the nodes that have one (the val-top-k).\n # Recorded in run_started so replay applies the same rule; old logs fold to False (legacy pick).\n # The per-node val-holdout `generalization_gap` folds into the Trust panel either way.\n holdout_select: bool = True\n # How many val-leaders get a holdout evaluation at finish (host-graded tasks).\n holdout_top_k: int = Field(default=3, ge=1)\n # R1-c / Part IV — calibrated §12-verifier tie-break in best-selection. When a fresh node's robust\n # metric EXACTLY TIES an already-eligible node, the engine runs the §12 verifier (grounded on each\n # node's realized result, `selection_criteria`) and records a soundness score per node. The tie-break\n # then applies across the WHOLE selection path: the mean pick breaks a robust_metric tie by the score,\n # and — when `holdout_select` is on — the holdout-slot ranking prefers the sounder node so it isn't\n # denied a holdout eval, and the final holdout override breaks a holdout_metric tie by it too. Strictly\n # a TIE-BREAK — it can NEVER move a node ahead of a strictly-better metric (§21.7 advisory-never-\n # overrides). Best-effort: verification is LAZY (only fires on an actual tie) and bounded per cadence,\n # so a large/late tie stays on deterministic metric+id order unless one atomic treatment scores every\n # member. Recorded in run_started so\n # replay applies the same rule; old logs fold to False (legacy pick). Opt-in (default off); needs a\n # reachable LLM client. Enable only after validating calibration offline with `verifier.calibrate`.\n # See trust/verifier.py + core/fitness.py.\n select_verifier: bool = False\n # R1-d (§21.19): widen the verifier tie-break from EXACT-metric to a conservative STATISTICAL tie.\n # The band is capped by both the metric leader's SE and the pooled SE-of-difference, so challenger\n # variance cannot widen it and a significant difference is never a tie (§21.7). Off -> exact only.\n verifier_ci_tie: bool = False\n # Verifier sample count for `select_verifier` (the §12 repeated-sampling expectation). 3 tames the\n # single-shot variance; 1 = single-shot (cheaper, noisier).\n select_verifier_samples: int = Field(default=3, ge=1, le=32)\n # Budget (I13): hard wall-clock ceiling; the run aborts cleanly when exceeded.\n max_seconds: float | None = None\n # Eval-compute budget (#2): hard ceiling on cumulative time spent INSIDE evals (training\n # runs), separate from wall-clock. Survives resume (summed from the event log). The real\n # guard against a silent multi-hour sweep when an eval is a minutes-hours training run.\n max_eval_seconds: float | None = None\n # Cross-run memory (I19, ADR-10): if set, the best result of each run is stored as\n # a case here, and the cases become retrievable knowledge for future runs.\n memory_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"memory\"))\n # HITL (I21, ADR-11): pause for human approval of the final best before finishing.\n require_approval: bool = False\n # Diversity archive (I22): niche bucket width in parameter space.\n archive_resolution: float = 1.0\n # Coverage read-model (narrowing signal): at the strategist cadence, compute a breadth summary\n # (themes / param-niches / theme entropy / dominant-theme fraction) from the folded run, record\n # it as a `coverage_snapshot` audit event, and surface it into the Strategist's decision context.\n # Deterministic, cheap, and purely additive context — no search-behavior change on its own. On by\n # default (like the rest of the always-on situational context). See search/coverage.py.\n coverage_context: bool = True\n # PART IV Phase 2a live steering (§21.11/§21.13). When on, the `concept_retag_every` cadence (NOT\n # `strategist_every` — the producer gates on `_should_consult_concepts`, which uses the seed boundary\n # + `concept_retag_every`, default 30) records a\n # concept-graph coverage + uncovered-region snapshot (deterministic heuristic tagger over the\n # task-type skeleton) and, on an `explore` stance, the Researcher's novelty hint names the exact\n # uncovered regions (\"0 coverage in {negatives/external-mining, distillation} — go there\") instead\n # of the vague \"broaden\". ON by default in the product Settings (ce4a379), EngineOptions off: it\n # only enriches an already-mode-gated explore hint, never forces exploration, and no-ops for a task\n # with no curated concept skeleton. The snapshot is auditable, but its prompt cue can change future\n # candidate generation; it does not directly rewrite best-metric selection. See search/concept_graph.py.\n # DESIGN NOTE (2026-07-17 critique): now ON unconditionally, but a toy / non-ML task yields EMPTY concept\n # tags while still paying the per-node LLM concept cost (~10 calls/node with the full Part IV/V bundle).\n # Consider AUTO-QUIESCING: if the heuristic tagger returns empty for the first N nodes (no concept signal),\n # skip the LLM concept work for the run — keeps it on for ML tasks, free on toy/degenerate ones.\n concept_pivot: bool = True\n # PART IV Phase 2b — D3 graded novelty into the LIVE novelty gate (§21.4/§21.13). When on and the\n # task has a curated concept skeleton, the novelty gate first grades a proposal over the concept\n # graph: a level-4 \"same direction, DIFFERENT implementation\" or a level-5 \"re-opens a\n # wrongly-abandoned FAILED direction\" proposal is ALLOWED through unchanged — the flat LLM/semantic\n # dedup gate can't tell \"this DCL tweak\" from \"the whole DCL branch\" and would wrongly reject a\n # legitimate variant or never re-open a sound-but-killed direction (the node_63 archetype). Levels\n # 1/2/3 (identical / near-dup / prior-run) still fall through to the existing gate. Deterministic\n # (heuristic tagger, no LLM), audit event `novelty_graded`, replay-safe. ON by default in the product\n # Settings (ce4a379), EngineOptions off; no-ops for a task with no skeleton. See search/graded_novelty.py.\n graded_novelty: bool = True\n # PART IV Phase 2b — D7 capability-expansion forced-jump DIRECTIVE (§21.8/§21.13, issue #7). When on\n # and the concept-graph cadence detects action-space LOCK-IN (the search has stayed inside one D5\n # branch for a long consecutive streak) on an `explore` stance, the Researcher's novelty hint\n # ESCALATES from \"broaden\" to \"expand the ACTION SPACE — build the missing infra (external mining,\n # a new data pipeline, a different eval), do NOT swap another {locked} lever.\" The same\n # gate also stamps the proposal's authoritative operator as `expand`; that node competes normally\n # under SearchFitness and its separate operator yield is measured. It does not force a winner.\n # Reads the 2a concept-coverage snapshot, so it needs `concept_pivot` to record one. OPT-IN\n # (default off). See engine/proposal_cues.py + search/lock_in.py.\n capability_expansion: bool = False\n # PART IV cross-run Step 0 (§21.20.12/§21.20.13). Universal task-fingerprint tokenization: drop the\n # ASCII-only `[a-z0-9]` allowlist on goal keywords so a non-Latin goal (Russian, CJK, …) is NOT\n # silently dropped from its cross-run fingerprint (today such a goal keys only on kind/dir/metric and\n # never reaches SIMILAR-task priors/lessons/cases). Uses `[^\\W_]+`/`.casefold()` — same splitting as\n # before, any script. ON by default in the product Settings (ce4a379); the bare-library EngineOptions\n # default stays off, so a run pinned to it won't silently re-key a portfolio mid-flight. See engine/memory.py.\n fingerprint_universal: bool = True\n # PART IV cross-run Step 2 (§21.20). Fill graded-novelty's `prior_concepts` from the cross-run\n # ConceptCapsuleStore: at run end write a per-run concept capsule (the shipped `node_concepts` tags +\n # best outcome, keyed by task_fingerprint) to `memory_dir`; when a later SIMILAR run proposes an idea\n # whose concept was tried before, SURFACE the prior outcome as a `cross_run_prior` audit event (never\n # reject — D3 level 3). Audit-only, off the selection path; ON by default in the product Settings\n # (ce4a379), EngineOptions off. Needs a `memory_dir` to share capsules.\n # See engine/memory.py (ConceptCapsuleStore) + engine/novelty.py.\n # PREREQUISITES (this flag is NOT standalone): the WRITE needs per-run concept tags\n # (`node_concepts`, produced by `concept_pivot`/F1 tagging) — no tags => no capsule; the SURFACE path\n # runs inside the graded-novelty precheck, so it also needs `graded_novelty` on. With only this flag +\n # `memory_dir` it persists/surfaces nothing. Enable `concept_pivot` + `graded_novelty` alongside it.\n # A non-Latin (Russian/CJK) portfolio should ALSO set `fingerprint_universal`, else the capsule's goal\n # tokens are dropped and its fingerprint over-matches on kind/dir/metric alone (the novelty direction\n # gate narrows this, but goal-word similarity is still lost).\n cross_run_concepts: bool = True\n # PART V (B): run-base + node-DELTA concept authoring. When on, once the first evaluated node has\n # authored concepts the engine seeds RunState.run_base_concepts from them (a one-shot EV_RUN_CONCEPTS,\n # idempotent because it only fires while the base is empty), and the Researcher's proposal hint tells it\n # to author only `concepts_added`/`concepts_removed` vs the run base + its parent — minimal per-node\n # annotations that inherit down the DAG (a swap propagates). Off -> every node authors its full concept\n # set as before. Product-on; the bare EngineOptions mirror defaults off (test_options_divergence).\n concept_run_base: bool = True\n # PART IV cross-run Step 5 advisory (§21.20.5). Fold the bounded cross-run CONTEXT PACK — evidence-\n # grounded claims with BOTH support and counter-evidence (Step 4) + a portfolio-coverage line (Step 3) —\n # into the Researcher's proposal prompt, exactly like the E4 cross-run prior note. Advisory ONLY: it is\n # prompt-grounding: it can change the Researcher's later candidates, but does not directly rewrite\n # best-metric selection (§21.7). Reads `memory_dir` (lessons.jsonl + concept_capsules.jsonl); \"\" when\n # empty. ON by explicit experimental product choice; no frozen A/B validation is claimed. The\n # bare-library EngineOptions default stays off (engine/options.py, frozen in\n # test_options_divergence). See engine/proposal_cues.py.\n cross_run_advisory: bool = True\n # PART IV cross-run §21.20.13 (full CR of the lean fuzzy claim merge). Switch the claim read-model to the\n # SCOPE+POLARITY-safe STRUCTURED claim key (engine/claim_key.py): claims from different tasks never\n # merge, opposite polarity (\"X helps\" vs \"X never helps\") is surfaced as a CONTRADICTION instead of being\n # collapsed, paraphrase/inflection variants group by exact structured key (no transitive over-merge), and\n # operator governance is scope-precise (a decision in task A cannot reach a same-worded claim in task B).\n # Affects the `cross_run_advisory` context pack only; ON by default in the product Settings (ce4a379);\n # the bare-library EngineOptions default stays off (engine/options.py). See engine/claims.py.\n cross_run_structured_claims: bool = True\n # PART IV cross-run §22.4 (AGENTIC taxonomy steward). At finalize, when an LLM client is available, let\n # the concept steward (engine/concept_steward.py) review the freshly-updated portfolio concept graph and\n # PROPOSE a curation — merge duplicate slugs / split conflated ones / purge noise. Proposals are LOGGED to\n # `concept_curation_log.jsonl` for operator ratification (via the serve/CLI governance surface); the LLM\n # never mutates fold state. The calls run synchronously during finalize, so they add latency and may add\n # paid inference; a caught steward failure does not prevent terminal completion or trigger a retry.\n # ON by explicit experimental product choice when `memory_dir` + an LLM backend are present; the\n # bare-library EngineOptions default stays off (engine/options.py). See engine/lessons.py.\n cross_run_curation: bool = True\n # Deprecated compatibility flag. Steward output is untrusted and finalize is proposal-only regardless\n # of this value; retained so old snapshots still validate and logs can disclose that auto was requested.\n cross_run_curation_auto: bool = False\n # Role backend (ADR-7/14): \"toy\" (offline optimizer) | \"llm\" (live model). Validated in\n # `_check_trust_gate` alongside the other enum-ish strings — every consumer tests `== \"llm\"`\n # exactly (cli/__init__.py, adapters/tasks.py), so an untyped `--set`/file/env typo (or a mis-cased\n # \"LLM\") used to fall through to the OFFLINE toy roles and quietly produce a run that never called\n # the model. Left as `str`, not `Literal`, so old snapshots still validate through the same path.\n backend: str = \"toy\"\n # Developer backend (ADR-7): \"default\" (templated/LLM from the task) or an external\n # CLI coding agent: \"opencode\" | \"aider\" | \"goose\" | \"continue\".\n developer_backend: str = \"default\"\n # C2 best-of-N: generate N candidate implementations per node and keep the best by an\n # execution-free reward (static validity + metric-print). 1 = off. In-house LLM developer only\n # (not external agents — ADR-7 cost rule). The top SWE-bench reliability lever for weak models.\n best_of_n: int = 1\n # D10 list-wise best-of-N selection: when the top static-scorers TIE, break the tie with a\n # comparative LLM selection over the candidates presented together (+~3 pts vs pointwise on\n # GAIA, arXiv:2506.12928). The static score stays the primary filter — the LLM is a weak\n # comparative prior, never the sole oracle. ON by default (no-op when best_of_n==1).\n best_of_n_listwise: bool = True\n # === Foresight / predict-before-execute ===============================================\n # FOREAGENT predict-before-execute (arXiv:2601.05930): use the LLM as an IMPLICIT WORLD MODEL to\n # predict — WITHOUT executing — which candidate / hypothesis will score best, primed with a\n # Verified Data Analysis Report (data profile / brief) + the accumulated experiment memory. Master\n # switch, ON by default; a no-op wherever there is nothing to rank (best_of_n==1 and\n # foresight_panel==1) or no LLM client. See `looplab/search/foresight.py`.\n foresight: bool = True\n # Hypothesis foresight breadth: generate K candidate ideas per proposal and keep the one PREDICTED\n # best pre-execution by the world model — it ranks the STRUCTURAL / text ideas the numeric k-NN\n # surrogate (researcher_panel) is blind to, primed with the data profile + memory (the synergy).\n # >1 enables it (LLM backend only; needs `foresight` on); 2 = on by default at modest cost, 1 = off.\n foresight_panel: int = 2\n # AGENTIC foresight: run the ranking (hypothesis-board prioritization + K-idea pick) as a TOOL-USING\n # loop that can pull actual experiment results / data facts before deciding, instead of a one-shot\n # prediction from a pre-baked report. ON by default (needs foresight + a client; falls back to the\n # one-shot predictor on any hiccup). A few extra LLM calls per proposal; set False for the cheaper\n # single-call ranker.\n foresight_agentic: bool = True\n # §1 foresight confidence gate: the minimum predicted confidence at which a predict-before-execute\n # pick is ACTED ON. Below it the ranker abstains — the K-idea panel falls back to the first\n # proposal, best-of-N falls through to the D10 tie-break — instead of committing a low-confidence\n # choice. 0.0 (default) = OFF (act on every pick, the historical behavior); raise toward ~0.5 to\n # make the world model defer when it isn't sure. Pairs with the foresight track record (§1) the\n # predictor is now primed with. Range 0.0-1.0.\n foresight_min_confidence: float = 0.0\n # PART IV Phase 2c — replace the world model's SELF-REPORTED confidence (measured Pearson≈0 with the\n # realized outcome, §21.12) with a CALIBRATED §12-verifier score. When on and a client is available,\n # after the K-idea ranker picks the predicted-best candidate the §12 verifier scores it (grounded +\n # repeated + criteria-decomposed: `foresight_criteria` — likely to improve the objective, and\n # sound/feasible) and THAT calibrated score becomes the confidence the `foresight_min_confidence` gate\n # and the telemetry use. Degrades to the self-reported confidence without a client or on any verifier\n # error (the telemetry records which source was used). ON by default in the product Settings (ce4a379),\n # EngineOptions off; a few extra LLM calls per acted-on proposal. See looplab/trust/verifier.py + search/foresight.py.\n # REVISIT (cost/quality, not yet resolved): a live classification run measured ~10 LLM calls & ~3.8 min/node\n # on deepseek-v4-flash with the full Part IV/V default bundle; the calibrated verifier is part of that per-node\n # tax. Kept ON deliberately — it raises decision quality (self-reported foresight confidence has ≈0 Pearson\n # with realized outcome), and lowering quality to save calls is the wrong trade. TODO: run a frozen quality/cost\n # A/B (verifier-on vs -off) to quantify the recall@objective gain per extra call before considering a default flip.\n foresight_verify: bool = True\n # Verifier sample count for `foresight_verify` (the §12 repeated-sampling expectation on a no-logprob\n # backend). 3 tames the single-shot variance §21.12 measured; 1 = single-shot (cheaper, noisier).\n # RESUME-critical: this value is persisted in config.snapshot.json, so a hard `le` schema bound would\n # make an old run started with a larger LOOPLAB_FORESIGHT_VERIFY_SAMPLES un-resumable (Settings(**snapshot)\n # would raise on `looplab resume` / finalize-recovery / serve replay). CLAMP into [1, MAX] via a validator\n # instead of rejecting — the runtime cap is preserved without breaking the snapshots/env-compat rule.\n foresight_verify_samples: int = 3\n\n @field_validator(\"foresight_verify_samples\")\n @classmethod\n def _clamp_foresight_verify_samples(cls, v: int) -> int:\n # Coerce a persisted/env value into [1, MAX] rather than reject (see field note above).\n try:\n v = int(v)\n except (TypeError, ValueError):\n return 3\n return max(1, min(v, MAX_FORESIGHT_VERIFY_SAMPLES))\n\n # T7 LLM response cache: serve an identical DETERMINISTIC (temperature 0) request from an\n # in-process content-addressed cache instead of re-hitting the model — cuts cost on\n # retry/panel/verify flows. NEVER caches sampling calls (temp>0: best-of-N / panel / novelty\n # re-propose must vary). Off by default (most role calls run at temperature>0 anyway).\n llm_cache: bool = False\n agent_cmd: str | None = None # override the agent's launcher (path / wrapper)\n # External-agent validation (ADR-7): wrap a CLI-agent Developer with a validator that\n # audits each output (no-op/syntax/crash/timeout), retries with feedback, and falls\n # back to the in-house LLM Developer. Off -> use the raw agent output unchecked.\n validate_agent: bool = True\n agent_max_retries: int = 1 # re-prompts of the agent on an invalid result\n # Patch-gated multi-file agent (ADR-7 Rule 3): run the CLI agent in a git worktree and\n # accept only edits whose paths match `agent_surface` (reject-not-strip out-of-surface\n # touches). Lets the agent create helper modules, not just solution.py. Degrades to\n # whole-file readback if git is unavailable.\n agent_patch_gate: bool = True\n agent_surface: list[str] = [\"*.py\"] # edit-surface allow-list (globs)\n # Trust policy for an agent-authored eval/metric adapter (RepoTask onboarding, Phase 3):\n # \"ratify_freeze\" waits for human approval, \"autonomous\" accepts the proposed spec without that\n # gate, and \"ratify_freeze_drift\" also enforces the configured independent drift cross-check.\n eval_trust_mode: str = \"ratify_freeze\"\n # RepoTask node seeding policy (run-wide fallback; a task/editable `seed_mode` overrides):\n # \"auto\" (default) copies git-tracked files when the editable is a git repo (so a tree bloated\n # with untracked artifacts isn't deep-copied per node), else a full copy; \"tracked\" forces\n # code-only; \"all\" forces a full recursive copy (legacy). Genesis authors per-task from the\n # user's words; this is the global default when unspecified.\n seed_mode: str = \"auto\"\n llm_model: str = \"qwen3:8b\"\n # === LLM / transport ==================================================================\n llm_base_url: str = \"http://localhost:11434/v1\" # Ollama OpenAI-compatible endpoint\n llm_temperature: float = 0.6\n # H3 per-role model presets (5090 recipe): optionally run the Researcher and Developer on\n # DIFFERENT models/endpoints (e.g. Developer=Qwen3-Coder-30B for code, a fast model for breadth).\n # Blank = use the shared llm_model/llm_base_url. Generalizes the role-backend swap to per-role.\n researcher_model: str | None = None\n developer_model: str | None = None\n researcher_base_url: str | None = None\n developer_base_url: str | None = None\n # Per-role sampling temperature (§4.1): the Researcher wants breadth (higher temp = more diverse\n # ideas), the Developer wants determinism (lower temp = fewer syntax slips), the Strategist a\n # steady hand. Each overrides the shared `llm_temperature` for that role only; None = use the\n # shared value (byte-identical to before). Flat + nullable, mirroring the per-role model fields.\n researcher_temperature: float | None = None\n developer_temperature: float | None = None\n strategist_temperature: float | None = None\n # The Strategist had per-role TEMPERATURE but no model/endpoint of its own, so in split mode it\n # always ran on the shared llm_model however the other roles were pointed. These close that gap\n # with the same names/shape as the pair above; blank = its profile, else the shared value.\n strategist_model: str | None = None\n strategist_base_url: str | None = None\n # === Connection profiles (multi-provider) =============================================\n # A \"profile\" is a NAMED connection: {model, base_url, temperature, api_key_env, provider}. Roles\n # point at one by name through `role_profiles`; `llm_profile` is the default for every role that\n # doesn't. All three are EMPTY by default and the resolver short-circuits to the shared\n # llm_model/llm_base_url/llm_api_key path when they are — an operator running one model never\n # needs to know the word \"profile\".\n #\n # A profile stores `api_key_env`, the NAME of an environment variable, never the key itself. That\n # is ADR-11's \"a secret is a reference, not a value\" turned from a convention into a data type:\n # the whole profile map is then safe to write into config.snapshot.json, hand back over HTTP and\n # render into LOOPLAB_* — there is nothing in it to mask. It is also what makes per-role\n # CREDENTIALS expressible at all: two roles on one provider can carry different keys, which a\n # key-per-endpoint map could not say.\n llm_profiles: dict[str, dict] = Field(default_factory=dict)\n llm_profile: str | None = None\n role_profiles: dict[str, str] = Field(default_factory=dict)\n # Unified self-driving agent: one LLM identity that plays Researcher + Developer (+ Strategist)\n # across pipeline stages, choosing its own model/toolset per stage. ON by default — the agent\n # drives the loop (incl. crash triage: repair/abandon/reject_idea) and, via\n # `agent_drives_actions`, picks the next macro action within a pure legal-action gate. Set both\n # to False for the legacy byte-identical split-role behavior. (No-op unless backend=\"llm\".)\n unified_agent: bool = True\n agent_drives_actions: bool = True\n # Layer 3 Card queue ownership. False preserves the existing policy/unified-pilot action path.\n # True is pinned in run_started because changing the selector on resume would mix two search\n # treatments in one run. When both this and agent_drives_actions are true, Card selection wins.\n card_driven_selection: bool = False\n # Layer 5 bounded speculative Card buffer. Zero is a hard OFF switch and preserves the\n # historical alternating build/eval spine; positive values are pinned by run_started so a\n # resume cannot silently mix search treatments after a snapshot/default edit.\n speculation_depth: int = Field(default=0, ge=0, le=64)\n # Local evidence receipt produced by ``looplab speculation-gate``. This is deliberately absent\n # from the curated Settings UI: positive-depth execution is a calibrated rollout decision, not a\n # casual tuning knob. The Engine validates the receipt against the current scorer,\n # implementation, GPU inventory and raw paired-run evidence before it enables speculation.\n speculation_gate_receipt: str | None = Field(default=None, max_length=4096)\n # Per-stage model/endpoint overrides for the unified agent. Recognized keys (see\n # tasks.build_unified_agent): `propose` (researcher), `implement`/`repair` (developer),\n # `strategy`, `pilot`. Unlisted keys are ignored. Empty = the per-role researcher_*/developer_*\n # models, then the shared llm_model. Explicit map wins. No-op unless `unified_agent` is true.\n agent_stage_models: dict = {}\n agent_stage_base_urls: dict = {}\n llm_parser: str = \"tool_call\"\n # Reasoning/thinking toggle sent IN the request (providers differ — see `llm.reasoning_body`):\n # Qwen3 on vLLM/SGLang -> chat_template_kwargs.enable_thinking (bool)\n # OpenAI/Ollama-v1/DeepSeek -> reasoning_effort (low|medium|high|none)\n # `llm_reasoning`: \"\" = send nothing (server default — current behavior); off = actively disable;\n # on = enable at default depth; low|medium|high = enable at that effort.\n # Defaults to \"high\" so the agent reasons before proposing/repairing/triaging (better code, fewer\n # crashes); set \"\" to send nothing (server default) or \"off\" to disable. The model's chain-of-thought\n # is CAPTURED either way (reasoning_content field or inline <think>). NOTE: to get a SEPARATE\n # reasoning_content field from SGLang/vLLM, the server also needs `--reasoning-parser qwen3`.\n llm_reasoning: str = \"high\"\n llm_reasoning_style: str = \"auto\" # auto | qwen | effort | none — how to shape the request param\n llm_reasoning_extra: dict = {} # raw fields merged into the body (escape hatch, e.g. Anthropic thinking)\n # H1: drive structured calls with the endpoint's constrained decoding (vLLM/SGLang guided_json +\n # response_format json_schema) so weak models can't emit invalid JSON. Off for Ollama (default).\n llm_guided_json: bool = False\n # Stream every LLM request (SSE) and reassemble it, so the request `timeout` is an INTER-TOKEN\n # idle timeout rather than a whole-request deadline: a long-but-alive generation is never cut off\n # (tokens keep resetting the timer), while a genuinely STALLED endpoint (no token for `timeout` s)\n # is detected and retried on a fresh connection — the fix for silent multi-minute hangs. Set False\n # to use one blocking read (old per-op timeout semantics) if an endpoint streams badly.\n llm_stream: bool = True\n # LLM IDLE timeout (seconds). Stream mode: the INTER-TOKEN stall limit — a steady generation is\n # never cut off, only a silent endpoint. Non-stream: bounds the whole read. Raise for endpoints\n # with a long prefill on huge prompts; lower to fail fast on a flaky shared endpoint.\n llm_timeout: float = Field(default=180.0, gt=0)\n # First-byte (response-headers) window for STREAM attempts, seconds: an SSE response sends its\n # headers on admission, so no-headers ≈ a black-holed request — fail over to a fresh connection\n # fast instead of waiting the full idle timeout. Clamped to llm_timeout. Non-stream attempts are\n # NOT bounded by this (their headers arrive only after the whole generation). The default mirrors\n # `DEFAULT_HEADER_TIMEOUT_S` in looplab/core/llm.py is the single source (imported above).\n llm_header_timeout: float = Field(default=DEFAULT_HEADER_TIMEOUT_S, gt=0)\n # Honour HTTP(S)_PROXY / NO_PROXY / SSL_CERT_FILE env vars for LLM calls. Default False: the\n # transport connects DIRECTLY (many internal/OLAP endpoints are reachable only by bypassing an\n # ambient proxy — a picked-up proxy yields \"connection refused\"). Set true when the endpoint is\n # reachable ONLY via a corporate proxy or needs a custom CA bundle from the environment.\n llm_trust_env: bool = False\n # H4/C2: compact the growing agentic tool-call history once it exceeds this many chars (auto_summary\n # LLM-summarizes the stale middle; else middle-truncate). Sized to the model's real context window so\n # files read earlier STAY in context instead of being compacted away and re-read: ~1,000,000 chars ≈\n # 250k tokens (≈4 chars/token), matching the configured deepseek-v4-flash (≥200k tokens confirmed).\n # The old 120k-char (~30k-token) default was ~8× too aggressive and drove a read-thrash loop. 0 = off.\n context_budget_chars: int = 1_000_000\n # M5: the Researcher's always-on experiments digest budget (chars). 0 = AUTO — scales with the\n # run size (60 chars/node, bounded [1200, 6000]) instead of one flat cap for an 8-node toy run\n # and a 200-node benchmark run alike. Set a positive value to pin it.\n digest_char_cap: int = Field(default=0, ge=0)\n # D11 compression model slot (open_deep_research's four-slot pattern): a dedicated CHEAP model\n # for agent-history summarization (C2 auto-summary), so compression doesn't pay the main\n # model's price. Blank = use each loop's own model (legacy). `compressor_base_url` blank =\n # the shared llm_base_url.\n compressor_model: str | None = None\n compressor_base_url: str | None = None\n # === Agentic tool-loop ================================================================\n # Agentic tool-loop limits — apply to EVERY tool-using agent (the LLM Researcher, the unified\n # agent's pilot + crash-triage, Deep-Research, the run-chat Boss, the genesis repo-scout, and the\n # cross-run report synthesizer). The loop lets the model call read-only tools across turns before\n # emitting its structured result. Both default to UNLIMITED so the agent takes as many turns / as\n # long as it needs — it is never cut off mid-reasoning; set a positive value here (or per-run, or\n # in the UI settings) to bound latency/cost. These used to be hardcoded per call site (e.g. the\n # boss at max_turns=3 / 45s), which silently dropped a slow reasoning model to a no-op reply.\n agent_max_turns: int = 0 # max tool turns before the emit is forced (0 = unlimited)\n agent_time_budget_s: float = 0.0 # wall-clock ceiling across the loop's turns (0 = no cap)\n # G · Soft convergence SAFETY NET (not a research cap). Two thresholds on the agent's tool-turn count:\n # at `agent_emit_after` the loop NUDGES the agent once (\"you've investigated enough — emit now\"); at\n # `agent_emit_force` it FORCES the emit from what was gathered. Set GENEROUSLY — a focused researcher\n # may legitimately read many files/experiments before it can propose a grounded idea; these only catch\n # a genuine runaway that keeps issuing DIFFERENT calls forever (which stuck-detection, keyed on\n # repeats, misses). NB: the old 499-read runaway (node 63) was 79% REDUNDANT re-reads caused by a\n # broken read_file — fixing read_file (pagination) is the real cure; this is just insurance. 0 = off.\n agent_emit_after: int = 300\n agent_emit_force: int = 500\n # B1 · No-progress / stuck detection — the safety net that makes \"unlimited turns\" safe. The\n # turn/time ceilings above are only BACKSTOPS; this is what actually stops a runaway loop, on the\n # cheapest signal: when the model repeats the SAME tool call (or ping-pongs between two, or keeps\n # hitting the SAME error) with no progress, the loop forces the final emit and finishes. ON by\n # default; reading DIFFERENT files or running ONE long command never trips it. (OpenHands-style.)\n agent_stuck_detection: bool = True\n agent_stuck_repeat: int = 4 # identical calls in a row that count as \"stuck\" (>=2)\n agent_stuck_alternate: int = 4 # ping-pong cycles between two calls that count as \"stuck\" (>=2)\n # C1 · Self-plan (TodoWrite-style): expose an `update_plan` tool so a long-running agent keeps its\n # OWN working TODO and re-surfaces it every `agent_plan_reinject_every` turns — keeps the goal in\n # view across a long loop so the agent \"writes its own context\" (Claude Code style). ON by default.\n agent_self_plan: bool = True\n agent_plan_reinject_every: int = 5\n # C2 · Auto-summary (ON by default): when the tool-loop history grows long, LLM-summarize the\n # stale middle instead of only middle-truncating it. The trigger is `context_budget_chars` if set,\n # else a built-in high-water mark (context_budget.DEFAULT_SUMMARY_CHARS ≈ 120k chars) so short\n # loops are untouched. Falls back to truncation on any summarizer error; one extra model call per\n # over-budget turn.\n agent_auto_summary: bool = True\n # C4 · Repo-developer plan decomposition. A big feature attempted in ONE developer session made a\n # non-converging reasoning model loop indefinitely — it kept writing + exploring without ever\n # emitting `done` (10k+ LLM calls / hours; agentic stuck-detection can't catch VARIED exploration,\n # only literal repeats). Fix: the developer first proposes an ordered plan of ATOMIC steps; a\n # multi-step plan is executed step-by-step, each in a FRESH bounded session with only that step's\n # scope + the files accumulated so far, syntax-validated per write. Every session stays\n # small-context and convergent. ON by default for repo tasks (a 1-step plan == the old single pass).\n developer_plan_decompose: bool = True\n developer_plan_min_steps: int = 2 # a proposed plan with >= this many steps runs step-by-step\n developer_plan_max_steps: int = 8 # cap on plan length (a runaway planner can't spawn 100 steps)\n # Hard per-session backstop for the repo developer — the ONE write-heavy agent that can run away\n # even with stuck-detection on (varied writes/reads never trip the repeat/ping-pong signal). Bounds\n # the plan phase, EACH step, and the single-session fallback. Unlike the global agent_* limits\n # (0 = unlimited, meant for read-only agents), the developer always gets a FINITE ceiling so a\n # model that never emits `done` fails cleanly with the code it wrote so far, instead of looping.\n developer_session_max_turns: int = 500\n developer_session_time_budget_s: float = 1200.0 # 20 min wall-clock per developer session\n # Phase-handoff summaries. Each LLM phase in a node build (Researcher·propose → Developer·stages →\n # plan → implement) ends with ONE extra LLM call that distills its transcript — the repo structure\n # it mapped, files/data confirmed, decisions made — into a brief injected into the NEXT phase (even\n # across the role boundary). The next phase TRUSTS that instead of re-reading the same files, which\n # cuts the biggest source of tool-call thrash (stages/plan/implement each re-exploring the repo).\n # One summary call per phase in exchange for many fewer read calls downstream. ON by default.\n phase_handoff_summary: bool = True\n # Observability (ADR-17): capture each LLM call's full prompt + completion into the active\n # span (spans.jsonl) so the UI can show exactly what the model read and wrote per node.\n # Diagnostics only — `replay.fold` never reads spans. Default on for local single-user; set\n # LOOPLAB_TRACE_LLM_IO=0 to suppress (e.g. to keep spans.jsonl small or avoid storing prompts).\n trace_llm_io: bool = True\n # Run-introspection tools (ON by default): make the LLM Researcher (and DeepResearcher) a\n # tool-using agent that can read its OWN experiments (list/read/find-analogous/themes/code) and\n # the task data (schema/profile/asset) mid-loop, instead of seeing only best+parent. Advisory —\n # never changes best-selection. Off = the legacy single-shot Researcher (richer digest still added).\n researcher_tools: bool = True\n # Cross-run introspection: give the agentic Researcher / pilot read-only tools to look at\n # SIBLING runs (same task_id, same run-root) — list them, read an experiment / its code, and\n # find analogous configs across runs — so a run can build on what neighbouring runs already\n # learned instead of rediscovering it. Advisory; never changes best-selection. Needs the run's\n # own dir wired through (no-op for unit-built roles). Off = the legacy single-run view only.\n cross_run_tools: bool = True\n # Read-only access to EVERY run on this machine ACROSS ALL TASKS (not just same-task siblings):\n # the Developer/Researcher get list_all_runs + read_run_code + read_run_experiment so they can read\n # the code + result of ANY past experiment anywhere and reuse an approach. Broader than\n # `cross_run_tools` (same-task only); the agent decides when a foreign run is relevant. Advisory;\n # never changes best-selection. Needs the run's own dir wired through (no-op for unit-built roles).\n all_runs_tools: bool = True\n # PART V §22 — read-only CROSS-RUN KNOWLEDGE tool for the reasoning roles (Researcher, Strategist,\n # deep-research, and a Developer-scoped variant). Adds `cross_run_prior_attempts` / `cross_run_claims`\n # / `cross_run_atlas` over the §21.20 read-models (concept overview + claim assessments + atlas), read\n # from `memory_dir` (lessons.jsonl + concept_capsules.jsonl). Read-only is a storage-authority\n # boundary, not a behavioral no-op: retrieved content can steer later proposals, and a tool-using\n # reasoning loop can consume additional model calls. The tools never mutate cross-run truth (facts\n # are engine-written, verdicts operator-ratified, §22.4) or directly select a winner. ON by explicit\n # experimental product choice, EngineOptions off; stores only exist once Part-IV features have run.\n cross_run_read_tools: bool = True\n # Agentic retrieval (ADR-16): if set, the LLM Researcher gets grep/kb_search/read\n # tools over this directory of markdown notes and chooses when to use them.\n knowledge_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"knowledge\"))\n # T4 real embeddings: model id for an OpenAI-compatible `/embeddings` endpoint used for\n # kb_search / case retrieval. Blank (default) = the dependency-free lexical `hash_embed` (today's\n # behavior). Set e.g. \"nomic-embed-text\" (Ollama) or \"text-embedding-3-small\" for SEMANTIC\n # retrieval; a misconfigured/offline endpoint degrades back to hash_embed at call time (never\n # crashes a run). `embed_base_url` blank = reuse `llm_base_url`; the shared `llm_api_key` is used.\n embed_model: str | None = None\n embed_base_url: str | None = None\n # Harmonic memory (Memora, ICML'26 — idea import). When ON, cross-run cases and knowledge notes are\n # indexed by a short ABSTRACTION + cue ANCHORS instead of their raw text, near-duplicate memories are\n # CONSOLIDATED into one entry under a matching abstraction, and `kb_search`/case retrieval EXPAND\n # through the top hits' anchors to surface related-but-not-similar memories. ON by default. Set\n # `memora=false` to restore the pre-Memora raw-text index. Never a source of truth — abstractions\n # live only in the derived, rebuildable retrieval index (never the event log or canonical cases.jsonl).\n memora: bool = True\n # Write abstractions with the wired chat model (richer than the deterministic lexical fallback). ON\n # by default; results are CACHED by content hash (see `memora_cache`) so a re-built index doesn't\n # re-call the model on unchanged notes/cases, and any endpoint failure degrades to lexical at call\n # time — so an offline box just gets lexical abstractions, never a crash. Set false to force lexical.\n memora_llm: bool = True\n # === Cross-run memory / Memora ========================================================\n memora_anchors: int = 6 # max cue anchors kept per memory\n # Cosine similarity at/above which a new case is CONSOLIDATED into an existing one (same evolving\n # topic) rather than stored as a separate entry. Only used when `memora` is on.\n memora_consolidate_threshold: float = 0.86\n # Where the LLM-abstraction cache lives (JSON). Blank (default) derives it from `memory_dir`\n # (`<memory_dir>/memora_cache.json`) or else `knowledge_dir` (`<knowledge_dir>/.memora_cache.json`);\n # with neither set the cache is in-memory only (per process). No-op unless `memora_llm` is on.\n memora_cache: str | None = None\n # E3 literature-grounded ideation (network-OPTIONAL): give the agentic Researcher an arXiv search\n # tool to ground ideas in real techniques. Off by default (egress is unreliable on some boxes);\n # fails gracefully if the network is blocked.\n literature_search: bool = False\n # Deep-Research stage (network-OPTIONAL): give the DeepResearcher a general web search/fetch\n # tool (DuckDuckGo) on top of arXiv to read across results + the web before steering the next\n # batch. Off by default (egress is unreliable on some boxes); fails gracefully when blocked.\n web_search: bool = False\n # Cadence for the Deep-Research stage: run it automatically every N created nodes. Zero disables\n # only the cadence; manual `deep_research` and Strategist `request_research` triggers still fire.\n # Default 3: deep research analyzes the accumulating results and steers the next batch (its\n # directions become hints + open hypotheses); paired with `concurrent_research` it overlaps the\n # think with the GPU-bound eval, so the cadence costs LLM tokens but no wall-clock on the search.\n deep_research_every: int = 3\n # Overlap a DUE deep-research \"think\" with the GPU-bound eval instead of running it in its own\n # serial step (the agent is otherwise idle while a node trains). research() computes from a state\n # snapshot; the joined background task records only allowlisted, order-tolerant\n # research/hint/hypothesis events as soon as the memo finishes. ON by default: the LLM is typically\n # remote (no GPU contention with eval), so overlapping hides latency behind long training.\n concurrent_research: bool = True\n # Concurrent-research REPEAT — don't idle a multi-DAY eval. The overlapped \"think\" fires ONCE per\n # window by default; ON here, it RE-RUNS on an adaptive time cadence for the WHOLE eval window\n # (a two-day training must not leave the reasoning agents idle after one memo). Self-paced: it\n # only records a memo whose CONTENT is new (identical re-runs are skipped, so the log/board don't\n # bloat) and backs off geometrically as the analysis converges, capped so it always re-checks.\n # The appended records never rewrite the current metric champion, but their hints/open hypotheses\n # deliberately steer later proposals and replay reconstructs that advice. The library default is\n # one-shot (== today, byte-identical); this product default turns the repetition on.\n concurrent_research_repeat: bool = True\n # Base cadence (seconds) between repeated concurrent-research passes. Research is EXPENSIVE\n # (multi-turn LLM + web/arXiv), so this is a FLOOR, not a ceiling: the effective cadence is\n # max(this, ~5% of the per-experiment time budget) — a two-day eval is re-researched about hourly,\n # a short eval not at all (the first tick outlasts it). Only matters when the repeat is on.\n concurrent_research_interval_s: float = Field(default=1800.0, gt=0)\n # Per-eval-window backstop on repeated-research LLM calls (0 = cadence-only, no cap). The adaptive\n # cadence + convergence backoff are the primary budget control; this bounds a pathological\n # never-converging window. Past it the loop stops calling the LLM (the health monitor still runs).\n concurrent_research_max_calls: int = Field(default=40, ge=0)\n # Concurrent hypothesis-board CONSOLIDATION during the eval window. Repeated research keeps ADDING\n # near-duplicate directions to the open board (each memo registers up to 5 as open hypotheses), so a\n # long eval's board bloats before the between-nodes merge runs. This dedups/merges it on the SAME\n # background loop so the board stays clean for the next proposal. In Card-driven mode the same merge\n # changes ownership/readiness, so it is deferred to the joined main-task cadence instead. Needs the\n # reflect client + `track_hypotheses`.\n # Only active while `concurrent_research` runs the overlap loop. Library default off (== today).\n concurrent_consolidate: bool = True\n # Cadence for the agent-authored run report: regenerate the conclusion-first narrative every N\n # created nodes (0 = off; it still regenerates on a manual `report_refresh` from the UI). The\n # deterministic report always renders from the node set regardless of this knob.\n report_every: int = 3\n # Agent Skills (I18, ADR-9): dir of SKILL.md the Researcher can list/load as tools.\n skills_dir: str | None = None\n # Prompt store (I18, ADR-8): dir of editable, hot-reloaded role prompt .md files.\n prompt_dir: str | None = None\n # API key as a reference, never serialized as a value (ADR-11). Local servers\n # ignore it; default to a placeholder.\n llm_api_key: SecretStr | None = None\n\n @model_validator(mode=\"before\")\n @classmethod\n def _reject_bool_for_numeric_fields(cls, data):\n \"\"\"A JSON boolean is a TYPE ERROR for a numeric setting, not the number 1.\n\n Pydantic runs in lax mode here (`model_config` sets no `strict`), and `isinstance(True, int)`\n is True — so `{\"max_nodes\": true}` from a UI/API type slip validated cleanly as a budget of\n ONE node, and `{\"eval_parallel\": true}` collapsed the width to one. The run then looked\n configured and simply did almost nothing, with no 422 and nothing in the snapshot to show why.\n Rejecting at the boundary covers every numeric field at once, including the many that carry no\n `Field(ge=...)` bound of their own to trip over.\n \"\"\"\n if not isinstance(data, dict):\n return data\n for key, val in data.items():\n if type(val) is bool and _is_numeric_annotation(\n getattr(cls.model_fields.get(key), \"annotation\", None)):\n raise ValueError(f\"{key} must be a number, not a boolean (got {val!r})\")\n return data\n\n @model_validator(mode=\"before\")\n @classmethod\n def _apply_profile(cls, data):\n \"\"\"Expand `profile` into a bundle of setting defaults BEFORE validation.\n\n pydantic-settings hands this validator the fully-merged input from ALL explicit sources\n (init kwargs + LOOPLAB_* env + .env), with fields the user left unset simply ABSENT (they\n fall to field defaults afterward). So a profile value is applied only when its key is not\n already present — i.e. any explicit file/CLI/env setting wins over the profile, and the\n profile wins over the bare field default. This makes `thorough` a true convenience preset,\n never an override of what the user asked for.\"\"\"\n if not isinstance(data, dict):\n return data\n prof = data.get(\"profile\", \"default\")\n # env may deliver the key as \"profile\" (env_prefix stripped) — normalize to a str\n prof = str(prof).strip().lower() if prof is not None else \"default\"\n overrides = PROFILES.get(prof)\n if overrides is None:\n raise ValueError(f\"unknown profile {prof!r}; known: {sorted(PROFILES)}\")\n for key, val in overrides.items():\n if key not in data: # explicit source (file/CLI/env) always wins\n data[key] = val\n return data\n\n @model_validator(mode=\"after\")\n def _check_trust_gate(self):\n if self.trust_gate not in (\"audit\", \"gate\", \"block\"):\n raise ValueError(\n f\"trust_gate must be audit|gate|block, got {self.trust_gate!r}\")\n if self.merge_mode not in (\"auto\", \"mean\", \"ensemble\"):\n raise ValueError(\n f\"merge_mode must be auto|mean|ensemble, got {self.merge_mode!r}\")\n # novelty_mode drives the dedup/novelty gate; an out-of-set value (e.g. a mis-cased\n # LOOPLAB_NOVELTY_MODE=LLM, or \"on\") used to fall through _apply_novelty_gate silently as a\n # NO-OP — turning the gate off with no diagnostic. Fail loudly at config time like trust_gate/\n # merge_mode do. (Other enum-ish string fields — seed_mode/eval_trust_mode/strategist_backend —\n # share the pattern; left unvalidated here only because their full value sets are less settled.)\n if self.novelty_mode not in (\"off\", \"algo\", \"llm\"):\n raise ValueError(\n f\"novelty_mode must be off|algo|llm, got {self.novelty_mode!r}\")\n # The remaining enum-ish string fields (arch-review §5 P3): a typo used to be accepted at\n # construction and only fail-safe/later-loud downstream (e.g. a mis-cased strategist_backend\n # silently ran the default). Fail loudly here like the fields above.\n if self.strategist_backend not in (\"off\", \"rule\", \"llm\", \"agent\"):\n raise ValueError(\n f\"strategist_backend must be off|rule|llm|agent, got {self.strategist_backend!r}\")\n if self.eval_trust_mode not in (\"ratify_freeze\", \"autonomous\", \"ratify_freeze_drift\"):\n raise ValueError(\n \"eval_trust_mode must be ratify_freeze|autonomous|ratify_freeze_drift, \"\n f\"got {self.eval_trust_mode!r}\")\n if self.seed_mode not in (\"auto\", \"tracked\", \"all\"):\n raise ValueError(f\"seed_mode must be auto|tracked|all, got {self.seed_mode!r}\")\n if self.backend not in (\"toy\", \"llm\"):\n raise ValueError(f\"backend must be toy|llm, got {self.backend!r}\")\n self._check_llm_profiles()\n return self\n\n def _check_llm_profiles(self) -> None:\n \"\"\"Validate the connection-profile maps LOUDLY, at construction.\n\n Assignment is not validated anywhere in this project, and these two maps are the kind of\n thing a typo makes into a silent no-op: an unknown role name would simply never be read, and\n a reference to a missing profile would fall back to the shared model while the operator\n believed a second provider was in play. Both are errors here instead.\n\n The `api_key_env` rules exist for a specific reason. It must LOOK like a secret to\n `runtime/sandbox.py::SECRET_ENV`, because that is the filter that strips the operator's keys\n out of the environment handed to generated candidate code — a key parked in a variable named\n `MY_AUTH` would be invisible to it and ride straight into the sandbox. (The pattern is\n duplicated below rather than imported: layering forbids `core` from importing `runtime`.\n `tests/test_secret_env_pattern.py` pins the two together.) And a literal key inside a profile\n is refused outright: the profile map is written verbatim into config.snapshot.json and served\n over HTTP precisely because it is supposed to contain no secrets.\"\"\"\n from looplab.core.llm import LLM_ROLE_KEYS\n if not isinstance(self.llm_profiles, dict):\n raise ValueError(\"llm_profiles must be a map of profile name -> profile\")\n for name, entry in self.llm_profiles.items():\n if not isinstance(entry, dict):\n raise ValueError(f\"llm_profiles[{name!r}] must be an object\")\n # BEFORE the unknown-field check: none of these is a known field, so checking them\n # afterwards could never fire and someone who pasted a live key was told it was a typo.\n for banned in (\"api_key\", \"key\", \"token\", \"secret\"):\n if banned in entry:\n raise ValueError(\n f\"llm_profiles[{name!r}] must not contain {banned!r}: a profile stores \"\n \"api_key_env (the NAME of an environment variable), never a key value — \"\n \"the whole map is written to config.snapshot.json and served over HTTP\")\n unknown = set(entry) - _PROFILE_FIELDS\n if unknown:\n raise ValueError(\n f\"llm_profiles[{name!r}] has unknown field(s) {sorted(unknown)}; \"\n f\"known: {sorted(_PROFILE_FIELDS)}\")\n env = entry.get(\"api_key_env\")\n if env is not None and not _SECRET_ENV_NAME.match(str(env)):\n raise ValueError(\n f\"llm_profiles[{name!r}].api_key_env={env!r} is not a usable variable name: it \"\n \"must be UPPER_SNAKE and contain KEY/SECRET/TOKEN/PASSWORD/PASSWD/CREDENTIAL, so \"\n \"the sandbox's secret-name filter strips it from generated code's environment\")\n temp = entry.get(\"temperature\")\n if temp is not None and (isinstance(temp, bool) or not isinstance(temp, (int, float))):\n raise ValueError(f\"llm_profiles[{name!r}].temperature must be a number\")\n if not isinstance(self.role_profiles, dict):\n raise ValueError(\"role_profiles must be a map of role name -> profile name\")\n for role, profile in self.role_profiles.items():\n if role not in LLM_ROLE_KEYS:\n raise ValueError(\n f\"role_profiles has unknown role {role!r}; known: {sorted(LLM_ROLE_KEYS)}\")\n if profile not in self.llm_profiles:\n raise ValueError(\n f\"role_profiles[{role!r}] names profile {profile!r}, which is not in llm_profiles\")\n if self.llm_profile is not None and self.llm_profile not in self.llm_profiles:\n raise ValueError(\n f\"llm_profile={self.llm_profile!r} is not in llm_profiles\")\n\n def masked_snapshot(self) -> dict:\n # Snapshots are a JSON contract (they are written verbatim to config.snapshot.json), not a\n # Python-object dump. In particular tuple-valued schema fields such as\n # ``inline_repair_reasons`` must become JSON arrays before callers hash the runtime envelope;\n # otherwise the strict speculation-scope canonicalizer sees a different/non-JSON preimage\n # from the one that ``json.dumps`` eventually persists.\n d = self.model_dump(mode=\"json\")\n if self.llm_api_key is not None:\n d[\"llm_api_key\"] = \"***\"\n else:\n d[\"llm_api_key\"] = None\n # The snapshot's own FORMAT version — not a Settings field (it is never settable by env or\n # file, and it describes the document, not the run). `settings_from_snapshot` fails closed on\n # a version it does not understand; see CONFIG_SNAPSHOT_SCHEMA.\n d[CONFIG_SNAPSHOT_SCHEMA_KEY] = CONFIG_SNAPSHOT_SCHEMA\n return d\n\n\n# The config snapshot's document-format version, written by `masked_snapshot` and enforced by\n# `settings_from_snapshot`. Bump it when a snapshot written by this binary would be MISREAD by an\n# older one — i.e. when a new field changes paid, concurrency or selection behaviour and silently\n# defaulting it would resume the same event history under different semantics. `Settings` is\n# `extra=\"ignore\"`, so without this an older binary drops what it does not recognize and resumes\n# anyway, with no diagnostic; a version it does not know is now a loud refusal instead.\n# Version 1 is the first VERSIONED format. An absent key means a pre-versioning snapshot, which stays\n# readable through `LEGACY_CONFIG_SNAPSHOT_DEFAULTS` — that historical contract is unchanged.\nCONFIG_SNAPSHOT_SCHEMA = 1\nCONFIG_SNAPSHOT_SCHEMA_KEY = \"config_snapshot_schema\"\n\n\nclass ConfigSnapshotVersionError(ValueError):\n \"\"\"A config snapshot was written by a NEWER binary than the one reading it.\"\"\"\n\n\n# Pre-versioned snapshots are full Settings dumps, so absence is a reliable per-field version marker.\n# Keep their historical effective behavior when newer product defaults become active: re-entry must not\n# silently add paid calls, interventions, concurrency or a different selection policy to an old run.\nLEGACY_CONFIG_SNAPSHOT_DEFAULTS: dict[str, object] = {\n \"parallel_build\": 1,\n \"eval_parallel\": None,\n \"llm_parallel\": None,\n \"train_monitor\": False,\n \"train_monitor_interval_s\": 600.0,\n \"train_monitor_kill\": False,\n \"train_monitor_kill_confidence\": 0.8,\n \"asha_live\": False,\n \"asha_live_kill\": False,\n \"asha_live_quantile\": 0.5,\n \"asha_live_min_siblings\": 3,\n # Before this operator ceiling existed, the sandbox's 24-hour process limit was authoritative.\n \"max_eval_timeout\": 24 * 3600.0,\n \"watchdog_reflection\": False,\n \"card_driven_selection\": False,\n \"speculation_depth\": 0,\n \"speculation_gate_receipt\": None,\n \"concurrent_research_repeat\": False,\n \"concurrent_research_interval_s\": 1800.0,\n # Zero means unlimited; 40 was the first bounded default, and is latent while repeat is disabled.\n \"concurrent_research_max_calls\": 40,\n \"concurrent_consolidate\": False,\n}\n\n\ndef migrate_config_snapshot(data: dict) -> dict:\n \"\"\"Return an effective copy of one legacy/current snapshot without rewriting evidence bytes.\"\"\"\n if not isinstance(data, dict):\n raise TypeError(\"config snapshot must be a JSON object\")\n migrated = dict(data)\n for key, value in LEGACY_CONFIG_SNAPSHOT_DEFAULTS.items():\n migrated.setdefault(key, value)\n return migrated\n\n\ndef config_snapshot_schema(data: dict) -> int:\n \"\"\"The snapshot's declared format version. 0 = pre-versioning (the key did not exist yet).\n\n A malformed value is treated as UNREADABLE rather than as 0: a snapshot whose own version marker\n is corrupt cannot be assumed to be the oldest format.\"\"\"\n raw = data.get(CONFIG_SNAPSHOT_SCHEMA_KEY, 0) if isinstance(data, dict) else 0\n if isinstance(raw, bool) or not isinstance(raw, int) or raw < 0:\n raise ConfigSnapshotVersionError(\n f\"config snapshot has a malformed {CONFIG_SNAPSHOT_SCHEMA_KEY}: {raw!r}\")\n return raw\n\n\ndef settings_from_snapshot(data: dict) -> Settings:\n \"\"\"Build re-entry Settings from a masked snapshot under its historical missing-field contract.\n\n Fails closed on a snapshot written by a NEWER binary. `Settings` is `extra=\"ignore\"`, so such a\n snapshot would otherwise load with every unrecognized control silently dropped — the run would\n resume on the same event history under different paid/concurrency/selection semantics, with\n nothing to show why. Refusing is the only honest answer: this binary cannot know what it is\n dropping. Older and pre-versioning snapshots keep loading exactly as before.\"\"\"\n found = config_snapshot_schema(data)\n if found > CONFIG_SNAPSHOT_SCHEMA:\n raise ConfigSnapshotVersionError(\n f\"this run's config.snapshot.json declares format v{found}, but this build understands \"\n f\"at most v{CONFIG_SNAPSHOT_SCHEMA}. It was written by a newer LoopLab; resuming here \"\n \"would silently drop settings this build does not know. Upgrade LoopLab to resume it.\")\n migrated = migrate_config_snapshot(data)\n migrated.pop(\"llm_api_key\", None)\n migrated.pop(CONFIG_SNAPSHOT_SCHEMA_KEY, None) # a document marker, never a Settings field\n return Settings(**migrated)\n"}, "721e6bff6f35311e2e00ff70b98ad5fd4400a721": {"t": 1785768754.3909347, "n": 108487, "h": "8749e89c56a5ba09", "c": "\"\"\"Engine config: the schema (what settings exist). (I0, ADR-11)\n\nOne pydantic-settings class declaring every engine knob, its default, and its docs. The loader —\nhow a run file / CLI / env becomes a `Settings` — is `looplab.core.appconfig`, which also owns\nthe config-precedence order. A resolved, secret-masked snapshot is written next to each run for\nreproducibility. (No real secrets in P0, but the masking discipline is in place.)\n\"\"\"\nfrom __future__ import annotations\n\nimport re\nimport types\nimport typing\nfrom pathlib import Path\n\nfrom pydantic import Field, SecretStr, field_validator, model_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n# Single source of the LLM first-byte (response-headers) default — see core/llm.py.\nfrom looplab.core.llm import DEFAULT_HEADER_TIMEOUT_S\n\n# Default home for cross-run memory + the knowledge base (shared across every run). Both are ON by\n# default now (a user asked for it) so a fresh install accumulates + consults knowledge with no setup;\n# set the env var to \"\" to disable, or to a path to relocate. `~/.looplab/` keeps them out of any one\n# project + shared across projects.\n_LL_HOME = Path.home() / \".looplab\"\n\n# What one `llm_profiles` entry may contain. Deliberately closed: an unrecognized field is a typo the\n# operator would otherwise never see fail, and the map is a public artifact (config.snapshot.json,\n# HTTP, LOOPLAB_*), so it must never accrete free-form keys.\n_PROFILE_FIELDS = frozenset({\"model\", \"base_url\", \"temperature\", \"api_key_env\"})\n# A variable name that `runtime/sandbox.py::SECRET_ENV` will recognize as holding a secret and strip\n# from generated code's environment. Duplicated here, not imported: layering forbids `core` from\n# importing `runtime`. `tests/test_secret_env_pattern.py` holds the two in agreement.\n_SECRET_ENV_NAME = re.compile(\n r\"\\A(?=[A-Z][A-Z0-9_]{0,63}\\Z)(?=.*(KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL)).*\\Z\")\n\n\ndef _is_numeric_annotation(ann) -> bool:\n \"\"\"True when a field's annotation admits int/float but NOT bool.\n\n Used by `Settings._reject_bool_for_numeric_fields`: pydantic resolves annotations at class build,\n so `model_fields[...].annotation` is a real type object here even under\n `from __future__ import annotations`. Optionals are numeric when every non-None member is\n (`int | None` yes, `int | str` no), so a boolean can never slip in through a union arm.\"\"\"\n if ann is bool:\n return False # a bool field legitimately takes a bool\n if ann is int or ann is float:\n return True\n args = [a for a in typing.get_args(ann) if a is not type(None)]\n origin = typing.get_origin(ann)\n if args and origin in (typing.Union, types.UnionType):\n return all(_is_numeric_annotation(a) for a in args)\n return False\n\n# Autonomous roles that may be granted per-setting write access (see Settings.agent_control).\n# strategist = A7 meta-controller (run-wide); boss = run-chat operator-proxy (run-wide);\n# researcher = per-experiment proposer (per-node sizing, e.g. eval timeout for a heavy model).\nAGENT_ROLES = (\"strategist\", \"boss\", \"researcher\")\n\n# A foresight score is computed for every acted-on proposal. Keep this lower than the generic\n# verifier's emergency ceiling so a typo cannot multiply paid synchronous calls across a run.\nMAX_FORESIGHT_VERIFY_SAMPLES = 8\n\n\n# Layer-2 parallelism renamed two public settings without removing the durable legacy fields. Keep\n# the equivalence in one place: config-source precedence, governance migration, and live strategy\n# application must all agree which names describe the same authority/value.\nPARALLELISM_ALIASES: dict[str, str] = {\n \"max_parallel\": \"eval_parallel\",\n \"parallel_build\": \"llm_parallel\",\n}\n\n\ndef parallelism_aliases(setting: str) -> tuple[str, ...]:\n \"\"\"Return every spelling in ``setting``'s alias family, canonical name first.\"\"\"\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if setting in (legacy, canonical):\n return canonical, legacy\n return (setting,)\n\n\ndef canonicalize_parallelism_source(\n values: dict, *, promote_build_to_llm_parallel: bool = False,\n) -> dict:\n \"\"\"Promote legacy parallel keys *within one config-precedence source*.\n\n Applying this only after file/CLI/env were flattened would let a lower-priority canonical env\n value shadow a higher-priority legacy file value (or a file canonical value shadow a CLI legacy\n override), so callers with sub-layers normalize each layer independently.\n\n ``max_parallel``/``eval_parallel`` are true aliases (pure eval width, no extra semantics) and are\n always promoted. ``parallel_build``/``llm_parallel`` are NOT: a positive ``llm_parallel`` also flips\n on the finite shared LLM broker (``orchestrator._startup_llm_total``), while legacy ``parallel_build``\n means \"legacy build width, unbounded overlap, no shared total\". So promoting a legacy-only\n ``parallel_build`` layer (e.g. just ``LOOPLAB_PARALLEL_BUILD=1`` in env) would silently serialize\n every provider call — contradicting llm_broker.py's docstring, orchestrator's \"legacy-only preserves\n unbounded overlap\" note, the broker-neutral live-control path, and the configuration.md ``llm_parallel``\n row (\"falls back ... without enabling a shared total\"). Config/startup loads therefore keep broker\n opt-in tied to an EXPLICITLY-spelled canonical ``llm_parallel``; the orchestrator's own width fallback\n (``llm_parallel if not None else parallel_build``) still carries the legacy build width. Only the\n Strategist live-control path (``canonicalize_strategy_parallelism``) opts into\n ``promote_build_to_llm_parallel``, where ``parallel_build`` is a deliberate full alias of the\n canonical knob (see strategy.py ``_apply_strategy``).\n \"\"\"\n if not isinstance(values, dict):\n return values\n out = dict(values)\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if canonical == \"llm_parallel\" and not promote_build_to_llm_parallel:\n continue\n # None is the durable legacy mode for llm_parallel, not a missing value. A normal snapshot\n # contains llm_parallel:null plus parallel_build:1; promoting null to 1 would unexpectedly enable\n # the global one-call broker. Promote aliases only when the canonical key is absent, and pin\n # snapshot round-trip parity. Canonicalize inside each precedence layer, never across the\n # flattened result, so the alias migration cannot invert CLI > file > env precedence.\n if canonical not in out and out.get(legacy) is not None:\n out[canonical] = out[legacy]\n # PRECEDENCE REPAIR for the non-promoted pair. Skipping promotion above is right (a legacy-only\n # layer must not flip on the shared broker), but it also let a LOWER-priority canonical value\n # survive a HIGHER-priority legacy override: file `llm_parallel: 2` + CLI `-s parallel_build=8`\n # flattened to BOTH keys, and the orchestrator prefers `llm_parallel`, so the operator's explicit\n # CLI width was silently discarded — exactly the inversion `build_settings`' docstring says this\n # per-layer canonicalization exists to prevent. Mask it with the LEGACY SENTINEL instead of\n # promoting: `llm_parallel=None` is the durable \"legacy mode — take the build width from\n # parallel_build, no shared total\", so the higher layer wins with the semantics it asked for.\n if (not promote_build_to_llm_parallel and \"llm_parallel\" not in out\n and out.get(\"parallel_build\") is not None):\n out[\"llm_parallel\"] = None\n return out\n\n# Settings whose semantics are committed by ``run_started`` and restored from the folded event log\n# on every re-entry. Changing one only in ``config.snapshot.json`` would mix incompatible evidence\n# (a different holdout split) or make the live producer disagree with replay's selection policy.\n# The HTTP config editor and the engine both consume this one contract; keep deliberately mutable\n# controls such as ``trust_gate`` out (that field has its own durable change event).\nRUN_START_PINNED_FIELDS = frozenset({\n \"card_driven_selection\",\n \"speculation_depth\",\n \"holdout_fraction\",\n \"holdout_select\",\n \"select_verifier\",\n \"select_verifier_samples\",\n \"verifier_ci_tie\",\n})\n\n\ndef run_start_pinned_settings(state) -> dict:\n \"\"\"Return the effective Settings names committed by a folded run-start record.\n\n Pre-pin legacy logs have no recorded holdout fraction, so their snapshot remains authoritative\n for both holdout knobs. Verifier fields, however, have always been re-pinned from the legacy-safe\n fold defaults whenever a valid ``run_started`` identity exists.\n \"\"\"\n if not getattr(state, \"run_id\", \"\"):\n return {}\n values = {\n # Layer 3 changes which queue owns macro-action selection. Once a run starts, replay and\n # resume must keep the same owner even if the snapshot or ambient default changes later.\n \"card_driven_selection\": bool(getattr(state, \"card_driven_selection\", False)),\n # Layer 5 overlaps Card production with evaluation only when this pinned treatment is\n # positive. Old logs omit it and therefore retain the exact serial build/eval spine.\n \"speculation_depth\": int(getattr(state, \"speculation_depth\", 0)),\n \"select_verifier\": bool(getattr(state, \"select_verifier_tiebreak\", False)),\n \"select_verifier_samples\": int(getattr(state, \"select_verifier_samples\", 3)),\n \"verifier_ci_tie\": bool(getattr(state, \"verifier_ci_tie\", False)),\n }\n holdout_fraction = getattr(state, \"holdout_fraction\", None)\n if holdout_fraction is not None:\n values.update({\n \"holdout_fraction\": holdout_fraction,\n \"holdout_select\": bool(getattr(state, \"holdout_select\", False)),\n })\n if not values.keys() <= RUN_START_PINNED_FIELDS:\n raise RuntimeError(\"folded run-start pinned settings contract drifted\")\n return values\n\n# --- Run profiles (config-first presets) -------------------------------------------------------\n# A `profile` is a NAMED BUNDLE of setting defaults — nothing more. It only fills fields the user\n# did NOT set explicitly (via file / --set / LOOPLAB_* env / init-kwargs), so any explicit knob\n# always wins (see `_apply_profile`). Most engine intelligence lives behind individual flags. The\n# PART IV/V cross-run + concept-graph machinery (concept_pivot, graded_novelty, cross_run_concepts /\n# _advisory / _structured_claims / _curation / _read_tools, fingerprint_universal, foresight_verify)\n# now ships ON in the product `Settings` default. That is an explicit experimental product choice,\n# not a claim that a frozen A/B or workload-wide cost/quality gate has cleared: the bundle can steer\n# prompts and candidate generation, change graded-novelty admission / pre-execution choices, and add\n# paid LLM work when its client and memory prerequisites are present. The bare-library `EngineOptions`\n# default stays lean (see tests/test_options_divergence.py — product side is the aggressive one) so a\n# toy `Engine(...)` in a test doesn't fire cross-run LLM work unasked. The trust/confirm/ablate QUALITY\n# bundle still ships OFF and is turned on by `thorough`. Every value here is reachable by hand — the\n# profile is a convenience, never a hidden mode.\n# keep this rationale behavioral and evidence-based; do not relabel read-only storage\n# access or proposal-only governance as a no-op for agent behavior, cost, or proposal admission.\n#\n# `thorough` deliberately touches only QUALITY/TRUST machinery, not spend: it does NOT raise\n# max_nodes / eval_parallel (those are cost knobs the user owns). It enables multi-seed confirmation\n# (demote seed-lucky leaders), the reward-hack / leakage / critic monitors AND flips them from\n# advisory to *gating* (`trust_gate=\"gate\"` — a\n# flagged win can no longer be selected as best), ablation-driven refinement, and the prompt cues\n# (complexity / budget / failure-reflection / watchdog-reflection). Agentic novelty and the experimental\n# Part IV/V bundle are already product defaults, not profile changes. Effect is large; marginal cost is a\n# few extra evals.\nPROFILES: dict[str, dict] = {\n \"default\": {}, # explicit no-op alias for \"ship-as-is\" (== omitting profile)\n \"fast\": {}, # today's lean defaults, named for symmetry with `thorough`\n \"thorough\": {\n \"confirm_top_k\": 3,\n \"confirm_seeds\": 3,\n # novelty_gate intentionally NOT enabled here: duplicate-detection is the agentic Researcher's\n # job (it reads past experiments + find_analogous_across_runs and DECIDES), not an algorithmic\n # param-distance / embedding auto-reject that mis-fired (it nudged a good fresh idea onto a\n # twice-dead lineage — live node 61). Search stays as tools; the LLM judges novelty.\n \"operator_bandit\": True, # P4: adaptive operator mix over folded yields (GreedyTree)\n \"reward_hack_detect\": True,\n \"code_leakage_detect\": True,\n \"critic_check\": True,\n \"trust_gate\": \"gate\", # detectors stop gating a flagged node from WINNING (not just audit)\n \"ablate_every\": 3,\n \"complexity_cue\": True,\n \"budget_aware\": True,\n \"failure_reflection\": True,\n \"watchdog_reflection\": True, # feed recent live-watchdog (train-monitor/ASHA) flags to proposals\n \"reflection_priors\": True, # no-op unless memory_dir is set (cross-run priors)\n },\n}\n\n\n# The shipped agent-governance matrix (who may change which setting at runtime). Defined as a module\n# constant so the Engine's direct-construction default matches this shipped default exactly (the\n# EngineOptions \"Engine() == shipped defaults\" invariant) instead of diverging to an all-locked map.\nDEFAULT_AGENT_CONTROL: dict[str, list[str]] = {\n \"timeout\": [\"researcher\", \"strategist\"],\n # Layer-2 canonical names are the presented/default governance surface. `_agent_may` falls back\n # to an old snapshot's legacy grant only when its canonical counterpart is absent, so removing\n # duplicate defaults preserves resume authority while an explicit canonical revocation still wins.\n \"eval_parallel\": [\"strategist\"],\n \"llm_parallel\": [\"strategist\"],\n # Strategy-only pseudo-setting: the closed per-lane allocation inside the canonical LLM total.\n # It is not a launch Settings field; operators pin it through `set_strategy` and the Strategist\n # may reallocate it only while this grant is present.\n \"llm_lane_limits\": [\"strategist\"],\n \"max_nodes\": [\"strategist\", \"boss\"],\n \"max_eval_seconds\": [\"strategist\", \"boss\"],\n \"policy\": [\"strategist\"],\n # The policy NAME and its PARAMS are gated independently in `_apply_strategy`; grant both by default\n # so the Strategist that may switch to (say) MCTS may also apply the `c`/`eta` it decided — else its\n # tuned params are silently dropped and the recorded `active_strategy` diverges from the live engine.\n \"policy_params\": [\"strategist\"],\n \"n_seeds\": [\"strategist\"],\n \"ablate_every\": [\"strategist\"],\n \"merge_mode\": [\"strategist\"],\n \"complexity_cue\": [\"strategist\"],\n \"ablate_code_blocks\": [\"strategist\"],\n \"prefer_sweep\": [\"strategist\"],\n \"novelty_stance\": [\"strategist\"],\n \"developer\": [\"strategist\"],\n \"fidelity\": [\"strategist\", \"researcher\"],\n}\n\n\ndef default_agent_control() -> dict[str, list[str]]:\n \"\"\"A FRESH deep-ish copy of the shipped governance matrix (each role list copied so a caller can\n mutate its own map without touching the module constant). The SINGLE constructor of the default,\n called by both `Settings.agent_control`'s default_factory AND the Engine's direct-construction\n default — so \"Engine() == shipped defaults\" can't drift if the copy depth ever needs to change\n (e.g. nested values requiring copy.deepcopy), instead of two verbatim copy expressions.\"\"\"\n return {k: list(v) for k, v in DEFAULT_AGENT_CONTROL.items()}\n\n\nclass Settings(BaseSettings):\n \"\"\"The engine settings schema (every knob a run accepts).\n\n Timeout family — six distinct knobs, each owned by a different subsystem:\n - `timeout`: per-eval wall-clock budget for ONE experiment's evaluation (engine/eval).\n - `max_eval_timeout`: hard ceiling for a governed per-node Researcher timeout override.\n - `llm_timeout`: LLM request idle timeout — inter-token stall limit in stream mode\n (core.llm OpenAICompatibleClient).\n - `llm_header_timeout`: LLM first-byte (response-headers) window for stream attempts (core.llm).\n - `agent_time_budget_s`: wall-clock ceiling across a tool-using agent loop's turns (agents).\n - `dep_install_timeout`: per-package budget for auto-installing a missing dep (env self-prep).\n\n Config-source precedence is owned by `looplab.core.appconfig` (the loader) — see its module\n docstring for the one canonical order.\n \"\"\"\n # Config precedence: see appconfig.py — the loader owns the full order. The `.env` is read\n # so `looplab run`/`looplab ui` pick up LOOPLAB_LLM_BASE_URL etc. without exporting them by hand\n # (keys MUST carry the LOOPLAB_ prefix, same as the env vars). utf-8-sig tolerates a BOM from\n # Windows editors. The test suite disables this (tests/conftest.py) so a dev's real .env in the\n # repo root can't leak into assertions.\n model_config = SettingsConfigDict(\n env_prefix=\"LOOPLAB_\",\n env_file=\".env\",\n env_file_encoding=\"utf-8-sig\",\n extra=\"ignore\",\n allow_inf_nan=False,\n )\n\n @classmethod\n def settings_customise_sources(\n cls, settings_cls, init_settings, env_settings, dotenv_settings,\n file_secret_settings):\n \"\"\"Preserve source precedence across canonical/legacy parallelism names.\n\n Pydantic ranks init > env > dotenv correctly, but it ranks field names rather than semantic\n alias families. Promoting a legacy value inside each source before the merge makes an old\n explicit snapshot/file value beat a lower-priority canonical env value. ``appconfig`` also\n promotes its file/typed/--set sub-layers before combining them into the init source.\n \"\"\"\n def _with_parallel_aliases(source):\n return lambda: canonicalize_parallelism_source(source())\n\n return (\n _with_parallel_aliases(init_settings),\n _with_parallel_aliases(env_settings),\n _with_parallel_aliases(dotenv_settings),\n _with_parallel_aliases(file_secret_settings),\n )\n\n # Run profile (config-first preset, see PROFILES above). \"default\"/\"fast\" = today's lean\n # defaults; \"thorough\" turns the built quality/trust machinery ON in one word. A profile only\n # fills fields the user did NOT set — every knob it touches stays individually overridable.\n profile: str = \"default\"\n\n # Bounds (review C/⚪): a non-positive node/seed budget or timeout is never valid. Upper bounds are\n # equally load-bearing — the UI /start path writes these straight into the engine's env, and an\n # unbounded `eval_parallel` (or legacy `max_parallel`; e.g. 100000 from a crafted preflight) would\n # fan out that many sandboxes → resource exhaustion. Reject at config time, not mid-run. Ceilings\n # are generous; tests top out near 50.\n # Pydantic's lax mode coerces a JSON boolean into any numeric field (`true` -> 1), so a UI/API type\n # error used to silently collapse a run budget to ONE node instead of returning 422. Rejected for\n # EVERY numeric field by `_reject_bool_for_numeric_fields` below, not just the ones bounded here.\n n_seeds: int = Field(default=3, ge=1, le=1024)\n max_nodes: int = Field(default=8, ge=1, le=1_000_000)\n # legacy evaluation-width input. `eval_parallel` below is canonical and wins when set;\n # keep this field for old env/config/snapshots and direct Engine call sites. A width > 1 admits\n # concurrent evals, whose resource reservations may be CPU-only or span one/more GPUs — there is no\n # one-node/one-GPU promise. `max_parallel=0` is launch-time AUTO (detected GPU count, at least 1).\n max_parallel: int = Field(default=1, ge=0, le=1024)\n # legacy build-width input. `llm_parallel` below is canonical and also controls the\n # shared provider-call total when positive. A width > 1 uses fresh pooled researcher/developer pairs;\n # it says nothing about the later eval's CPU/GPU reservation. Startup `0` derives build fan-out from\n # resolved eval width; a live Strategist/operator zero settles to 1. Without role_factory it clamps to 1.\n parallel_build: int = Field(default=1, ge=0, le=64)\n # Hypothesis-card Kanban re-architecture (docs/23, Layer 2). Two INDEPENDENT concurrency axes,\n # decoupled from each other so cost (LLM work) can be tuned separately from throughput (GPU evals).\n # These are the CANONICAL names going forward; `max_parallel`/`parallel_build` above are kept as thin\n # back-compat aliases (env vars / snapshots / ~100 Engine(...) call sites depend on the exact names,\n # so they cannot be renamed — CLAUDE.md). Resolution (orchestrator.__init__): a NEW name, when set,\n # WINS over its legacy alias; None => fall back to the legacy field => byte-identical to today.\n # `eval_parallel`: concurrent EVALS (the GPU/experiment consumer). None -> max_parallel; 0 = AUTO\n # (one experiment per detected GPU). This is the in-Run eval-concurrency ceiling; the resource\n # scheduler bin-packs declared footprints separately. Sibling local GPU-owning Runs serialize\n # through the conservative host-pool lease; external execution needs its own admission boundary.\n # `llm_parallel`: total concurrent LLM provider calls + BUILD fan-out. None -> legacy\n # parallel_build semantics without a shared total; 0 = startup AUTO (= resolved eval_parallel\n # for build fan-out, historical research overlap unbounded). A positive canonical value enables\n # the named-lane broker the Strategist can reallocate at cadence.\n eval_parallel: int | None = Field(default=None, ge=0, le=1024)\n llm_parallel: int | None = Field(default=None, ge=0, le=64)\n # Training-log monitor (I-series watchdog family): a per-eval background observer that tails the live\n # training log while a (often multi-hour) declared stage runs in a worker thread. ON in the product\n # surface (Settings). Its alert event is fold-ignored and cannot directly change lifecycle/champion;\n # with watchdog_reflection on, that alert is nevertheless advice in a later Researcher\n # prompt and can change future proposals. It only fires on the command-eval path with an LLM client;\n # bare-library EngineOptions stays OFF. See test_options_divergence.\n # `_interval_s` is the BASE tick cadence (the effective one adapts to the per-experiment budget).\n train_monitor: bool = True\n train_monitor_interval_s: float = Field(default=600.0, gt=0)\n # Phase 3 — INTERVENTION (opt-in, separate from observation): let the monitor tree-kill a training the\n # LLM judges 'broken' (diverged / silent CPU fallback / not learning) EARLY, instead of burning the\n # whole budget. Off by default — the observer only WATCHES unless this is on. A kill only fires on a\n # 'broken' verdict (a plateau is 'watch', never killed) with confidence >= the threshold; the node then\n # fails normally (reason='monitor_broken'), so replay reconstructs it from that one terminal event.\n train_monitor_kill: bool = False\n train_monitor_kill_confidence: float = Field(default=0.8, ge=0.0, le=1.0)\n # ASHA live-curve watchdog (sibling of the training monitor): reads the latest INTERMEDIATE value of\n # the objective metric off the live log (reusing the eval's OWN metric reader). Finished-endpoint rank\n # remains advisory. An opt-in KILL additionally requires an operator-declared metric.resource_key and\n # enough sibling observations at exactly that resource; absent comparable evidence cannot stop a run.\n # Records a fold-ignored `asha_rank` diagnostic + trace span; the library default is OFF (off == today).\n asha_live: bool = True\n # Opt-in INTERVENTION (separate from the advisory signal): let the watchdog tree-kill a node whose\n # intermediate metric stays below the SAME-RESOURCE bar past a short grace window. Off by default —\n # it only surfaces endpoint rank unless this is on. A kill fails normally (asha_underperforming).\n asha_live_kill: bool = False\n # The bar sits at `asha_live_quantile` along a WORST->BEST ordering of the finished siblings' finals:\n # 0.5 = the median (a node worse than the median peer flags); a SMALLER value is more conservative —\n # it lowers the bar toward the WORST finished peer, so only a node worse than nearly all peers flags\n # (0.0 = worse than the worst). `min_siblings` is the floor of finished peers required before it ranks\n # at all, so it never acts on too little evidence.\n asha_live_quantile: float = Field(default=0.5, ge=0.0, le=1.0)\n asha_live_min_siblings: int = Field(default=3, ge=1)\n timeout: float = Field(default=30.0, gt=0)\n # Hard ceiling for the Researcher's governed per-node `Idea.eval_timeout`. One hour preserves the\n # existing heavy/neural-net authoring example while staying well below the sandbox's defensive\n # 24-hour subprocess ceiling. This is operator-owned run config: agents may request less or more,\n # but the accepted action is always clamped here after the `agent_control.timeout` permission gate.\n max_eval_timeout: float = Field(default=3600.0, gt=0, le=24 * 3600.0)\n # Intra-node sweep: a sweep node runs a whole grid in one process, so it gets this multiple of\n # `timeout` as its wall-clock budget (solution.py path; RepoTasks use their per-profile timeout).\n sweep_timeout_mult: float = Field(default=8.0, ge=1)\n # Eval STALL watchdog CAP (seconds): a stage that is completely SILENT on stdout/stderr for this\n # long — while still alive and BELOW its wall-clock deadline — is tree-killed early with a STALLED\n # marker (a hung dataloader/deadlock dies in minutes instead of burning a multi-hour timeout). The\n # per-stage window is `min(this, the stage's own timeout)`, so a short scorer's deadline fires first.\n # `0` DISABLES the watchdog entirely (only the hard deadline applies) — for a legitimately quiet\n # non-Python stage (block-buffered stdout, a script that logs only to its own file). Threaded into\n # the eval and surfaced to the Developer so its code can emit periodic progress to stay alive.\n eval_stall_timeout_s: float = Field(default=1800.0, ge=0)\n # Sandbox tier (ADR-13): \"trusted_local\" (subprocess, no Docker) for the CLI;\n # \"untrusted\" (Docker --network none, shared-kernel runtime) for hosted/multi-tenant UI;\n # \"hostile\" (untrusted + a true-isolation OCI runtime, gVisor `runsc` by default / Kata) for\n # running untrusted code. See runtime/sandbox.py::make_sandbox for the tier selection.\n trust_mode: str = \"trusted_local\"\n # Image for the untrusted command-eval tier (RepoTask, Phase 4): the framework's deps\n # should be baked in (the container runs --network none, so a pip setup can't fetch).\n docker_image: str = \"python:3.12-slim\"\n # Resource caps for the untrusted/hostile Docker tier (passed to `docker run --memory/--cpus`).\n # The default 4g bound protects other tenants from an OOM, but a model-training MLE-bench eval\n # routinely needs more — raise it (e.g. \"16g\") for such runs. \"\" disables the cap (unbounded).\n # Ignored by the trusted_local (subprocess) tier.\n sandbox_memory: str = \"4g\"\n sandbox_cpus: str = \"\"\n # Best-effort host-OOM guard for the TRUSTED-LOCAL (subprocess) tier. When set (e.g. \"8g\"), each\n # eval child gets an RLIMIT_AS address-space cap so a runaway trainer hits MemoryError instead of\n # OOM-killing the whole host (and the engine). POSIX only; a no-op on Windows. Default \"\" = OFF:\n # RLIMIT_AS bounds VIRTUAL memory, and CUDA/torch reserve tens of GB of virtual without using it,\n # so a default cap would spuriously kill GPU evals — leave it off for those and use the Docker\n # tier's real --memory cgroup bound (sandbox_memory) instead. Ideal for CPU/numpy/sklearn runs.\n sandbox_memory_local: str = \"\"\n # Best-effort disk-fill guard for the TRUSTED-LOCAL tier (P1-5): an RLIMIT_FSIZE cap on the size of\n # any single file an eval child writes (e.g. \"2g\"), so a runaway that writes an unbounded file gets\n # SIGXFSZ instead of filling the host disk. POSIX only. Default \"\" = OFF (large model checkpoints\n # need big files); set it for tasks that write only small artifacts.\n sandbox_fsize_local: str = \"\"\n # Search policy (ADR-2): \"greedy\" | \"evolutionary\" | \"mcts\" | \"asha\".\n policy: str = \"greedy\"\n # Ablation-driven refinement (I7): every N improves, ablate the best to find the\n # highest-impact parameter and refine it. 0 = off. (GreedyTree only.)\n ablate_every: int = 0\n # A0a (MLE-STAR): when ablating, treat each generated pipeline *code block* (not just a\n # numeric param) as an ablation unit — neutralize a block, measure the metric delta, refine\n # the highest-impact block. Off => the classic numeric-param ablation. Config-first knob.\n ablate_code_blocks: bool = False\n # A0b: real merge/ensembling. \"mean\" = legacy mean-param merge; \"ensemble\" = the Developer\n # writes a code-recombination ensemble over the two parents' solutions (verified: agent-proposed\n # ensembling 37.9%->43.9%). \"auto\" (default) = ensemble whenever the Developer actually\n # generates code (LLM/agent backends; declared via `is_code_generating`), mean for the\n # templated/toy developers where a code ensemble is meaningless — the evidence says code\n # recombination is the strongest single merge (removing it costs ~9 pp), so it is the default\n # wherever it can work. Falls back to mean when the Developer can't ensemble.\n merge_mode: str = \"auto\"\n # A0d (AIRA): inject a dynamic complexity hint into the draft/improve prompt keyed on the\n # node's child count (few children -> keep minimal; many -> escalate to ensembling/HPO).\n complexity_cue: bool = False\n # I1 (CAAFE-style) LLM feature-engineering: instruct the proposer/developer to add\n # semantically-meaningful engineered features (ratios, interactions, aggregations) as code. The\n # existing cross-validation eval + best-selection is the CV GATE — a feature set that doesn't\n # improve CV is never selected (feature engineering is non-universal, so this gate is mandatory).\n # Tabular tasks (those exposing column data). Off by default.\n feature_engineering: bool = False\n # A5: surface the REMAINING eval-compute budget into the proposal prompt so the Researcher can\n # reason over how much compute is left (explore broad while flush, exploit/cheapen when low).\n # No-op unless a max_eval_seconds budget is set.\n budget_aware: bool = False\n # A4 (LATS-style): feed a summary of the most recent FAILED branches (operator + error reason)\n # back into the proposal prompt so the proposer reflects on and avoids repeating them. ON by\n # default: it is SELECTIVE by construction (injects only when recent failures exist — the\n # evidenced form of reflection; reflecting at every step shows no gains, arXiv:2506.12928).\n failure_reflection: bool = True\n # Feed the LIVE-WATCHDOG observations (train-monitor health verdicts + ASHA intermediate-rank flags)\n # of recent experiments back into the proposal prompt, so the Researcher avoids re-proposing a config\n # whose TRAINING was already observed to be weak — even when the watchdog kills are off (default) and\n # the node ran to completion (those diagnostics are fold-ignored, so failure_reflection never sees\n # them). ON by default and SELECTIVE like failure_reflection (injects only when a recent flag exists).\n watchdog_reflection: bool = True\n # C3 deep test-driven repair: hand the Developer the failure taxonomy + a structured \"reproduce\n # then fix\" directive on debug, not just the raw stderr tail. ON by default (product surface); the\n # conservative library default in EngineOptions stays off, per that module's contract.\n deep_repair: bool = True\n # === Inline crash repair ==============================================================\n # Hybrid in-node crash repair: when an LLM-generated node CRASHES at runtime (mechanical errors\n # — bad import, removed kwarg, typo), the agent triages it and may repair the code IN PLACE within\n # the same eval (cheap; does NOT consume max_nodes and does NOT add a node to the search tree).\n # Semantic failures still flow to a new idea/debug node via the policy. ON by default; set False\n # to restore the prior behavior (every crash waits for a budgeted inter-node debug node).\n inline_repair: bool = True\n # Max in-place repair attempts per node before it fails normally. Default 0 = UNLIMITED: the same\n # agent keeps fixing + re-evaluating IN THE SAME node until it produces a clean metric — no new\n # node just to retry. Runaway is prevented not by a count but by the anti-stuck guard\n # (`inline_repair_stuck_repeat`): when the SAME error signature repeats with no progress, or the\n # agent's crash-triage says \"abandon\", the node fails. Set a positive N to cap the retries instead.\n inline_repair_attempts: int = Field(default=0, ge=0)\n # Anti-stuck: abandon in-node repair when the SAME error recurs this many times in a row (no\n # progress). Keeps \"unlimited\" repair from looping forever on an unfixable error.\n inline_repair_stuck_repeat: int = Field(default=4, ge=2)\n # Which failure reasons (_failure_reason: crash|timeout|oom|setup|no_metric|drift) are eligible for\n # inline repair. Default: mechanical crashes, timeouts AND OOM-kills — a timeout/OOM means the code\n # was too slow / too memory-hungry for the budget (or the pod's cgroup limit), not that the idea is\n # wrong, so the agent repairs it by reducing compute/memory (fewer estimators/epochs/folds/seeds, a\n # lighter model, a smaller batch, subsampling) instead of letting the node die unrecovered. Env\n # override expects a JSON array.\n inline_repair_reasons: tuple[str, ...] = (\"crash\", \"timeout\", \"oom\")\n # Cost ceiling for in-node repair of a MULTI-STAGE eval: the max number of FULL pipeline re-runs\n # (i.e. re-trains from the first stage) the inline-repair loop may do before abandoning to the\n # inter-node debug operator. A late-stage failure (a broken `score`/eval script that didn't touch\n # `train`) REUSES the completed train checkpoint and re-runs only from the failed stage — that is\n # CHEAP and NOT counted here; only a repair that changes an EARLIER stage's code (train.py/loss.py/…)\n # forces a full re-train, which each costs the whole training time. Without this bound, a repair that\n # keeps changing training code could burn many full trains (the anti-stuck guard is error-signature\n # based, not cost based). 0 = unlimited (legacy behavior). Single-command evals ignore it.\n inline_repair_retrain_cap: int = Field(default=2, ge=0)\n # Environment self-prep: when a solution crashes purely because a KNOWN library isn't installed\n # (ModuleNotFoundError), the engine pip-installs it into the eval interpreter and re-runs — so a\n # torch/XGBoost/CatBoost idea (e.g. a GRU model) actually runs on a fresh box instead of being\n # thrown away as `idea_rejected`. Trusted_local tier only (the untrusted/hostile Docker tiers run\n # --network none). A typo'd or local helper module is NOT installed (that's a code bug to repair).\n auto_install_deps: bool = True\n # Per-package wall-clock budget for an auto-install (seconds). Generous: large wheels like torch\n # can take minutes to download/build.\n dep_install_timeout: float = Field(default=900.0, gt=0)\n # --- Agent governance: who may change a setting at runtime (per-setting allow-list of roles) ---\n # Roles: \"strategist\" (A7 meta-controller; run-wide), \"boss\" (run-chat operator-proxy; run-wide),\n # \"researcher\" (per-experiment proposer; PER-NODE — e.g. set a longer eval timeout for a neural-net\n # idea). A setting ABSENT from this map is LOCKED — no agent may change it, only a human via the\n # snapshot/UI. The sole conditional exception is Strategy-only `card_scoring`: Card mode grants\n # it implicitly without changing the default flag-off snapshot, while an explicit [] revokes it.\n # The gate is ENFORCED at every AGENT seam via `_agent_may` — the strategist's whole\n # control surface (policy/operators/novelty_stance/prefer_sweep/developer/fidelity/timeout/\n # eval_parallel/llm_parallel) is checked in `_apply_strategy`, so removing a role from a knob locks it,\n # not just greys out the UI pill. The boss/max_* grants document boss-relevant knobs, but a\n # `budget_extend` is a HUMAN control intent (the boss action-builder can only emit `add_nodes`), so\n # it is applied as-is — the human always wins via the UI/snapshot. Defaults are conservative: resource/\n # search-shape knobs the agents already reason about; provider infra (llm_model/base_url/api_key,\n # docker_image) stays locked. The UI shows S/B/R pills per setting. `timeout` granted to the\n # researcher IS the \"auto\" per-node mode — the researcher sizes each experiment's wall-clock\n # (Idea.eval_timeout); the config `timeout` is the fallback for nodes it doesn't size. Env override\n # expects a JSON object. (`fidelity`/`novelty_stance`/`prefer_sweep`/`developer` are governance keys\n # for the strategist's per-node dials — not 1:1 Settings fields, but gated the same way.) The default\n # lives in the module-level DEFAULT_AGENT_CONTROL so the Engine's direct-construction default matches.\n agent_control: dict[str, list[str]] = Field(default_factory=default_agent_control)\n # C1 (Agentless localization): for repo tasks, rank the source files most relevant to the most\n # recent failure (traceback + identifiers) and surface them in the proposal/repair prompt so the\n # Developer edits the right place. Off by default; repo tasks only.\n localize_faults: bool = False\n # A2 surrogate-guided proposal (BO-lite): fit a k-NN surrogate over (params->metric) and propose\n # by acquisition instead of random/hill-climb. Numeric-bounds tasks only; bootstraps via the\n # base Researcher. Off by default. `surrogate_explore` = UCB-style exploration weight.\n surrogate_proposer: bool = False\n surrogate_explore: float = 0.1\n # E2 researcher panel: generate K candidate ideas per proposal and keep the one ranked best by a\n # cheap empirical surrogate over the (params->metric) history (NOT an LLM-judge). 1 = off.\n researcher_panel: int = 1\n # A1 ASHA / successive-halving: reduction factor (keep top 1/eta per rung) and rung budget.\n asha_eta: int = Field(default=3, ge=2)\n # Rung-0 width for ASHA/BOHB (the wide base of cheap drafts). 0 = use n_seeds (default,\n # preserving prior behavior); set >0 to seed a wider/narrower base independent of n_seeds.\n asha_rung_nodes: int = Field(default=0, ge=0)\n # A6 proxy/predictive scoring: cheaply rank a candidate's potential from early-stage signals\n # and skip a full eval for the doomed bottom fraction. 0.0 = off (no candidate skipped).\n proxy_scoring: bool = False\n proxy_kill_fraction: float = Field(default=0.0, ge=0.0, le=0.9)\n # === Novelty / dedup ==================================================================\n # E1 novelty/dedup gate: reject near-duplicate proposals (normalized param-space distance below\n # `novelty_epsilon`) by deterministically nudging them off the duplicate — stops the search\n # wasting evals re-trying the same idea. Audit event `novelty_rejected`. Off by default.\n # Novelty/dedup MODE selector: how a fresh proposal is checked against already-tried experiments.\n # \"off\" — no explicit gate; the agentic Researcher's own read-the-history judgment is trusted.\n # \"algo\" — the deterministic gate: numeric param-distance (`novelty_epsilon`) + optional embedding\n # similarity (`novelty_semantic`). Cheap, no LLM call, but can't explain itself.\n # \"llm\" — an LLM adjudicates (reads the real experiments via tools) whether the idea is a\n # near-duplicate and, if so, asks the Researcher once more for something different. One\n # extra LLM call per proposal — highest quality, follows the \"let the LLM decide\" line.\n novelty_mode: str = \"llm\"\n # Legacy sub-toggles, honored by the \"algo\" mode (and back-compat: novelty_gate=True forces \"algo\"):\n novelty_gate: bool = False\n novelty_epsilon: float = 0.05\n # T5 semantic novelty (active whenever the deterministic gate runs — novelty_mode=algo,\n # novelty_gate=true which is the legacy alias for algo, OR the Strategist novelty stance is\n # \"explore\"; a no-op only under novelty_mode=llm/off with a non-explore stance): reject a proposal\n # whose idea TEXT embeds within\n # `novelty_semantic_threshold` cosine of an existing node's. OFF BY DEFAULT: novelty is the agentic\n # Researcher's call, made by READING the actual prior experiments (read_experiment /\n # find_analogous_across_runs / list_experiments), NOT an embedding-similarity auto-reject that can't\n # explain itself and mis-fires on paraphrases. Semantic/param search stays available as TOOLS in the\n # Researcher's context; the LLM decides whether an idea is a true duplicate.\n novelty_semantic: bool = False\n novelty_semantic_threshold: float = Field(default=0.92, ge=0.5, le=1.0)\n # T10: debug-lineage depth bound for every policy — how many error-feedback repairs a failing\n # lineage gets before it is abandoned. The old default (1) abandoned lineages after ONE repair;\n # multi-turn/deeper debugging is a verified lever (AIRA2 ReAct debug +5.5 percentile points).\n debug_depth: int = Field(default=2, ge=1)\n # P4 operator bandit (GreedyTree): replace the fixed merge/ablate cadences with a\n # deterministic UCB over per-operator yield (Δmetric per eval-second, folded from the run).\n # Off by default (the cadences are well-tested and the bandit has no direct published\n # ablation); `thorough` turns it on.\n operator_bandit: bool = False\n # P1 hypothesis ledger: ask the Researcher to state the one-line hypothesis each experiment tests,\n # register deep-research directions as hypotheses, and track them to a verdict on the board. ON by\n # default. The board is shown to the Researcher and can be foresight-ordered, so it can\n # steer later proposals even though it never directly re-ranks already-evaluated nodes. Set False\n # to drop the prompt nudge + registration.\n track_hypotheses: bool = True\n # E4/M2/M3 reflection-memory -> priors (gradient-free cross-run meta-learning): at run end distill\n # the winner into `<memory_dir>/meta_notes.jsonl` AND structured lessons (incl. NEGATIVE results:\n # tested/abandoned hypotheses + failure themes) into `lessons.jsonl` with a task fingerprint; at run\n # start, inject exact-task notes + fingerprint-matched lessons from SIMILAR tasks into the proposal\n # prompt. ON by default — but a NO-OP until `memory_dir` is set (that's where cross-run memory lives).\n reflection_priors: bool = True\n # M6 comparative lessons (MARS \"comparative reflective memory\", doc 13 §7 item 2): distill\n # credit-assigned lessons from PAIRS of solutions — which SPECIFIC change made a child beat\n # (or regress from) its parent, and what fixed a failure — on top of the one-shot run-end\n # reflection. At most one extra LLM call per distillation (all pairs batched); offline a\n # deterministic param-diff credit stands in. Gated on reflection_priors + memory_dir like the\n # rest of cross-run memory — and since memory_dir has a default (~/.looplab/memory), this is\n # ACTIVE out of the box; clear memory_dir to disable cross-run memory wholesale.\n comparative_lessons: bool = True\n # M6 mid-run cadences (doc 13 §7 item 5 — the AgentRxiv live-share pattern): every N created\n # nodes, WRITE comparative lessons to the shared cross-run store during the run (lessons_every\n # — one batched LLM call per firing) and RE-READ the store so lessons distilled by CONCURRENT\n # runs reach this run's proposals (lessons_refresh_every; file re-read, no LLM call, skipped\n # when the store is unchanged). ON by default (every 4 nodes) and, like comparative_lessons\n # above, live out of the box because memory_dir defaults on; 0 = off — run-end write /\n # run-start read only, the pre-M6 behavior.\n lessons_every: int = Field(default=4, ge=0)\n lessons_refresh_every: int = Field(default=4, ge=0)\n # B3 output redaction: mask credentials (known key shapes + high-entropy tokens) in the\n # stdout/stderr tail before it is persisted to the event log / spans / UI — a leaked secret in a\n # print()/traceback must not land in the durable log. Off by default; recommend on for untrusted.\n redact_output: bool = False\n # B5 reward-hacking detector: a host-side monitor that flags suspicious wins (grader/answer-key\n # access, runtime writes to frozen files, suspiciously-perfect metrics) as a `reward_hack_suspected`\n # audit event in the Trust panel. Off by default. Whether a flag CHANGES selection is governed by\n # `trust_gate` below (default: audit-only, never changes selection).\n reward_hack_detect: bool = False\n # Trust enforcement (T2): what a reward-hack / data-leakage flag actually DOES to selection.\n # \"audit\" (default) = surface it in the Trust panel, never change selection (today's behavior);\n # \"gate\" = a flagged node can no longer be selected as BEST **and isn't bred/confirmed from**\n # (breed_excluded — see replay._apply_trust_gate / models.breedable_nodes), but stays\n # FEASIBLE so it still counts for diversity/audit — closes the \"a hacked/leaky node can\n # win OR seed its lineage\" hole;\n # \"block\" = additionally mark it fully INFEASIBLE (removed from feasible_nodes entirely).\n # Deliberately gates only high-precision CHEATING/LEAKAGE signals. Broad `critic:*` findings stay\n # advisory; `critic:hardcoded_metric` is the one narrow high-precision critic exception\n # and gates because it requires a literal metric with no computed assignment anywhere.\n trust_gate: str = \"audit\"\n # I3 data-centric: static code-leakage scan of each evaluated solution (fit-before-split,\n # fit-on-test) surfaced into the Trust panel alongside reward-hack flags. Off by default.\n code_leakage_detect: bool = False\n # 4.4 sandbox instrumentation (needs reward_hack_detect): after each eval, flag a RUNTIME write\n # to a protected/frozen file (an answer key or grader tampered mid-run) — behavioral evidence a\n # static code scan misses. ON by default; only fires when the reward-hack detector is on.\n workdir_audit: bool = True\n # D8 decoupled research Verifier: check every Deep-Research memo's CLAIMS against their cited\n # evidence (node ids / urls) before recording — synthesis is the documented weak link (Kosmos\n # 57.9% accurate). Deterministic layer always; an LLM rubric pass when the DeepResearcher has a\n # client. The verdict cannot change this run's node/champion, but finalize uses aligned supported\n # verdicts as the evidence gate for persisted D8 cross-run claims; unavailable/misaligned stays\n # unverified. ON by default (no-op with no memo/claims).\n research_verify: bool = True\n # C4 independent critic: an execution-free critic of each solution (stub / hardcoded-metric /\n # params-ignored; on host-graded tasks the metric checks become a submission-output check)\n # surfaced in the Trust panel. Broad findings are advisory; `critic:hardcoded_metric` can gate under\n # trust_gate=gate/block. Off by default.\n critic_check: bool = False\n # A7 Strategist (NEW, user-requested): optional LLM/rule meta-controller that picks the search\n # policy/allocator + operator mix + fidelity (+ Developer backend) per situation. Config-first:\n # every choice the Strategist makes is also a direct knob. Defaults to \"agent\" so the agent adapts\n # the search policy/operators/fidelity per situation; set \"off\" for static config-driven search.\n # \"agent\" = a tool-using Strategist that READS the run/data/sibling-runs/KB/memory (B1-guarded)\n # before deciding, instead of the single-shot \"llm\" call over aggregate stats.\n strategist_backend: str = \"agent\" # \"off\" | \"rule\" | \"llm\" | \"agent\" — default AGENTIC: the\n # Strategist READS the run/data/sibling-runs/KB/memory with tools before deciding, not a single-shot\n # call over aggregate stats. \"llm\" = the old non-agentic single-shot; \"rule\"/\"off\" = no LLM.\n strategist_every: int = Field(default=3, ge=1) # consult cadence (created nodes)\n # PART V (F1): concept CLASSIFIER re-tag + consolidation cadence, DECOUPLED from strategist_every. The\n # LLM concept map is heavier and slower-moving than a strategy consult, so it refreshes on its own\n # (sparser) interval. Researcher-authored idea.concepts still fold immediately at node_created — this\n # only paces the classifier-evidence + consolidation refresh (and the concept-coverage pivot snapshot).\n concept_retag_every: int = Field(default=30, ge=1)\n # === Confirmation & holdout ===========================================================\n # Multi-seed confirmation (I12, ADR-15): confirm the top-k under N seeds before\n # finishing. 0 disables (default). Only meaningful when eval has variance.\n confirm_top_k: int = 0\n confirm_seeds: int = 0\n # D1 seed-holdout discipline: the FIRST seed the confirm phase uses. Search evals run with the\n # implicit seed 0 (LOOPLAB_EVAL_SEED unset -> \"0\"), so a base of 1 keeps every confirm split\n # DISJOINT from anything the search optimized against — the confirm metric is then a\n # generalization signal, not a re-measurement (AIRA: selecting on a signal the search saw\n # overfits by 9-13 pp). Set 0 to restore the legacy overlapping seeds 0..N-1.\n confirm_seed_base: int = Field(default=1, ge=0)\n # D1 holdout-gated promotion (B6, Arbor-style): for host-graded tasks, reserve this fraction of\n # the held-out labels as a FINAL holdout partition the search never sees — every search/confirm\n # eval is scored on the remaining rows only, and at finish the val-top-k are re-scored on the\n # holdout partition (free: the predictions already exist). 0.0 = off (legacy full-label scoring).\n holdout_fraction: float = Field(default=0.25, ge=0.0, le=0.9)\n # Champion selection prefers the HOLDOUT metric among the nodes that have one (the val-top-k).\n # Recorded in run_started so replay applies the same rule; old logs fold to False (legacy pick).\n # The per-node val-holdout `generalization_gap` folds into the Trust panel either way.\n holdout_select: bool = True\n # How many val-leaders get a holdout evaluation at finish (host-graded tasks).\n holdout_top_k: int = Field(default=3, ge=1)\n # R1-c / Part IV — calibrated §12-verifier tie-break in best-selection. When a fresh node's robust\n # metric EXACTLY TIES an already-eligible node, the engine runs the §12 verifier (grounded on each\n # node's realized result, `selection_criteria`) and records a soundness score per node. The tie-break\n # then applies across the WHOLE selection path: the mean pick breaks a robust_metric tie by the score,\n # and — when `holdout_select` is on — the holdout-slot ranking prefers the sounder node so it isn't\n # denied a holdout eval, and the final holdout override breaks a holdout_metric tie by it too. Strictly\n # a TIE-BREAK — it can NEVER move a node ahead of a strictly-better metric (§21.7 advisory-never-\n # overrides). Best-effort: verification is LAZY (only fires on an actual tie) and bounded per cadence,\n # so a large/late tie stays on deterministic metric+id order unless one atomic treatment scores every\n # member. Recorded in run_started so\n # replay applies the same rule; old logs fold to False (legacy pick). Opt-in (default off); needs a\n # reachable LLM client. Enable only after validating calibration offline with `verifier.calibrate`.\n # See trust/verifier.py + core/fitness.py.\n select_verifier: bool = False\n # R1-d (§21.19): widen the verifier tie-break from EXACT-metric to a conservative STATISTICAL tie.\n # The band is capped by both the metric leader's SE and the pooled SE-of-difference, so challenger\n # variance cannot widen it and a significant difference is never a tie (§21.7). Off -> exact only.\n verifier_ci_tie: bool = False\n # Verifier sample count for `select_verifier` (the §12 repeated-sampling expectation). 3 tames the\n # single-shot variance; 1 = single-shot (cheaper, noisier).\n select_verifier_samples: int = Field(default=3, ge=1, le=32)\n # Budget (I13): hard wall-clock ceiling; the run aborts cleanly when exceeded.\n max_seconds: float | None = None\n # Eval-compute budget (#2): hard ceiling on cumulative time spent INSIDE evals (training\n # runs), separate from wall-clock. Survives resume (summed from the event log). The real\n # guard against a silent multi-hour sweep when an eval is a minutes-hours training run.\n max_eval_seconds: float | None = None\n # Cross-run memory (I19, ADR-10): if set, the best result of each run is stored as\n # a case here, and the cases become retrievable knowledge for future runs.\n memory_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"memory\"))\n # HITL (I21, ADR-11): pause for human approval of the final best before finishing.\n require_approval: bool = False\n # Diversity archive (I22): niche bucket width in parameter space.\n archive_resolution: float = 1.0\n # Coverage read-model (narrowing signal): at the strategist cadence, compute a breadth summary\n # (themes / param-niches / theme entropy / dominant-theme fraction) from the folded run, record\n # it as a `coverage_snapshot` audit event, and surface it into the Strategist's decision context.\n # Deterministic, cheap, and purely additive context — no search-behavior change on its own. On by\n # default (like the rest of the always-on situational context). See search/coverage.py.\n coverage_context: bool = True\n # PART IV Phase 2a live steering (§21.11/§21.13). When on, the `concept_retag_every` cadence (NOT\n # `strategist_every` — the producer gates on `_should_consult_concepts`, which uses the seed boundary\n # + `concept_retag_every`, default 30) records a\n # concept-graph coverage + uncovered-region snapshot (deterministic heuristic tagger over the\n # task-type skeleton) and, on an `explore` stance, the Researcher's novelty hint names the exact\n # uncovered regions (\"0 coverage in {negatives/external-mining, distillation} — go there\") instead\n # of the vague \"broaden\". ON by default in the product Settings (ce4a379), EngineOptions off: it\n # only enriches an already-mode-gated explore hint, never forces exploration, and no-ops for a task\n # with no curated concept skeleton. The snapshot is auditable, but its prompt cue can change future\n # candidate generation; it does not directly rewrite best-metric selection. See search/concept_graph.py.\n # DESIGN NOTE (2026-07-17 critique): now ON unconditionally, but a toy / non-ML task yields EMPTY concept\n # tags while still paying the per-node LLM concept cost (~10 calls/node with the full Part IV/V bundle).\n # Consider AUTO-QUIESCING: if the heuristic tagger returns empty for the first N nodes (no concept signal),\n # skip the LLM concept work for the run — keeps it on for ML tasks, free on toy/degenerate ones.\n concept_pivot: bool = True\n # PART IV Phase 2b — D3 graded novelty into the LIVE novelty gate (§21.4/§21.13). When on and the\n # task has a curated concept skeleton, the novelty gate first grades a proposal over the concept\n # graph: a level-4 \"same direction, DIFFERENT implementation\" or a level-5 \"re-opens a\n # wrongly-abandoned FAILED direction\" proposal is ALLOWED through unchanged — the flat LLM/semantic\n # dedup gate can't tell \"this DCL tweak\" from \"the whole DCL branch\" and would wrongly reject a\n # legitimate variant or never re-open a sound-but-killed direction (the node_63 archetype). Levels\n # 1/2/3 (identical / near-dup / prior-run) still fall through to the existing gate. Deterministic\n # (heuristic tagger, no LLM), audit event `novelty_graded`, replay-safe. ON by default in the product\n # Settings (ce4a379), EngineOptions off; no-ops for a task with no skeleton. See search/graded_novelty.py.\n graded_novelty: bool = True\n # PART IV Phase 2b — D7 capability-expansion forced-jump DIRECTIVE (§21.8/§21.13, issue #7). When on\n # and the concept-graph cadence detects action-space LOCK-IN (the search has stayed inside one D5\n # branch for a long consecutive streak) on an `explore` stance, the Researcher's novelty hint\n # ESCALATES from \"broaden\" to \"expand the ACTION SPACE — build the missing infra (external mining,\n # a new data pipeline, a different eval), do NOT swap another {locked} lever.\" The same\n # gate also stamps the proposal's authoritative operator as `expand`; that node competes normally\n # under SearchFitness and its separate operator yield is measured. It does not force a winner.\n # Reads the 2a concept-coverage snapshot, so it needs `concept_pivot` to record one. OPT-IN\n # (default off). See engine/proposal_cues.py + search/lock_in.py.\n capability_expansion: bool = False\n # PART IV cross-run Step 0 (§21.20.12/§21.20.13). Universal task-fingerprint tokenization: drop the\n # ASCII-only `[a-z0-9]` allowlist on goal keywords so a non-Latin goal (Russian, CJK, …) is NOT\n # silently dropped from its cross-run fingerprint (today such a goal keys only on kind/dir/metric and\n # never reaches SIMILAR-task priors/lessons/cases). Uses `[^\\W_]+`/`.casefold()` — same splitting as\n # before, any script. ON by default in the product Settings (ce4a379); the bare-library EngineOptions\n # default stays off, so a run pinned to it won't silently re-key a portfolio mid-flight. See engine/memory.py.\n fingerprint_universal: bool = True\n # PART IV cross-run Step 2 (§21.20). Fill graded-novelty's `prior_concepts` from the cross-run\n # ConceptCapsuleStore: at run end write a per-run concept capsule (the shipped `node_concepts` tags +\n # best outcome, keyed by task_fingerprint) to `memory_dir`; when a later SIMILAR run proposes an idea\n # whose concept was tried before, SURFACE the prior outcome as a `cross_run_prior` audit event (never\n # reject — D3 level 3). Audit-only, off the selection path; ON by default in the product Settings\n # (ce4a379), EngineOptions off. Needs a `memory_dir` to share capsules.\n # See engine/memory.py (ConceptCapsuleStore) + engine/novelty.py.\n # PREREQUISITES (this flag is NOT standalone): the WRITE needs per-run concept tags\n # (`node_concepts`, produced by `concept_pivot`/F1 tagging) — no tags => no capsule; the SURFACE path\n # runs inside the graded-novelty precheck, so it also needs `graded_novelty` on. With only this flag +\n # `memory_dir` it persists/surfaces nothing. Enable `concept_pivot` + `graded_novelty` alongside it.\n # A non-Latin (Russian/CJK) portfolio should ALSO set `fingerprint_universal`, else the capsule's goal\n # tokens are dropped and its fingerprint over-matches on kind/dir/metric alone (the novelty direction\n # gate narrows this, but goal-word similarity is still lost).\n cross_run_concepts: bool = True\n # PART V (B): run-base + node-DELTA concept authoring. When on, once the first evaluated node has\n # authored concepts the engine seeds RunState.run_base_concepts from them (a one-shot EV_RUN_CONCEPTS,\n # idempotent because it only fires while the base is empty), and the Researcher's proposal hint tells it\n # to author only `concepts_added`/`concepts_removed` vs the run base + its parent — minimal per-node\n # annotations that inherit down the DAG (a swap propagates). Off -> every node authors its full concept\n # set as before. Product-on; the bare EngineOptions mirror defaults off (test_options_divergence).\n concept_run_base: bool = True\n # PART IV cross-run Step 5 advisory (§21.20.5). Fold the bounded cross-run CONTEXT PACK — evidence-\n # grounded claims with BOTH support and counter-evidence (Step 4) + a portfolio-coverage line (Step 3) —\n # into the Researcher's proposal prompt, exactly like the E4 cross-run prior note. Advisory ONLY: it is\n # prompt-grounding: it can change the Researcher's later candidates, but does not directly rewrite\n # best-metric selection (§21.7). Reads `memory_dir` (lessons.jsonl + concept_capsules.jsonl); \"\" when\n # empty. ON by explicit experimental product choice; no frozen A/B validation is claimed. The\n # bare-library EngineOptions default stays off (engine/options.py, frozen in\n # test_options_divergence). See engine/proposal_cues.py.\n cross_run_advisory: bool = True\n # PART IV cross-run §21.20.13 (full CR of the lean fuzzy claim merge). Switch the claim read-model to the\n # SCOPE+POLARITY-safe STRUCTURED claim key (engine/claim_key.py): claims from different tasks never\n # merge, opposite polarity (\"X helps\" vs \"X never helps\") is surfaced as a CONTRADICTION instead of being\n # collapsed, paraphrase/inflection variants group by exact structured key (no transitive over-merge), and\n # operator governance is scope-precise (a decision in task A cannot reach a same-worded claim in task B).\n # Affects the `cross_run_advisory` context pack only; ON by default in the product Settings (ce4a379);\n # the bare-library EngineOptions default stays off (engine/options.py). See engine/claims.py.\n cross_run_structured_claims: bool = True\n # PART IV cross-run §22.4 (AGENTIC taxonomy steward). At finalize, when an LLM client is available, let\n # the concept steward (engine/concept_steward.py) review the freshly-updated portfolio concept graph and\n # PROPOSE a curation — merge duplicate slugs / split conflated ones / purge noise. Proposals are LOGGED to\n # `concept_curation_log.jsonl` for operator ratification (via the serve/CLI governance surface); the LLM\n # never mutates fold state. The calls run synchronously during finalize, so they add latency and may add\n # paid inference; a caught steward failure does not prevent terminal completion or trigger a retry.\n # ON by explicit experimental product choice when `memory_dir` + an LLM backend are present; the\n # bare-library EngineOptions default stays off (engine/options.py). See engine/lessons.py.\n cross_run_curation: bool = True\n # Deprecated compatibility flag. Steward output is untrusted and finalize is proposal-only regardless\n # of this value; retained so old snapshots still validate and logs can disclose that auto was requested.\n cross_run_curation_auto: bool = False\n # Role backend (ADR-7/14): \"toy\" (offline optimizer) | \"llm\" (live model). Validated in\n # `_check_trust_gate` alongside the other enum-ish strings — every consumer tests `== \"llm\"`\n # exactly (cli/__init__.py, adapters/tasks.py), so an untyped `--set`/file/env typo (or a mis-cased\n # \"LLM\") used to fall through to the OFFLINE toy roles and quietly produce a run that never called\n # the model. Left as `str`, not `Literal`, so old snapshots still validate through the same path.\n backend: str = \"toy\"\n # Developer backend (ADR-7): \"default\" (templated/LLM from the task) or an external\n # CLI coding agent: \"opencode\" | \"aider\" | \"goose\" | \"continue\".\n developer_backend: str = \"default\"\n # C2 best-of-N: generate N candidate implementations per node and keep the best by an\n # execution-free reward (static validity + metric-print). 1 = off. In-house LLM developer only\n # (not external agents — ADR-7 cost rule). The top SWE-bench reliability lever for weak models.\n best_of_n: int = 1\n # D10 list-wise best-of-N selection: when the top static-scorers TIE, break the tie with a\n # comparative LLM selection over the candidates presented together (+~3 pts vs pointwise on\n # GAIA, arXiv:2506.12928). The static score stays the primary filter — the LLM is a weak\n # comparative prior, never the sole oracle. ON by default (no-op when best_of_n==1).\n best_of_n_listwise: bool = True\n # === Foresight / predict-before-execute ===============================================\n # FOREAGENT predict-before-execute (arXiv:2601.05930): use the LLM as an IMPLICIT WORLD MODEL to\n # predict — WITHOUT executing — which candidate / hypothesis will score best, primed with a\n # Verified Data Analysis Report (data profile / brief) + the accumulated experiment memory. Master\n # switch, ON by default; a no-op wherever there is nothing to rank (best_of_n==1 and\n # foresight_panel==1) or no LLM client. See `looplab/search/foresight.py`.\n foresight: bool = True\n # Hypothesis foresight breadth: generate K candidate ideas per proposal and keep the one PREDICTED\n # best pre-execution by the world model — it ranks the STRUCTURAL / text ideas the numeric k-NN\n # surrogate (researcher_panel) is blind to, primed with the data profile + memory (the synergy).\n # >1 enables it (LLM backend only; needs `foresight` on); 2 = on by default at modest cost, 1 = off.\n foresight_panel: int = 2\n # AGENTIC foresight: run the ranking (hypothesis-board prioritization + K-idea pick) as a TOOL-USING\n # loop that can pull actual experiment results / data facts before deciding, instead of a one-shot\n # prediction from a pre-baked report. ON by default (needs foresight + a client; falls back to the\n # one-shot predictor on any hiccup). A few extra LLM calls per proposal; set False for the cheaper\n # single-call ranker.\n foresight_agentic: bool = True\n # §1 foresight confidence gate: the minimum predicted confidence at which a predict-before-execute\n # pick is ACTED ON. Below it the ranker abstains — the K-idea panel falls back to the first\n # proposal, best-of-N falls through to the D10 tie-break — instead of committing a low-confidence\n # choice. 0.0 (default) = OFF (act on every pick, the historical behavior); raise toward ~0.5 to\n # make the world model defer when it isn't sure. Pairs with the foresight track record (§1) the\n # predictor is now primed with. Range 0.0-1.0.\n foresight_min_confidence: float = 0.0\n # PART IV Phase 2c — replace the world model's SELF-REPORTED confidence (measured Pearson≈0 with the\n # realized outcome, §21.12) with a CALIBRATED §12-verifier score. When on and a client is available,\n # after the K-idea ranker picks the predicted-best candidate the §12 verifier scores it (grounded +\n # repeated + criteria-decomposed: `foresight_criteria` — likely to improve the objective, and\n # sound/feasible) and THAT calibrated score becomes the confidence the `foresight_min_confidence` gate\n # and the telemetry use. Degrades to the self-reported confidence without a client or on any verifier\n # error (the telemetry records which source was used). ON by default in the product Settings (ce4a379),\n # EngineOptions off; a few extra LLM calls per acted-on proposal. See looplab/trust/verifier.py + search/foresight.py.\n # REVISIT (cost/quality, not yet resolved): a live classification run measured ~10 LLM calls & ~3.8 min/node\n # on deepseek-v4-flash with the full Part IV/V default bundle; the calibrated verifier is part of that per-node\n # tax. Kept ON deliberately — it raises decision quality (self-reported foresight confidence has ≈0 Pearson\n # with realized outcome), and lowering quality to save calls is the wrong trade. TODO: run a frozen quality/cost\n # A/B (verifier-on vs -off) to quantify the recall@objective gain per extra call before considering a default flip.\n foresight_verify: bool = True\n # Verifier sample count for `foresight_verify` (the §12 repeated-sampling expectation on a no-logprob\n # backend). 3 tames the single-shot variance §21.12 measured; 1 = single-shot (cheaper, noisier).\n # RESUME-critical: this value is persisted in config.snapshot.json, so a hard `le` schema bound would\n # make an old run started with a larger LOOPLAB_FORESIGHT_VERIFY_SAMPLES un-resumable (Settings(**snapshot)\n # would raise on `looplab resume` / finalize-recovery / serve replay). CLAMP into [1, MAX] via a validator\n # instead of rejecting — the runtime cap is preserved without breaking the snapshots/env-compat rule.\n foresight_verify_samples: int = 3\n\n @field_validator(\"foresight_verify_samples\")\n @classmethod\n def _clamp_foresight_verify_samples(cls, v: int) -> int:\n # Coerce a persisted/env value into [1, MAX] rather than reject (see field note above).\n try:\n v = int(v)\n except (TypeError, ValueError):\n return 3\n return max(1, min(v, MAX_FORESIGHT_VERIFY_SAMPLES))\n\n # T7 LLM response cache: serve an identical DETERMINISTIC (temperature 0) request from an\n # in-process content-addressed cache instead of re-hitting the model — cuts cost on\n # retry/panel/verify flows. NEVER caches sampling calls (temp>0: best-of-N / panel / novelty\n # re-propose must vary). Off by default (most role calls run at temperature>0 anyway).\n llm_cache: bool = False\n agent_cmd: str | None = None # override the agent's launcher (path / wrapper)\n # External-agent validation (ADR-7): wrap a CLI-agent Developer with a validator that\n # audits each output (no-op/syntax/crash/timeout), retries with feedback, and falls\n # back to the in-house LLM Developer. Off -> use the raw agent output unchecked.\n validate_agent: bool = True\n agent_max_retries: int = 1 # re-prompts of the agent on an invalid result\n # Patch-gated multi-file agent (ADR-7 Rule 3): run the CLI agent in a git worktree and\n # accept only edits whose paths match `agent_surface` (reject-not-strip out-of-surface\n # touches). Lets the agent create helper modules, not just solution.py. Degrades to\n # whole-file readback if git is unavailable.\n agent_patch_gate: bool = True\n agent_surface: list[str] = [\"*.py\"] # edit-surface allow-list (globs)\n # Trust policy for an agent-authored eval/metric adapter (RepoTask onboarding, Phase 3):\n # \"ratify_freeze\" waits for human approval, \"autonomous\" accepts the proposed spec without that\n # gate, and \"ratify_freeze_drift\" also enforces the configured independent drift cross-check.\n eval_trust_mode: str = \"ratify_freeze\"\n # RepoTask node seeding policy (run-wide fallback; a task/editable `seed_mode` overrides):\n # \"auto\" (default) copies git-tracked files when the editable is a git repo (so a tree bloated\n # with untracked artifacts isn't deep-copied per node), else a full copy; \"tracked\" forces\n # code-only; \"all\" forces a full recursive copy (legacy). Genesis authors per-task from the\n # user's words; this is the global default when unspecified.\n seed_mode: str = \"auto\"\n llm_model: str = \"qwen3:8b\"\n # === LLM / transport ==================================================================\n llm_base_url: str = \"http://localhost:11434/v1\" # Ollama OpenAI-compatible endpoint\n llm_temperature: float = 0.6\n # H3 per-role model presets (5090 recipe): optionally run the Researcher and Developer on\n # DIFFERENT models/endpoints (e.g. Developer=Qwen3-Coder-30B for code, a fast model for breadth).\n # Blank = use the shared llm_model/llm_base_url. Generalizes the role-backend swap to per-role.\n researcher_model: str | None = None\n developer_model: str | None = None\n researcher_base_url: str | None = None\n developer_base_url: str | None = None\n # Per-role sampling temperature (§4.1): the Researcher wants breadth (higher temp = more diverse\n # ideas), the Developer wants determinism (lower temp = fewer syntax slips), the Strategist a\n # steady hand. Each overrides the shared `llm_temperature` for that role only; None = use the\n # shared value (byte-identical to before). Flat + nullable, mirroring the per-role model fields.\n researcher_temperature: float | None = None\n developer_temperature: float | None = None\n strategist_temperature: float | None = None\n # The Strategist had per-role TEMPERATURE but no model/endpoint of its own, so in split mode it\n # always ran on the shared llm_model however the other roles were pointed. These close that gap\n # with the same names/shape as the pair above; blank = its profile, else the shared value.\n strategist_model: str | None = None\n strategist_base_url: str | None = None\n # === Connection profiles (multi-provider) =============================================\n # A \"profile\" is a NAMED connection: {model, base_url, temperature, api_key_env, provider}. Roles\n # point at one by name through `role_profiles`; `llm_profile` is the default for every role that\n # doesn't. All three are EMPTY by default and the resolver short-circuits to the shared\n # llm_model/llm_base_url/llm_api_key path when they are — an operator running one model never\n # needs to know the word \"profile\".\n #\n # A profile stores `api_key_env`, the NAME of an environment variable, never the key itself. That\n # is ADR-11's \"a secret is a reference, not a value\" turned from a convention into a data type:\n # the whole profile map is then safe to write into config.snapshot.json, hand back over HTTP and\n # render into LOOPLAB_* — there is nothing in it to mask. It is also what makes per-role\n # CREDENTIALS expressible at all: two roles on one provider can carry different keys, which a\n # key-per-endpoint map could not say.\n llm_profiles: dict[str, dict] = Field(default_factory=dict)\n llm_profile: str | None = None\n role_profiles: dict[str, str] = Field(default_factory=dict)\n # Unified self-driving agent: one LLM identity that plays Researcher + Developer (+ Strategist)\n # across pipeline stages, choosing its own model/toolset per stage. ON by default — the agent\n # drives the loop (incl. crash triage: repair/abandon/reject_idea) and, via\n # `agent_drives_actions`, picks the next macro action within a pure legal-action gate. Set both\n # to False for the legacy byte-identical split-role behavior. (No-op unless backend=\"llm\".)\n unified_agent: bool = True\n agent_drives_actions: bool = True\n # Layer 3 Card queue ownership. False preserves the existing policy/unified-pilot action path.\n # True is pinned in run_started because changing the selector on resume would mix two search\n # treatments in one run. When both this and agent_drives_actions are true, Card selection wins.\n card_driven_selection: bool = False\n # Layer 5 bounded speculative Card buffer. Zero is a hard OFF switch and preserves the\n # historical alternating build/eval spine; positive values are pinned by run_started so a\n # resume cannot silently mix search treatments after a snapshot/default edit.\n speculation_depth: int = Field(default=0, ge=0, le=64)\n # Local evidence receipt produced by ``looplab speculation-gate``. This is deliberately absent\n # from the curated Settings UI: positive-depth execution is a calibrated rollout decision, not a\n # casual tuning knob. The Engine validates the receipt against the current scorer,\n # implementation, GPU inventory and raw paired-run evidence before it enables speculation.\n speculation_gate_receipt: str | None = Field(default=None, max_length=4096)\n # Per-stage model/endpoint overrides for the unified agent. Recognized keys (see\n # tasks.build_unified_agent): `propose` (researcher), `implement`/`repair` (developer),\n # `strategy`, `pilot`. Unlisted keys are ignored. Empty = the per-role researcher_*/developer_*\n # models, then the shared llm_model. Explicit map wins. No-op unless `unified_agent` is true.\n agent_stage_models: dict = {}\n agent_stage_base_urls: dict = {}\n llm_parser: str = \"tool_call\"\n # Reasoning/thinking toggle sent IN the request (providers differ — see `llm.reasoning_body`):\n # Qwen3 on vLLM/SGLang -> chat_template_kwargs.enable_thinking (bool)\n # OpenAI/Ollama-v1/DeepSeek -> reasoning_effort (low|medium|high|none)\n # `llm_reasoning`: \"\" = send nothing (server default — current behavior); off = actively disable;\n # on = enable at default depth; low|medium|high = enable at that effort.\n # Defaults to \"high\" so the agent reasons before proposing/repairing/triaging (better code, fewer\n # crashes); set \"\" to send nothing (server default) or \"off\" to disable. The model's chain-of-thought\n # is CAPTURED either way (reasoning_content field or inline <think>). NOTE: to get a SEPARATE\n # reasoning_content field from SGLang/vLLM, the server also needs `--reasoning-parser qwen3`.\n llm_reasoning: str = \"high\"\n llm_reasoning_style: str = \"auto\" # auto | qwen | effort | none — how to shape the request param\n llm_reasoning_extra: dict = {} # raw fields merged into the body (escape hatch, e.g. Anthropic thinking)\n # H1: drive structured calls with the endpoint's constrained decoding (vLLM/SGLang guided_json +\n # response_format json_schema) so weak models can't emit invalid JSON. Off for Ollama (default).\n llm_guided_json: bool = False\n # Stream every LLM request (SSE) and reassemble it, so the request `timeout` is an INTER-TOKEN\n # idle timeout rather than a whole-request deadline: a long-but-alive generation is never cut off\n # (tokens keep resetting the timer), while a genuinely STALLED endpoint (no token for `timeout` s)\n # is detected and retried on a fresh connection — the fix for silent multi-minute hangs. Set False\n # to use one blocking read (old per-op timeout semantics) if an endpoint streams badly.\n llm_stream: bool = True\n # LLM IDLE timeout (seconds). Stream mode: the INTER-TOKEN stall limit — a steady generation is\n # never cut off, only a silent endpoint. Non-stream: bounds the whole read. Raise for endpoints\n # with a long prefill on huge prompts; lower to fail fast on a flaky shared endpoint.\n llm_timeout: float = Field(default=180.0, gt=0)\n # First-byte (response-headers) window for STREAM attempts, seconds: an SSE response sends its\n # headers on admission, so no-headers ≈ a black-holed request — fail over to a fresh connection\n # fast instead of waiting the full idle timeout. Clamped to llm_timeout. Non-stream attempts are\n # NOT bounded by this (their headers arrive only after the whole generation). The default mirrors\n # `DEFAULT_HEADER_TIMEOUT_S` in looplab/core/llm.py is the single source (imported above).\n llm_header_timeout: float = Field(default=DEFAULT_HEADER_TIMEOUT_S, gt=0)\n # Honour HTTP(S)_PROXY / NO_PROXY / SSL_CERT_FILE env vars for LLM calls. Default False: the\n # transport connects DIRECTLY (many internal/OLAP endpoints are reachable only by bypassing an\n # ambient proxy — a picked-up proxy yields \"connection refused\"). Set true when the endpoint is\n # reachable ONLY via a corporate proxy or needs a custom CA bundle from the environment.\n llm_trust_env: bool = False\n # H4/C2: compact the growing agentic tool-call history once it exceeds this many chars (auto_summary\n # LLM-summarizes the stale middle; else middle-truncate). Sized to the model's real context window so\n # files read earlier STAY in context instead of being compacted away and re-read: ~1,000,000 chars ≈\n # 250k tokens (≈4 chars/token), matching the configured deepseek-v4-flash (≥200k tokens confirmed).\n # The old 120k-char (~30k-token) default was ~8× too aggressive and drove a read-thrash loop. 0 = off.\n context_budget_chars: int = 1_000_000\n # M5: the Researcher's always-on experiments digest budget (chars). 0 = AUTO — scales with the\n # run size (60 chars/node, bounded [1200, 6000]) instead of one flat cap for an 8-node toy run\n # and a 200-node benchmark run alike. Set a positive value to pin it.\n digest_char_cap: int = Field(default=0, ge=0)\n # D11 compression model slot (open_deep_research's four-slot pattern): a dedicated CHEAP model\n # for agent-history summarization (C2 auto-summary), so compression doesn't pay the main\n # model's price. Blank = use each loop's own model (legacy). `compressor_base_url` blank =\n # the shared llm_base_url.\n compressor_model: str | None = None\n compressor_base_url: str | None = None\n # === Agentic tool-loop ================================================================\n # Agentic tool-loop limits — apply to EVERY tool-using agent (the LLM Researcher, the unified\n # agent's pilot + crash-triage, Deep-Research, the run-chat Boss, the genesis repo-scout, and the\n # cross-run report synthesizer). The loop lets the model call read-only tools across turns before\n # emitting its structured result. Both default to UNLIMITED so the agent takes as many turns / as\n # long as it needs — it is never cut off mid-reasoning; set a positive value here (or per-run, or\n # in the UI settings) to bound latency/cost. These used to be hardcoded per call site (e.g. the\n # boss at max_turns=3 / 45s), which silently dropped a slow reasoning model to a no-op reply.\n agent_max_turns: int = 0 # max tool turns before the emit is forced (0 = unlimited)\n agent_time_budget_s: float = 0.0 # wall-clock ceiling across the loop's turns (0 = no cap)\n # G · Soft convergence SAFETY NET (not a research cap). Two thresholds on the agent's tool-turn count:\n # at `agent_emit_after` the loop NUDGES the agent once (\"you've investigated enough — emit now\"); at\n # `agent_emit_force` it FORCES the emit from what was gathered. Set GENEROUSLY — a focused researcher\n # may legitimately read many files/experiments before it can propose a grounded idea; these only catch\n # a genuine runaway that keeps issuing DIFFERENT calls forever (which stuck-detection, keyed on\n # repeats, misses). NB: the old 499-read runaway (node 63) was 79% REDUNDANT re-reads caused by a\n # broken read_file — fixing read_file (pagination) is the real cure; this is just insurance. 0 = off.\n agent_emit_after: int = 300\n agent_emit_force: int = 500\n # B1 · No-progress / stuck detection — the safety net that makes \"unlimited turns\" safe. The\n # turn/time ceilings above are only BACKSTOPS; this is what actually stops a runaway loop, on the\n # cheapest signal: when the model repeats the SAME tool call (or ping-pongs between two, or keeps\n # hitting the SAME error) with no progress, the loop forces the final emit and finishes. ON by\n # default; reading DIFFERENT files or running ONE long command never trips it. (OpenHands-style.)\n agent_stuck_detection: bool = True\n agent_stuck_repeat: int = 4 # identical calls in a row that count as \"stuck\" (>=2)\n agent_stuck_alternate: int = 4 # ping-pong cycles between two calls that count as \"stuck\" (>=2)\n # C1 · Self-plan (TodoWrite-style): expose an `update_plan` tool so a long-running agent keeps its\n # OWN working TODO and re-surfaces it every `agent_plan_reinject_every` turns — keeps the goal in\n # view across a long loop so the agent \"writes its own context\" (Claude Code style). ON by default.\n agent_self_plan: bool = True\n agent_plan_reinject_every: int = 5\n # C2 · Auto-summary (ON by default): when the tool-loop history grows long, LLM-summarize the\n # stale middle instead of only middle-truncating it. The trigger is `context_budget_chars` if set,\n # else a built-in high-water mark (context_budget.DEFAULT_SUMMARY_CHARS ≈ 120k chars) so short\n # loops are untouched. Falls back to truncation on any summarizer error; one extra model call per\n # over-budget turn.\n agent_auto_summary: bool = True\n # C4 · Repo-developer plan decomposition. A big feature attempted in ONE developer session made a\n # non-converging reasoning model loop indefinitely — it kept writing + exploring without ever\n # emitting `done` (10k+ LLM calls / hours; agentic stuck-detection can't catch VARIED exploration,\n # only literal repeats). Fix: the developer first proposes an ordered plan of ATOMIC steps; a\n # multi-step plan is executed step-by-step, each in a FRESH bounded session with only that step's\n # scope + the files accumulated so far, syntax-validated per write. Every session stays\n # small-context and convergent. ON by default for repo tasks (a 1-step plan == the old single pass).\n developer_plan_decompose: bool = True\n developer_plan_min_steps: int = 2 # a proposed plan with >= this many steps runs step-by-step\n developer_plan_max_steps: int = 8 # cap on plan length (a runaway planner can't spawn 100 steps)\n # Hard per-session backstop for the repo developer — the ONE write-heavy agent that can run away\n # even with stuck-detection on (varied writes/reads never trip the repeat/ping-pong signal). Bounds\n # the plan phase, EACH step, and the single-session fallback. Unlike the global agent_* limits\n # (0 = unlimited, meant for read-only agents), the developer always gets a FINITE ceiling so a\n # model that never emits `done` fails cleanly with the code it wrote so far, instead of looping.\n developer_session_max_turns: int = 500\n developer_session_time_budget_s: float = 1200.0 # 20 min wall-clock per developer session\n # Phase-handoff summaries. Each LLM phase in a node build (Researcher·propose → Developer·stages →\n # plan → implement) ends with ONE extra LLM call that distills its transcript — the repo structure\n # it mapped, files/data confirmed, decisions made — into a brief injected into the NEXT phase (even\n # across the role boundary). The next phase TRUSTS that instead of re-reading the same files, which\n # cuts the biggest source of tool-call thrash (stages/plan/implement each re-exploring the repo).\n # One summary call per phase in exchange for many fewer read calls downstream. ON by default.\n phase_handoff_summary: bool = True\n # Observability (ADR-17): capture each LLM call's full prompt + completion into the active\n # span (spans.jsonl) so the UI can show exactly what the model read and wrote per node.\n # Diagnostics only — `replay.fold` never reads spans. Default on for local single-user; set\n # LOOPLAB_TRACE_LLM_IO=0 to suppress (e.g. to keep spans.jsonl small or avoid storing prompts).\n trace_llm_io: bool = True\n # Run-introspection tools (ON by default): make the LLM Researcher (and DeepResearcher) a\n # tool-using agent that can read its OWN experiments (list/read/find-analogous/themes/code) and\n # the task data (schema/profile/asset) mid-loop, instead of seeing only best+parent. Advisory —\n # never changes best-selection. Off = the legacy single-shot Researcher (richer digest still added).\n researcher_tools: bool = True\n # Cross-run introspection: give the agentic Researcher / pilot read-only tools to look at\n # SIBLING runs (same task_id, same run-root) — list them, read an experiment / its code, and\n # find analogous configs across runs — so a run can build on what neighbouring runs already\n # learned instead of rediscovering it. Advisory; never changes best-selection. Needs the run's\n # own dir wired through (no-op for unit-built roles). Off = the legacy single-run view only.\n cross_run_tools: bool = True\n # Read-only access to EVERY run on this machine ACROSS ALL TASKS (not just same-task siblings):\n # the Developer/Researcher get list_all_runs + read_run_code + read_run_experiment so they can read\n # the code + result of ANY past experiment anywhere and reuse an approach. Broader than\n # `cross_run_tools` (same-task only); the agent decides when a foreign run is relevant. Advisory;\n # never changes best-selection. Needs the run's own dir wired through (no-op for unit-built roles).\n all_runs_tools: bool = True\n # PART V §22 — read-only CROSS-RUN KNOWLEDGE tool for the reasoning roles (Researcher, Strategist,\n # deep-research, and a Developer-scoped variant). Adds `cross_run_prior_attempts` / `cross_run_claims`\n # / `cross_run_atlas` over the §21.20 read-models (concept overview + claim assessments + atlas), read\n # from `memory_dir` (lessons.jsonl + concept_capsules.jsonl). Read-only is a storage-authority\n # boundary, not a behavioral no-op: retrieved content can steer later proposals, and a tool-using\n # reasoning loop can consume additional model calls. The tools never mutate cross-run truth (facts\n # are engine-written, verdicts operator-ratified, §22.4) or directly select a winner. ON by explicit\n # experimental product choice, EngineOptions off; stores only exist once Part-IV features have run.\n cross_run_read_tools: bool = True\n # Agentic retrieval (ADR-16): if set, the LLM Researcher gets grep/kb_search/read\n # tools over this directory of markdown notes and chooses when to use them.\n knowledge_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"knowledge\"))\n # T4 real embeddings: model id for an OpenAI-compatible `/embeddings` endpoint used for\n # kb_search / case retrieval. Blank (default) = the dependency-free lexical `hash_embed` (today's\n # behavior). Set e.g. \"nomic-embed-text\" (Ollama) or \"text-embedding-3-small\" for SEMANTIC\n # retrieval; a misconfigured/offline endpoint degrades back to hash_embed at call time (never\n # crashes a run). `embed_base_url` blank = reuse `llm_base_url`; the shared `llm_api_key` is used.\n embed_model: str | None = None\n embed_base_url: str | None = None\n # Harmonic memory (Memora, ICML'26 — idea import). When ON, cross-run cases and knowledge notes are\n # indexed by a short ABSTRACTION + cue ANCHORS instead of their raw text, near-duplicate memories are\n # CONSOLIDATED into one entry under a matching abstraction, and `kb_search`/case retrieval EXPAND\n # through the top hits' anchors to surface related-but-not-similar memories. ON by default. Set\n # `memora=false` to restore the pre-Memora raw-text index. Never a source of truth — abstractions\n # live only in the derived, rebuildable retrieval index (never the event log or canonical cases.jsonl).\n memora: bool = True\n # Write abstractions with the wired chat model (richer than the deterministic lexical fallback). ON\n # by default; results are CACHED by content hash (see `memora_cache`) so a re-built index doesn't\n # re-call the model on unchanged notes/cases, and any endpoint failure degrades to lexical at call\n # time — so an offline box just gets lexical abstractions, never a crash. Set false to force lexical.\n memora_llm: bool = True\n # === Cross-run memory / Memora ========================================================\n memora_anchors: int = 6 # max cue anchors kept per memory\n # Cosine similarity at/above which a new case is CONSOLIDATED into an existing one (same evolving\n # topic) rather than stored as a separate entry. Only used when `memora` is on.\n memora_consolidate_threshold: float = 0.86\n # Where the LLM-abstraction cache lives (JSON). Blank (default) derives it from `memory_dir`\n # (`<memory_dir>/memora_cache.json`) or else `knowledge_dir` (`<knowledge_dir>/.memora_cache.json`);\n # with neither set the cache is in-memory only (per process). No-op unless `memora_llm` is on.\n memora_cache: str | None = None\n # E3 literature-grounded ideation (network-OPTIONAL): give the agentic Researcher an arXiv search\n # tool to ground ideas in real techniques. Off by default (egress is unreliable on some boxes);\n # fails gracefully if the network is blocked.\n literature_search: bool = False\n # Deep-Research stage (network-OPTIONAL): give the DeepResearcher a general web search/fetch\n # tool (DuckDuckGo) on top of arXiv to read across results + the web before steering the next\n # batch. Off by default (egress is unreliable on some boxes); fails gracefully when blocked.\n web_search: bool = False\n # Cadence for the Deep-Research stage: run it automatically every N created nodes. Zero disables\n # only the cadence; manual `deep_research` and Strategist `request_research` triggers still fire.\n # Default 3: deep research analyzes the accumulating results and steers the next batch (its\n # directions become hints + open hypotheses); paired with `concurrent_research` it overlaps the\n # think with the GPU-bound eval, so the cadence costs LLM tokens but no wall-clock on the search.\n deep_research_every: int = 3\n # Overlap a DUE deep-research \"think\" with the GPU-bound eval instead of running it in its own\n # serial step (the agent is otherwise idle while a node trains). research() computes from a state\n # snapshot; the joined background task records only allowlisted, order-tolerant\n # research/hint/hypothesis events as soon as the memo finishes. ON by default: the LLM is typically\n # remote (no GPU contention with eval), so overlapping hides latency behind long training.\n concurrent_research: bool = True\n # Concurrent-research REPEAT — don't idle a multi-DAY eval. The overlapped \"think\" fires ONCE per\n # window by default; ON here, it RE-RUNS on an adaptive time cadence for the WHOLE eval window\n # (a two-day training must not leave the reasoning agents idle after one memo). Self-paced: it\n # only records a memo whose CONTENT is new (identical re-runs are skipped, so the log/board don't\n # bloat) and backs off geometrically as the analysis converges, capped so it always re-checks.\n # The appended records never rewrite the current metric champion, but their hints/open hypotheses\n # deliberately steer later proposals and replay reconstructs that advice. The library default is\n # one-shot (== today, byte-identical); this product default turns the repetition on.\n concurrent_research_repeat: bool = True\n # Base cadence (seconds) between repeated concurrent-research passes. Research is EXPENSIVE\n # (multi-turn LLM + web/arXiv), so this is a FLOOR, not a ceiling: the effective cadence is\n # max(this, ~5% of the per-experiment time budget) — a two-day eval is re-researched about hourly,\n # a short eval not at all (the first tick outlasts it). Only matters when the repeat is on.\n concurrent_research_interval_s: float = Field(default=1800.0, gt=0)\n # Per-eval-window backstop on repeated-research LLM calls (0 = cadence-only, no cap). The adaptive\n # cadence + convergence backoff are the primary budget control; this bounds a pathological\n # never-converging window. Past it the loop stops calling the LLM (the health monitor still runs).\n concurrent_research_max_calls: int = Field(default=40, ge=0)\n # Concurrent hypothesis-board CONSOLIDATION during the eval window. Repeated research keeps ADDING\n # near-duplicate directions to the open board (each memo registers up to 5 as open hypotheses), so a\n # long eval's board bloats before the between-nodes merge runs. This dedups/merges it on the SAME\n # background loop so the board stays clean for the next proposal. In Card-driven mode the same merge\n # changes ownership/readiness, so it is deferred to the joined main-task cadence instead. Needs the\n # reflect client + `track_hypotheses`.\n # Only active while `concurrent_research` runs the overlap loop. Library default off (== today).\n concurrent_consolidate: bool = True\n # Cadence for the agent-authored run report: regenerate the conclusion-first narrative every N\n # created nodes (0 = off; it still regenerates on a manual `report_refresh` from the UI). The\n # deterministic report always renders from the node set regardless of this knob.\n report_every: int = 3\n # Agent Skills (I18, ADR-9): dir of SKILL.md the Researcher can list/load as tools.\n skills_dir: str | None = None\n # Prompt store (I18, ADR-8): dir of editable, hot-reloaded role prompt .md files.\n prompt_dir: str | None = None\n # API key as a reference, never serialized as a value (ADR-11). Local servers\n # ignore it; default to a placeholder.\n llm_api_key: SecretStr | None = None\n\n @model_validator(mode=\"before\")\n @classmethod\n def _reject_bool_for_numeric_fields(cls, data):\n \"\"\"A JSON boolean is a TYPE ERROR for a numeric setting, not the number 1.\n\n Pydantic runs in lax mode here (`model_config` sets no `strict`), and `isinstance(True, int)`\n is True — so `{\"max_nodes\": true}` from a UI/API type slip validated cleanly as a budget of\n ONE node, and `{\"eval_parallel\": true}` collapsed the width to one. The run then looked\n configured and simply did almost nothing, with no 422 and nothing in the snapshot to show why.\n Rejecting at the boundary covers every numeric field at once, including the many that carry no\n `Field(ge=...)` bound of their own to trip over.\n \"\"\"\n if not isinstance(data, dict):\n return data\n for key, val in data.items():\n if type(val) is bool and _is_numeric_annotation(\n getattr(cls.model_fields.get(key), \"annotation\", None)):\n raise ValueError(f\"{key} must be a number, not a boolean (got {val!r})\")\n return data\n\n @model_validator(mode=\"before\")\n @classmethod\n def _apply_profile(cls, data):\n \"\"\"Expand `profile` into a bundle of setting defaults BEFORE validation.\n\n pydantic-settings hands this validator the fully-merged input from ALL explicit sources\n (init kwargs + LOOPLAB_* env + .env), with fields the user left unset simply ABSENT (they\n fall to field defaults afterward). So a profile value is applied only when its key is not\n already present — i.e. any explicit file/CLI/env setting wins over the profile, and the\n profile wins over the bare field default. This makes `thorough` a true convenience preset,\n never an override of what the user asked for.\"\"\"\n if not isinstance(data, dict):\n return data\n prof = data.get(\"profile\", \"default\")\n # env may deliver the key as \"profile\" (env_prefix stripped) — normalize to a str\n prof = str(prof).strip().lower() if prof is not None else \"default\"\n overrides = PROFILES.get(prof)\n if overrides is None:\n raise ValueError(f\"unknown profile {prof!r}; known: {sorted(PROFILES)}\")\n for key, val in overrides.items():\n if key not in data: # explicit source (file/CLI/env) always wins\n data[key] = val\n return data\n\n @model_validator(mode=\"after\")\n def _check_trust_gate(self):\n if self.trust_gate not in (\"audit\", \"gate\", \"block\"):\n raise ValueError(\n f\"trust_gate must be audit|gate|block, got {self.trust_gate!r}\")\n if self.merge_mode not in (\"auto\", \"mean\", \"ensemble\"):\n raise ValueError(\n f\"merge_mode must be auto|mean|ensemble, got {self.merge_mode!r}\")\n # novelty_mode drives the dedup/novelty gate; an out-of-set value (e.g. a mis-cased\n # LOOPLAB_NOVELTY_MODE=LLM, or \"on\") used to fall through _apply_novelty_gate silently as a\n # NO-OP — turning the gate off with no diagnostic. Fail loudly at config time like trust_gate/\n # merge_mode do. (Other enum-ish string fields — seed_mode/eval_trust_mode/strategist_backend —\n # share the pattern; left unvalidated here only because their full value sets are less settled.)\n if self.novelty_mode not in (\"off\", \"algo\", \"llm\"):\n raise ValueError(\n f\"novelty_mode must be off|algo|llm, got {self.novelty_mode!r}\")\n # The remaining enum-ish string fields (arch-review §5 P3): a typo used to be accepted at\n # construction and only fail-safe/later-loud downstream (e.g. a mis-cased strategist_backend\n # silently ran the default). Fail loudly here like the fields above.\n if self.strategist_backend not in (\"off\", \"rule\", \"llm\", \"agent\"):\n raise ValueError(\n f\"strategist_backend must be off|rule|llm|agent, got {self.strategist_backend!r}\")\n if self.eval_trust_mode not in (\"ratify_freeze\", \"autonomous\", \"ratify_freeze_drift\"):\n raise ValueError(\n \"eval_trust_mode must be ratify_freeze|autonomous|ratify_freeze_drift, \"\n f\"got {self.eval_trust_mode!r}\")\n if self.seed_mode not in (\"auto\", \"tracked\", \"all\"):\n raise ValueError(f\"seed_mode must be auto|tracked|all, got {self.seed_mode!r}\")\n if self.backend not in (\"toy\", \"llm\"):\n raise ValueError(f\"backend must be toy|llm, got {self.backend!r}\")\n self._check_llm_profiles()\n return self\n\n def _check_llm_profiles(self) -> None:\n \"\"\"Validate the connection-profile maps LOUDLY, at construction.\n\n Assignment is not validated anywhere in this project, and these two maps are the kind of\n thing a typo makes into a silent no-op: an unknown role name would simply never be read, and\n a reference to a missing profile would fall back to the shared model while the operator\n believed a second provider was in play. Both are errors here instead.\n\n The `api_key_env` rules exist for a specific reason. It must LOOK like a secret to\n `runtime/sandbox.py::SECRET_ENV`, because that is the filter that strips the operator's keys\n out of the environment handed to generated candidate code — a key parked in a variable named\n `MY_AUTH` would be invisible to it and ride straight into the sandbox. (The pattern is\n duplicated below rather than imported: layering forbids `core` from importing `runtime`.\n `tests/test_secret_env_pattern.py` pins the two together.) And a literal key inside a profile\n is refused outright: the profile map is written verbatim into config.snapshot.json and served\n over HTTP precisely because it is supposed to contain no secrets.\"\"\"\n from looplab.core.llm import LLM_ROLE_KEYS\n if not isinstance(self.llm_profiles, dict):\n raise ValueError(\"llm_profiles must be a map of profile name -> profile\")\n for name, entry in self.llm_profiles.items():\n if not isinstance(entry, dict):\n raise ValueError(f\"llm_profiles[{name!r}] must be an object\")\n # BEFORE the unknown-field check: none of these is a known field, so checking them\n # afterwards could never fire and someone who pasted a live key was told it was a typo.\n for banned in (\"api_key\", \"key\", \"token\", \"secret\"):\n if banned in entry:\n raise ValueError(\n f\"llm_profiles[{name!r}] must not contain {banned!r}: a profile stores \"\n \"api_key_env (the NAME of an environment variable), never a key value — \"\n \"the whole map is written to config.snapshot.json and served over HTTP\")\n unknown = set(entry) - _PROFILE_FIELDS\n if unknown:\n raise ValueError(\n f\"llm_profiles[{name!r}] has unknown field(s) {sorted(unknown)}; \"\n f\"known: {sorted(_PROFILE_FIELDS)}\")\n env = entry.get(\"api_key_env\")\n if env is not None and not _SECRET_ENV_NAME.match(str(env)):\n raise ValueError(\n f\"llm_profiles[{name!r}].api_key_env={env!r} is not a usable variable name: it \"\n \"must be UPPER_SNAKE and contain KEY/SECRET/TOKEN/PASSWORD/PASSWD/CREDENTIAL, so \"\n \"the sandbox's secret-name filter strips it from generated code's environment\")\n temp = entry.get(\"temperature\")\n if temp is not None and (isinstance(temp, bool) or not isinstance(temp, (int, float))):\n raise ValueError(f\"llm_profiles[{name!r}].temperature must be a number\")\n if not isinstance(self.role_profiles, dict):\n raise ValueError(\"role_profiles must be a map of role name -> profile name\")\n for role, profile in self.role_profiles.items():\n if role not in LLM_ROLE_KEYS:\n raise ValueError(\n f\"role_profiles has unknown role {role!r}; known: {sorted(LLM_ROLE_KEYS)}\")\n if profile not in self.llm_profiles:\n raise ValueError(\n f\"role_profiles[{role!r}] names profile {profile!r}, which is not in llm_profiles\")\n if self.llm_profile is not None and self.llm_profile not in self.llm_profiles:\n raise ValueError(\n f\"llm_profile={self.llm_profile!r} is not in llm_profiles\")\n\n def masked_snapshot(self) -> dict:\n # Snapshots are a JSON contract (they are written verbatim to config.snapshot.json), not a\n # Python-object dump. In particular tuple-valued schema fields such as\n # ``inline_repair_reasons`` must become JSON arrays before callers hash the runtime envelope;\n # otherwise the strict speculation-scope canonicalizer sees a different/non-JSON preimage\n # from the one that ``json.dumps`` eventually persists.\n d = self.model_dump(mode=\"json\")\n if self.llm_api_key is not None:\n d[\"llm_api_key\"] = \"***\"\n else:\n d[\"llm_api_key\"] = None\n return d\n\n\n# Pre-versioned snapshots are full Settings dumps, so absence is a reliable per-field version marker.\n# Keep their historical effective behavior when newer product defaults become active: re-entry must not\n# silently add paid calls, interventions, concurrency or a different selection policy to an old run.\nLEGACY_CONFIG_SNAPSHOT_DEFAULTS: dict[str, object] = {\n \"parallel_build\": 1,\n \"eval_parallel\": None,\n \"llm_parallel\": None,\n \"train_monitor\": False,\n \"train_monitor_interval_s\": 600.0,\n \"train_monitor_kill\": False,\n \"train_monitor_kill_confidence\": 0.8,\n \"asha_live\": False,\n \"asha_live_kill\": False,\n \"asha_live_quantile\": 0.5,\n \"asha_live_min_siblings\": 3,\n # Before this operator ceiling existed, the sandbox's 24-hour process limit was authoritative.\n \"max_eval_timeout\": 24 * 3600.0,\n \"watchdog_reflection\": False,\n \"card_driven_selection\": False,\n \"speculation_depth\": 0,\n \"speculation_gate_receipt\": None,\n \"concurrent_research_repeat\": False,\n \"concurrent_research_interval_s\": 1800.0,\n # Zero means unlimited; 40 was the first bounded default, and is latent while repeat is disabled.\n \"concurrent_research_max_calls\": 40,\n \"concurrent_consolidate\": False,\n}\n\n\ndef migrate_config_snapshot(data: dict) -> dict:\n \"\"\"Return an effective copy of one legacy/current snapshot without rewriting evidence bytes.\"\"\"\n if not isinstance(data, dict):\n raise TypeError(\"config snapshot must be a JSON object\")\n migrated = dict(data)\n for key, value in LEGACY_CONFIG_SNAPSHOT_DEFAULTS.items():\n migrated.setdefault(key, value)\n return migrated\n\n\ndef settings_from_snapshot(data: dict) -> Settings:\n # CODEX AGENT: snapshots have no schema/runtime version and Settings ignores unknown fields.\n # An older binary can silently drop newer behavior-affecting controls and resume the same event\n # history with different paid/concurrency/selection semantics. Version snapshots and fail closed\n # on an unsupported downgrade before migration/default filling.\n \"\"\"Build re-entry Settings from a masked snapshot under its historical missing-field contract.\"\"\"\n migrated = migrate_config_snapshot(data)\n migrated.pop(\"llm_api_key\", None)\n return Settings(**migrated)\n"}, "3837953b06464b87cfcbc0784f2300a27259df30": {"t": 1785768756.6061115, "n": 108301, "h": "7a358f07ec293a67", "c": "\"\"\"Engine config: the schema (what settings exist). (I0, ADR-11)\n\nOne pydantic-settings class declaring every engine knob, its default, and its docs. The loader —\nhow a run file / CLI / env becomes a `Settings` — is `looplab.core.appconfig`, which also owns\nthe config-precedence order. A resolved, secret-masked snapshot is written next to each run for\nreproducibility. (No real secrets in P0, but the masking discipline is in place.)\n\"\"\"\nfrom __future__ import annotations\n\nimport re\nimport types\nimport typing\nfrom pathlib import Path\n\nfrom pydantic import Field, SecretStr, field_validator, model_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n# Single source of the LLM first-byte (response-headers) default — see core/llm.py.\nfrom looplab.core.llm import DEFAULT_HEADER_TIMEOUT_S\n\n# Default home for cross-run memory + the knowledge base (shared across every run). Both are ON by\n# default now (a user asked for it) so a fresh install accumulates + consults knowledge with no setup;\n# set the env var to \"\" to disable, or to a path to relocate. `~/.looplab/` keeps them out of any one\n# project + shared across projects.\n_LL_HOME = Path.home() / \".looplab\"\n\n# What one `llm_profiles` entry may contain. Deliberately closed: an unrecognized field is a typo the\n# operator would otherwise never see fail, and the map is a public artifact (config.snapshot.json,\n# HTTP, LOOPLAB_*), so it must never accrete free-form keys.\n_PROFILE_FIELDS = frozenset({\"model\", \"base_url\", \"temperature\", \"api_key_env\", \"provider\"})\n# A variable name that `runtime/sandbox.py::SECRET_ENV` will recognize as holding a secret and strip\n# from generated code's environment. Duplicated here, not imported: layering forbids `core` from\n# importing `runtime`. `tests/test_secret_env_pattern.py` holds the two in agreement.\n_SECRET_ENV_NAME = re.compile(\n r\"^(?=[A-Z][A-Z0-9_]{0,63}$)(?=.*(KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL)).*$\")\n\n\ndef _is_numeric_annotation(ann) -> bool:\n \"\"\"True when a field's annotation admits int/float but NOT bool.\n\n Used by `Settings._reject_bool_for_numeric_fields`: pydantic resolves annotations at class build,\n so `model_fields[...].annotation` is a real type object here even under\n `from __future__ import annotations`. Optionals are numeric when every non-None member is\n (`int | None` yes, `int | str` no), so a boolean can never slip in through a union arm.\"\"\"\n if ann is bool:\n return False # a bool field legitimately takes a bool\n if ann is int or ann is float:\n return True\n args = [a for a in typing.get_args(ann) if a is not type(None)]\n origin = typing.get_origin(ann)\n if args and origin in (typing.Union, types.UnionType):\n return all(_is_numeric_annotation(a) for a in args)\n return False\n\n# Autonomous roles that may be granted per-setting write access (see Settings.agent_control).\n# strategist = A7 meta-controller (run-wide); boss = run-chat operator-proxy (run-wide);\n# researcher = per-experiment proposer (per-node sizing, e.g. eval timeout for a heavy model).\nAGENT_ROLES = (\"strategist\", \"boss\", \"researcher\")\n\n# A foresight score is computed for every acted-on proposal. Keep this lower than the generic\n# verifier's emergency ceiling so a typo cannot multiply paid synchronous calls across a run.\nMAX_FORESIGHT_VERIFY_SAMPLES = 8\n\n\n# Layer-2 parallelism renamed two public settings without removing the durable legacy fields. Keep\n# the equivalence in one place: config-source precedence, governance migration, and live strategy\n# application must all agree which names describe the same authority/value.\nPARALLELISM_ALIASES: dict[str, str] = {\n \"max_parallel\": \"eval_parallel\",\n \"parallel_build\": \"llm_parallel\",\n}\n\n\ndef parallelism_aliases(setting: str) -> tuple[str, ...]:\n \"\"\"Return every spelling in ``setting``'s alias family, canonical name first.\"\"\"\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if setting in (legacy, canonical):\n return canonical, legacy\n return (setting,)\n\n\ndef canonicalize_parallelism_source(\n values: dict, *, promote_build_to_llm_parallel: bool = False,\n) -> dict:\n \"\"\"Promote legacy parallel keys *within one config-precedence source*.\n\n Applying this only after file/CLI/env were flattened would let a lower-priority canonical env\n value shadow a higher-priority legacy file value (or a file canonical value shadow a CLI legacy\n override), so callers with sub-layers normalize each layer independently.\n\n ``max_parallel``/``eval_parallel`` are true aliases (pure eval width, no extra semantics) and are\n always promoted. ``parallel_build``/``llm_parallel`` are NOT: a positive ``llm_parallel`` also flips\n on the finite shared LLM broker (``orchestrator._startup_llm_total``), while legacy ``parallel_build``\n means \"legacy build width, unbounded overlap, no shared total\". So promoting a legacy-only\n ``parallel_build`` layer (e.g. just ``LOOPLAB_PARALLEL_BUILD=1`` in env) would silently serialize\n every provider call — contradicting llm_broker.py's docstring, orchestrator's \"legacy-only preserves\n unbounded overlap\" note, the broker-neutral live-control path, and the configuration.md ``llm_parallel``\n row (\"falls back ... without enabling a shared total\"). Config/startup loads therefore keep broker\n opt-in tied to an EXPLICITLY-spelled canonical ``llm_parallel``; the orchestrator's own width fallback\n (``llm_parallel if not None else parallel_build``) still carries the legacy build width. Only the\n Strategist live-control path (``canonicalize_strategy_parallelism``) opts into\n ``promote_build_to_llm_parallel``, where ``parallel_build`` is a deliberate full alias of the\n canonical knob (see strategy.py ``_apply_strategy``).\n \"\"\"\n if not isinstance(values, dict):\n return values\n out = dict(values)\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if canonical == \"llm_parallel\" and not promote_build_to_llm_parallel:\n continue\n # None is the durable legacy mode for llm_parallel, not a missing value. A normal snapshot\n # contains llm_parallel:null plus parallel_build:1; promoting null to 1 would unexpectedly enable\n # the global one-call broker. Promote aliases only when the canonical key is absent, and pin\n # snapshot round-trip parity. Canonicalize inside each precedence layer, never across the\n # flattened result, so the alias migration cannot invert CLI > file > env precedence.\n if canonical not in out and out.get(legacy) is not None:\n out[canonical] = out[legacy]\n # PRECEDENCE REPAIR for the non-promoted pair. Skipping promotion above is right (a legacy-only\n # layer must not flip on the shared broker), but it also let a LOWER-priority canonical value\n # survive a HIGHER-priority legacy override: file `llm_parallel: 2` + CLI `-s parallel_build=8`\n # flattened to BOTH keys, and the orchestrator prefers `llm_parallel`, so the operator's explicit\n # CLI width was silently discarded — exactly the inversion `build_settings`' docstring says this\n # per-layer canonicalization exists to prevent. Mask it with the LEGACY SENTINEL instead of\n # promoting: `llm_parallel=None` is the durable \"legacy mode — take the build width from\n # parallel_build, no shared total\", so the higher layer wins with the semantics it asked for.\n if (not promote_build_to_llm_parallel and \"llm_parallel\" not in out\n and out.get(\"parallel_build\") is not None):\n out[\"llm_parallel\"] = None\n return out\n\n# Settings whose semantics are committed by ``run_started`` and restored from the folded event log\n# on every re-entry. Changing one only in ``config.snapshot.json`` would mix incompatible evidence\n# (a different holdout split) or make the live producer disagree with replay's selection policy.\n# The HTTP config editor and the engine both consume this one contract; keep deliberately mutable\n# controls such as ``trust_gate`` out (that field has its own durable change event).\nRUN_START_PINNED_FIELDS = frozenset({\n \"card_driven_selection\",\n \"speculation_depth\",\n \"holdout_fraction\",\n \"holdout_select\",\n \"select_verifier\",\n \"select_verifier_samples\",\n \"verifier_ci_tie\",\n})\n\n\ndef run_start_pinned_settings(state) -> dict:\n \"\"\"Return the effective Settings names committed by a folded run-start record.\n\n Pre-pin legacy logs have no recorded holdout fraction, so their snapshot remains authoritative\n for both holdout knobs. Verifier fields, however, have always been re-pinned from the legacy-safe\n fold defaults whenever a valid ``run_started`` identity exists.\n \"\"\"\n if not getattr(state, \"run_id\", \"\"):\n return {}\n values = {\n # Layer 3 changes which queue owns macro-action selection. Once a run starts, replay and\n # resume must keep the same owner even if the snapshot or ambient default changes later.\n \"card_driven_selection\": bool(getattr(state, \"card_driven_selection\", False)),\n # Layer 5 overlaps Card production with evaluation only when this pinned treatment is\n # positive. Old logs omit it and therefore retain the exact serial build/eval spine.\n \"speculation_depth\": int(getattr(state, \"speculation_depth\", 0)),\n \"select_verifier\": bool(getattr(state, \"select_verifier_tiebreak\", False)),\n \"select_verifier_samples\": int(getattr(state, \"select_verifier_samples\", 3)),\n \"verifier_ci_tie\": bool(getattr(state, \"verifier_ci_tie\", False)),\n }\n holdout_fraction = getattr(state, \"holdout_fraction\", None)\n if holdout_fraction is not None:\n values.update({\n \"holdout_fraction\": holdout_fraction,\n \"holdout_select\": bool(getattr(state, \"holdout_select\", False)),\n })\n if not values.keys() <= RUN_START_PINNED_FIELDS:\n raise RuntimeError(\"folded run-start pinned settings contract drifted\")\n return values\n\n# --- Run profiles (config-first presets) -------------------------------------------------------\n# A `profile` is a NAMED BUNDLE of setting defaults — nothing more. It only fills fields the user\n# did NOT set explicitly (via file / --set / LOOPLAB_* env / init-kwargs), so any explicit knob\n# always wins (see `_apply_profile`). Most engine intelligence lives behind individual flags. The\n# PART IV/V cross-run + concept-graph machinery (concept_pivot, graded_novelty, cross_run_concepts /\n# _advisory / _structured_claims / _curation / _read_tools, fingerprint_universal, foresight_verify)\n# now ships ON in the product `Settings` default. That is an explicit experimental product choice,\n# not a claim that a frozen A/B or workload-wide cost/quality gate has cleared: the bundle can steer\n# prompts and candidate generation, change graded-novelty admission / pre-execution choices, and add\n# paid LLM work when its client and memory prerequisites are present. The bare-library `EngineOptions`\n# default stays lean (see tests/test_options_divergence.py — product side is the aggressive one) so a\n# toy `Engine(...)` in a test doesn't fire cross-run LLM work unasked. The trust/confirm/ablate QUALITY\n# bundle still ships OFF and is turned on by `thorough`. Every value here is reachable by hand — the\n# profile is a convenience, never a hidden mode.\n# keep this rationale behavioral and evidence-based; do not relabel read-only storage\n# access or proposal-only governance as a no-op for agent behavior, cost, or proposal admission.\n#\n# `thorough` deliberately touches only QUALITY/TRUST machinery, not spend: it does NOT raise\n# max_nodes / eval_parallel (those are cost knobs the user owns). It enables multi-seed confirmation\n# (demote seed-lucky leaders), the reward-hack / leakage / critic monitors AND flips them from\n# advisory to *gating* (`trust_gate=\"gate\"` — a\n# flagged win can no longer be selected as best), ablation-driven refinement, and the prompt cues\n# (complexity / budget / failure-reflection / watchdog-reflection). Agentic novelty and the experimental\n# Part IV/V bundle are already product defaults, not profile changes. Effect is large; marginal cost is a\n# few extra evals.\nPROFILES: dict[str, dict] = {\n \"default\": {}, # explicit no-op alias for \"ship-as-is\" (== omitting profile)\n \"fast\": {}, # today's lean defaults, named for symmetry with `thorough`\n \"thorough\": {\n \"confirm_top_k\": 3,\n \"confirm_seeds\": 3,\n # novelty_gate intentionally NOT enabled here: duplicate-detection is the agentic Researcher's\n # job (it reads past experiments + find_analogous_across_runs and DECIDES), not an algorithmic\n # param-distance / embedding auto-reject that mis-fired (it nudged a good fresh idea onto a\n # twice-dead lineage — live node 61). Search stays as tools; the LLM judges novelty.\n \"operator_bandit\": True, # P4: adaptive operator mix over folded yields (GreedyTree)\n \"reward_hack_detect\": True,\n \"code_leakage_detect\": True,\n \"critic_check\": True,\n \"trust_gate\": \"gate\", # detectors stop gating a flagged node from WINNING (not just audit)\n \"ablate_every\": 3,\n \"complexity_cue\": True,\n \"budget_aware\": True,\n \"failure_reflection\": True,\n \"watchdog_reflection\": True, # feed recent live-watchdog (train-monitor/ASHA) flags to proposals\n \"reflection_priors\": True, # no-op unless memory_dir is set (cross-run priors)\n },\n}\n\n\n# The shipped agent-governance matrix (who may change which setting at runtime). Defined as a module\n# constant so the Engine's direct-construction default matches this shipped default exactly (the\n# EngineOptions \"Engine() == shipped defaults\" invariant) instead of diverging to an all-locked map.\nDEFAULT_AGENT_CONTROL: dict[str, list[str]] = {\n \"timeout\": [\"researcher\", \"strategist\"],\n # Layer-2 canonical names are the presented/default governance surface. `_agent_may` falls back\n # to an old snapshot's legacy grant only when its canonical counterpart is absent, so removing\n # duplicate defaults preserves resume authority while an explicit canonical revocation still wins.\n \"eval_parallel\": [\"strategist\"],\n \"llm_parallel\": [\"strategist\"],\n # Strategy-only pseudo-setting: the closed per-lane allocation inside the canonical LLM total.\n # It is not a launch Settings field; operators pin it through `set_strategy` and the Strategist\n # may reallocate it only while this grant is present.\n \"llm_lane_limits\": [\"strategist\"],\n \"max_nodes\": [\"strategist\", \"boss\"],\n \"max_eval_seconds\": [\"strategist\", \"boss\"],\n \"policy\": [\"strategist\"],\n # The policy NAME and its PARAMS are gated independently in `_apply_strategy`; grant both by default\n # so the Strategist that may switch to (say) MCTS may also apply the `c`/`eta` it decided — else its\n # tuned params are silently dropped and the recorded `active_strategy` diverges from the live engine.\n \"policy_params\": [\"strategist\"],\n \"n_seeds\": [\"strategist\"],\n \"ablate_every\": [\"strategist\"],\n \"merge_mode\": [\"strategist\"],\n \"complexity_cue\": [\"strategist\"],\n \"ablate_code_blocks\": [\"strategist\"],\n \"prefer_sweep\": [\"strategist\"],\n \"novelty_stance\": [\"strategist\"],\n \"developer\": [\"strategist\"],\n \"fidelity\": [\"strategist\", \"researcher\"],\n}\n\n\ndef default_agent_control() -> dict[str, list[str]]:\n \"\"\"A FRESH deep-ish copy of the shipped governance matrix (each role list copied so a caller can\n mutate its own map without touching the module constant). The SINGLE constructor of the default,\n called by both `Settings.agent_control`'s default_factory AND the Engine's direct-construction\n default — so \"Engine() == shipped defaults\" can't drift if the copy depth ever needs to change\n (e.g. nested values requiring copy.deepcopy), instead of two verbatim copy expressions.\"\"\"\n return {k: list(v) for k, v in DEFAULT_AGENT_CONTROL.items()}\n\n\nclass Settings(BaseSettings):\n \"\"\"The engine settings schema (every knob a run accepts).\n\n Timeout family — six distinct knobs, each owned by a different subsystem:\n - `timeout`: per-eval wall-clock budget for ONE experiment's evaluation (engine/eval).\n - `max_eval_timeout`: hard ceiling for a governed per-node Researcher timeout override.\n - `llm_timeout`: LLM request idle timeout — inter-token stall limit in stream mode\n (core.llm OpenAICompatibleClient).\n - `llm_header_timeout`: LLM first-byte (response-headers) window for stream attempts (core.llm).\n - `agent_time_budget_s`: wall-clock ceiling across a tool-using agent loop's turns (agents).\n - `dep_install_timeout`: per-package budget for auto-installing a missing dep (env self-prep).\n\n Config-source precedence is owned by `looplab.core.appconfig` (the loader) — see its module\n docstring for the one canonical order.\n \"\"\"\n # Config precedence: see appconfig.py — the loader owns the full order. The `.env` is read\n # so `looplab run`/`looplab ui` pick up LOOPLAB_LLM_BASE_URL etc. without exporting them by hand\n # (keys MUST carry the LOOPLAB_ prefix, same as the env vars). utf-8-sig tolerates a BOM from\n # Windows editors. The test suite disables this (tests/conftest.py) so a dev's real .env in the\n # repo root can't leak into assertions.\n model_config = SettingsConfigDict(\n env_prefix=\"LOOPLAB_\",\n env_file=\".env\",\n env_file_encoding=\"utf-8-sig\",\n extra=\"ignore\",\n allow_inf_nan=False,\n )\n\n @classmethod\n def settings_customise_sources(\n cls, settings_cls, init_settings, env_settings, dotenv_settings,\n file_secret_settings):\n \"\"\"Preserve source precedence across canonical/legacy parallelism names.\n\n Pydantic ranks init > env > dotenv correctly, but it ranks field names rather than semantic\n alias families. Promoting a legacy value inside each source before the merge makes an old\n explicit snapshot/file value beat a lower-priority canonical env value. ``appconfig`` also\n promotes its file/typed/--set sub-layers before combining them into the init source.\n \"\"\"\n def _with_parallel_aliases(source):\n return lambda: canonicalize_parallelism_source(source())\n\n return (\n _with_parallel_aliases(init_settings),\n _with_parallel_aliases(env_settings),\n _with_parallel_aliases(dotenv_settings),\n _with_parallel_aliases(file_secret_settings),\n )\n\n # Run profile (config-first preset, see PROFILES above). \"default\"/\"fast\" = today's lean\n # defaults; \"thorough\" turns the built quality/trust machinery ON in one word. A profile only\n # fills fields the user did NOT set — every knob it touches stays individually overridable.\n profile: str = \"default\"\n\n # Bounds (review C/⚪): a non-positive node/seed budget or timeout is never valid. Upper bounds are\n # equally load-bearing — the UI /start path writes these straight into the engine's env, and an\n # unbounded `eval_parallel` (or legacy `max_parallel`; e.g. 100000 from a crafted preflight) would\n # fan out that many sandboxes → resource exhaustion. Reject at config time, not mid-run. Ceilings\n # are generous; tests top out near 50.\n # Pydantic's lax mode coerces a JSON boolean into any numeric field (`true` -> 1), so a UI/API type\n # error used to silently collapse a run budget to ONE node instead of returning 422. Rejected for\n # EVERY numeric field by `_reject_bool_for_numeric_fields` below, not just the ones bounded here.\n n_seeds: int = Field(default=3, ge=1, le=1024)\n max_nodes: int = Field(default=8, ge=1, le=1_000_000)\n # legacy evaluation-width input. `eval_parallel` below is canonical and wins when set;\n # keep this field for old env/config/snapshots and direct Engine call sites. A width > 1 admits\n # concurrent evals, whose resource reservations may be CPU-only or span one/more GPUs — there is no\n # one-node/one-GPU promise. `max_parallel=0` is launch-time AUTO (detected GPU count, at least 1).\n max_parallel: int = Field(default=1, ge=0, le=1024)\n # legacy build-width input. `llm_parallel` below is canonical and also controls the\n # shared provider-call total when positive. A width > 1 uses fresh pooled researcher/developer pairs;\n # it says nothing about the later eval's CPU/GPU reservation. Startup `0` derives build fan-out from\n # resolved eval width; a live Strategist/operator zero settles to 1. Without role_factory it clamps to 1.\n parallel_build: int = Field(default=1, ge=0, le=64)\n # Hypothesis-card Kanban re-architecture (docs/23, Layer 2). Two INDEPENDENT concurrency axes,\n # decoupled from each other so cost (LLM work) can be tuned separately from throughput (GPU evals).\n # These are the CANONICAL names going forward; `max_parallel`/`parallel_build` above are kept as thin\n # back-compat aliases (env vars / snapshots / ~100 Engine(...) call sites depend on the exact names,\n # so they cannot be renamed — CLAUDE.md). Resolution (orchestrator.__init__): a NEW name, when set,\n # WINS over its legacy alias; None => fall back to the legacy field => byte-identical to today.\n # `eval_parallel`: concurrent EVALS (the GPU/experiment consumer). None -> max_parallel; 0 = AUTO\n # (one experiment per detected GPU). This is the in-Run eval-concurrency ceiling; the resource\n # scheduler bin-packs declared footprints separately. Sibling local GPU-owning Runs serialize\n # through the conservative host-pool lease; external execution needs its own admission boundary.\n # `llm_parallel`: total concurrent LLM provider calls + BUILD fan-out. None -> legacy\n # parallel_build semantics without a shared total; 0 = startup AUTO (= resolved eval_parallel\n # for build fan-out, historical research overlap unbounded). A positive canonical value enables\n # the named-lane broker the Strategist can reallocate at cadence.\n eval_parallel: int | None = Field(default=None, ge=0, le=1024)\n llm_parallel: int | None = Field(default=None, ge=0, le=64)\n # Training-log monitor (I-series watchdog family): a per-eval background observer that tails the live\n # training log while a (often multi-hour) declared stage runs in a worker thread. ON in the product\n # surface (Settings). Its alert event is fold-ignored and cannot directly change lifecycle/champion;\n # with watchdog_reflection on, that alert is nevertheless advice in a later Researcher\n # prompt and can change future proposals. It only fires on the command-eval path with an LLM client;\n # bare-library EngineOptions stays OFF. See test_options_divergence.\n # `_interval_s` is the BASE tick cadence (the effective one adapts to the per-experiment budget).\n train_monitor: bool = True\n train_monitor_interval_s: float = Field(default=600.0, gt=0)\n # Phase 3 — INTERVENTION (opt-in, separate from observation): let the monitor tree-kill a training the\n # LLM judges 'broken' (diverged / silent CPU fallback / not learning) EARLY, instead of burning the\n # whole budget. Off by default — the observer only WATCHES unless this is on. A kill only fires on a\n # 'broken' verdict (a plateau is 'watch', never killed) with confidence >= the threshold; the node then\n # fails normally (reason='monitor_broken'), so replay reconstructs it from that one terminal event.\n train_monitor_kill: bool = False\n train_monitor_kill_confidence: float = Field(default=0.8, ge=0.0, le=1.0)\n # ASHA live-curve watchdog (sibling of the training monitor): reads the latest INTERMEDIATE value of\n # the objective metric off the live log (reusing the eval's OWN metric reader). Finished-endpoint rank\n # remains advisory. An opt-in KILL additionally requires an operator-declared metric.resource_key and\n # enough sibling observations at exactly that resource; absent comparable evidence cannot stop a run.\n # Records a fold-ignored `asha_rank` diagnostic + trace span; the library default is OFF (off == today).\n asha_live: bool = True\n # Opt-in INTERVENTION (separate from the advisory signal): let the watchdog tree-kill a node whose\n # intermediate metric stays below the SAME-RESOURCE bar past a short grace window. Off by default —\n # it only surfaces endpoint rank unless this is on. A kill fails normally (asha_underperforming).\n asha_live_kill: bool = False\n # The bar sits at `asha_live_quantile` along a WORST->BEST ordering of the finished siblings' finals:\n # 0.5 = the median (a node worse than the median peer flags); a SMALLER value is more conservative —\n # it lowers the bar toward the WORST finished peer, so only a node worse than nearly all peers flags\n # (0.0 = worse than the worst). `min_siblings` is the floor of finished peers required before it ranks\n # at all, so it never acts on too little evidence.\n asha_live_quantile: float = Field(default=0.5, ge=0.0, le=1.0)\n asha_live_min_siblings: int = Field(default=3, ge=1)\n timeout: float = Field(default=30.0, gt=0)\n # Hard ceiling for the Researcher's governed per-node `Idea.eval_timeout`. One hour preserves the\n # existing heavy/neural-net authoring example while staying well below the sandbox's defensive\n # 24-hour subprocess ceiling. This is operator-owned run config: agents may request less or more,\n # but the accepted action is always clamped here after the `agent_control.timeout` permission gate.\n max_eval_timeout: float = Field(default=3600.0, gt=0, le=24 * 3600.0)\n # Intra-node sweep: a sweep node runs a whole grid in one process, so it gets this multiple of\n # `timeout` as its wall-clock budget (solution.py path; RepoTasks use their per-profile timeout).\n sweep_timeout_mult: float = Field(default=8.0, ge=1)\n # Eval STALL watchdog CAP (seconds): a stage that is completely SILENT on stdout/stderr for this\n # long — while still alive and BELOW its wall-clock deadline — is tree-killed early with a STALLED\n # marker (a hung dataloader/deadlock dies in minutes instead of burning a multi-hour timeout). The\n # per-stage window is `min(this, the stage's own timeout)`, so a short scorer's deadline fires first.\n # `0` DISABLES the watchdog entirely (only the hard deadline applies) — for a legitimately quiet\n # non-Python stage (block-buffered stdout, a script that logs only to its own file). Threaded into\n # the eval and surfaced to the Developer so its code can emit periodic progress to stay alive.\n eval_stall_timeout_s: float = Field(default=1800.0, ge=0)\n # Sandbox tier (ADR-13): \"trusted_local\" (subprocess, no Docker) for the CLI;\n # \"untrusted\" (Docker --network none, shared-kernel runtime) for hosted/multi-tenant UI;\n # \"hostile\" (untrusted + a true-isolation OCI runtime, gVisor `runsc` by default / Kata) for\n # running untrusted code. See runtime/sandbox.py::make_sandbox for the tier selection.\n trust_mode: str = \"trusted_local\"\n # Image for the untrusted command-eval tier (RepoTask, Phase 4): the framework's deps\n # should be baked in (the container runs --network none, so a pip setup can't fetch).\n docker_image: str = \"python:3.12-slim\"\n # Resource caps for the untrusted/hostile Docker tier (passed to `docker run --memory/--cpus`).\n # The default 4g bound protects other tenants from an OOM, but a model-training MLE-bench eval\n # routinely needs more — raise it (e.g. \"16g\") for such runs. \"\" disables the cap (unbounded).\n # Ignored by the trusted_local (subprocess) tier.\n sandbox_memory: str = \"4g\"\n sandbox_cpus: str = \"\"\n # Best-effort host-OOM guard for the TRUSTED-LOCAL (subprocess) tier. When set (e.g. \"8g\"), each\n # eval child gets an RLIMIT_AS address-space cap so a runaway trainer hits MemoryError instead of\n # OOM-killing the whole host (and the engine). POSIX only; a no-op on Windows. Default \"\" = OFF:\n # RLIMIT_AS bounds VIRTUAL memory, and CUDA/torch reserve tens of GB of virtual without using it,\n # so a default cap would spuriously kill GPU evals — leave it off for those and use the Docker\n # tier's real --memory cgroup bound (sandbox_memory) instead. Ideal for CPU/numpy/sklearn runs.\n sandbox_memory_local: str = \"\"\n # Best-effort disk-fill guard for the TRUSTED-LOCAL tier (P1-5): an RLIMIT_FSIZE cap on the size of\n # any single file an eval child writes (e.g. \"2g\"), so a runaway that writes an unbounded file gets\n # SIGXFSZ instead of filling the host disk. POSIX only. Default \"\" = OFF (large model checkpoints\n # need big files); set it for tasks that write only small artifacts.\n sandbox_fsize_local: str = \"\"\n # Search policy (ADR-2): \"greedy\" | \"evolutionary\" | \"mcts\" | \"asha\".\n policy: str = \"greedy\"\n # Ablation-driven refinement (I7): every N improves, ablate the best to find the\n # highest-impact parameter and refine it. 0 = off. (GreedyTree only.)\n ablate_every: int = 0\n # A0a (MLE-STAR): when ablating, treat each generated pipeline *code block* (not just a\n # numeric param) as an ablation unit — neutralize a block, measure the metric delta, refine\n # the highest-impact block. Off => the classic numeric-param ablation. Config-first knob.\n ablate_code_blocks: bool = False\n # A0b: real merge/ensembling. \"mean\" = legacy mean-param merge; \"ensemble\" = the Developer\n # writes a code-recombination ensemble over the two parents' solutions (verified: agent-proposed\n # ensembling 37.9%->43.9%). \"auto\" (default) = ensemble whenever the Developer actually\n # generates code (LLM/agent backends; declared via `is_code_generating`), mean for the\n # templated/toy developers where a code ensemble is meaningless — the evidence says code\n # recombination is the strongest single merge (removing it costs ~9 pp), so it is the default\n # wherever it can work. Falls back to mean when the Developer can't ensemble.\n merge_mode: str = \"auto\"\n # A0d (AIRA): inject a dynamic complexity hint into the draft/improve prompt keyed on the\n # node's child count (few children -> keep minimal; many -> escalate to ensembling/HPO).\n complexity_cue: bool = False\n # I1 (CAAFE-style) LLM feature-engineering: instruct the proposer/developer to add\n # semantically-meaningful engineered features (ratios, interactions, aggregations) as code. The\n # existing cross-validation eval + best-selection is the CV GATE — a feature set that doesn't\n # improve CV is never selected (feature engineering is non-universal, so this gate is mandatory).\n # Tabular tasks (those exposing column data). Off by default.\n feature_engineering: bool = False\n # A5: surface the REMAINING eval-compute budget into the proposal prompt so the Researcher can\n # reason over how much compute is left (explore broad while flush, exploit/cheapen when low).\n # No-op unless a max_eval_seconds budget is set.\n budget_aware: bool = False\n # A4 (LATS-style): feed a summary of the most recent FAILED branches (operator + error reason)\n # back into the proposal prompt so the proposer reflects on and avoids repeating them. ON by\n # default: it is SELECTIVE by construction (injects only when recent failures exist — the\n # evidenced form of reflection; reflecting at every step shows no gains, arXiv:2506.12928).\n failure_reflection: bool = True\n # Feed the LIVE-WATCHDOG observations (train-monitor health verdicts + ASHA intermediate-rank flags)\n # of recent experiments back into the proposal prompt, so the Researcher avoids re-proposing a config\n # whose TRAINING was already observed to be weak — even when the watchdog kills are off (default) and\n # the node ran to completion (those diagnostics are fold-ignored, so failure_reflection never sees\n # them). ON by default and SELECTIVE like failure_reflection (injects only when a recent flag exists).\n watchdog_reflection: bool = True\n # C3 deep test-driven repair: hand the Developer the failure taxonomy + a structured \"reproduce\n # then fix\" directive on debug, not just the raw stderr tail. ON by default (product surface); the\n # conservative library default in EngineOptions stays off, per that module's contract.\n deep_repair: bool = True\n # === Inline crash repair ==============================================================\n # Hybrid in-node crash repair: when an LLM-generated node CRASHES at runtime (mechanical errors\n # — bad import, removed kwarg, typo), the agent triages it and may repair the code IN PLACE within\n # the same eval (cheap; does NOT consume max_nodes and does NOT add a node to the search tree).\n # Semantic failures still flow to a new idea/debug node via the policy. ON by default; set False\n # to restore the prior behavior (every crash waits for a budgeted inter-node debug node).\n inline_repair: bool = True\n # Max in-place repair attempts per node before it fails normally. Default 0 = UNLIMITED: the same\n # agent keeps fixing + re-evaluating IN THE SAME node until it produces a clean metric — no new\n # node just to retry. Runaway is prevented not by a count but by the anti-stuck guard\n # (`inline_repair_stuck_repeat`): when the SAME error signature repeats with no progress, or the\n # agent's crash-triage says \"abandon\", the node fails. Set a positive N to cap the retries instead.\n inline_repair_attempts: int = Field(default=0, ge=0)\n # Anti-stuck: abandon in-node repair when the SAME error recurs this many times in a row (no\n # progress). Keeps \"unlimited\" repair from looping forever on an unfixable error.\n inline_repair_stuck_repeat: int = Field(default=4, ge=2)\n # Which failure reasons (_failure_reason: crash|timeout|oom|setup|no_metric|drift) are eligible for\n # inline repair. Default: mechanical crashes, timeouts AND OOM-kills — a timeout/OOM means the code\n # was too slow / too memory-hungry for the budget (or the pod's cgroup limit), not that the idea is\n # wrong, so the agent repairs it by reducing compute/memory (fewer estimators/epochs/folds/seeds, a\n # lighter model, a smaller batch, subsampling) instead of letting the node die unrecovered. Env\n # override expects a JSON array.\n inline_repair_reasons: tuple[str, ...] = (\"crash\", \"timeout\", \"oom\")\n # Cost ceiling for in-node repair of a MULTI-STAGE eval: the max number of FULL pipeline re-runs\n # (i.e. re-trains from the first stage) the inline-repair loop may do before abandoning to the\n # inter-node debug operator. A late-stage failure (a broken `score`/eval script that didn't touch\n # `train`) REUSES the completed train checkpoint and re-runs only from the failed stage — that is\n # CHEAP and NOT counted here; only a repair that changes an EARLIER stage's code (train.py/loss.py/…)\n # forces a full re-train, which each costs the whole training time. Without this bound, a repair that\n # keeps changing training code could burn many full trains (the anti-stuck guard is error-signature\n # based, not cost based). 0 = unlimited (legacy behavior). Single-command evals ignore it.\n inline_repair_retrain_cap: int = Field(default=2, ge=0)\n # Environment self-prep: when a solution crashes purely because a KNOWN library isn't installed\n # (ModuleNotFoundError), the engine pip-installs it into the eval interpreter and re-runs — so a\n # torch/XGBoost/CatBoost idea (e.g. a GRU model) actually runs on a fresh box instead of being\n # thrown away as `idea_rejected`. Trusted_local tier only (the untrusted/hostile Docker tiers run\n # --network none). A typo'd or local helper module is NOT installed (that's a code bug to repair).\n auto_install_deps: bool = True\n # Per-package wall-clock budget for an auto-install (seconds). Generous: large wheels like torch\n # can take minutes to download/build.\n dep_install_timeout: float = Field(default=900.0, gt=0)\n # --- Agent governance: who may change a setting at runtime (per-setting allow-list of roles) ---\n # Roles: \"strategist\" (A7 meta-controller; run-wide), \"boss\" (run-chat operator-proxy; run-wide),\n # \"researcher\" (per-experiment proposer; PER-NODE — e.g. set a longer eval timeout for a neural-net\n # idea). A setting ABSENT from this map is LOCKED — no agent may change it, only a human via the\n # snapshot/UI. The sole conditional exception is Strategy-only `card_scoring`: Card mode grants\n # it implicitly without changing the default flag-off snapshot, while an explicit [] revokes it.\n # The gate is ENFORCED at every AGENT seam via `_agent_may` — the strategist's whole\n # control surface (policy/operators/novelty_stance/prefer_sweep/developer/fidelity/timeout/\n # eval_parallel/llm_parallel) is checked in `_apply_strategy`, so removing a role from a knob locks it,\n # not just greys out the UI pill. The boss/max_* grants document boss-relevant knobs, but a\n # `budget_extend` is a HUMAN control intent (the boss action-builder can only emit `add_nodes`), so\n # it is applied as-is — the human always wins via the UI/snapshot. Defaults are conservative: resource/\n # search-shape knobs the agents already reason about; provider infra (llm_model/base_url/api_key,\n # docker_image) stays locked. The UI shows S/B/R pills per setting. `timeout` granted to the\n # researcher IS the \"auto\" per-node mode — the researcher sizes each experiment's wall-clock\n # (Idea.eval_timeout); the config `timeout` is the fallback for nodes it doesn't size. Env override\n # expects a JSON object. (`fidelity`/`novelty_stance`/`prefer_sweep`/`developer` are governance keys\n # for the strategist's per-node dials — not 1:1 Settings fields, but gated the same way.) The default\n # lives in the module-level DEFAULT_AGENT_CONTROL so the Engine's direct-construction default matches.\n agent_control: dict[str, list[str]] = Field(default_factory=default_agent_control)\n # C1 (Agentless localization): for repo tasks, rank the source files most relevant to the most\n # recent failure (traceback + identifiers) and surface them in the proposal/repair prompt so the\n # Developer edits the right place. Off by default; repo tasks only.\n localize_faults: bool = False\n # A2 surrogate-guided proposal (BO-lite): fit a k-NN surrogate over (params->metric) and propose\n # by acquisition instead of random/hill-climb. Numeric-bounds tasks only; bootstraps via the\n # base Researcher. Off by default. `surrogate_explore` = UCB-style exploration weight.\n surrogate_proposer: bool = False\n surrogate_explore: float = 0.1\n # E2 researcher panel: generate K candidate ideas per proposal and keep the one ranked best by a\n # cheap empirical surrogate over the (params->metric) history (NOT an LLM-judge). 1 = off.\n researcher_panel: int = 1\n # A1 ASHA / successive-halving: reduction factor (keep top 1/eta per rung) and rung budget.\n asha_eta: int = Field(default=3, ge=2)\n # Rung-0 width for ASHA/BOHB (the wide base of cheap drafts). 0 = use n_seeds (default,\n # preserving prior behavior); set >0 to seed a wider/narrower base independent of n_seeds.\n asha_rung_nodes: int = Field(default=0, ge=0)\n # A6 proxy/predictive scoring: cheaply rank a candidate's potential from early-stage signals\n # and skip a full eval for the doomed bottom fraction. 0.0 = off (no candidate skipped).\n proxy_scoring: bool = False\n proxy_kill_fraction: float = Field(default=0.0, ge=0.0, le=0.9)\n # === Novelty / dedup ==================================================================\n # E1 novelty/dedup gate: reject near-duplicate proposals (normalized param-space distance below\n # `novelty_epsilon`) by deterministically nudging them off the duplicate — stops the search\n # wasting evals re-trying the same idea. Audit event `novelty_rejected`. Off by default.\n # Novelty/dedup MODE selector: how a fresh proposal is checked against already-tried experiments.\n # \"off\" — no explicit gate; the agentic Researcher's own read-the-history judgment is trusted.\n # \"algo\" — the deterministic gate: numeric param-distance (`novelty_epsilon`) + optional embedding\n # similarity (`novelty_semantic`). Cheap, no LLM call, but can't explain itself.\n # \"llm\" — an LLM adjudicates (reads the real experiments via tools) whether the idea is a\n # near-duplicate and, if so, asks the Researcher once more for something different. One\n # extra LLM call per proposal — highest quality, follows the \"let the LLM decide\" line.\n novelty_mode: str = \"llm\"\n # Legacy sub-toggles, honored by the \"algo\" mode (and back-compat: novelty_gate=True forces \"algo\"):\n novelty_gate: bool = False\n novelty_epsilon: float = 0.05\n # T5 semantic novelty (active whenever the deterministic gate runs — novelty_mode=algo,\n # novelty_gate=true which is the legacy alias for algo, OR the Strategist novelty stance is\n # \"explore\"; a no-op only under novelty_mode=llm/off with a non-explore stance): reject a proposal\n # whose idea TEXT embeds within\n # `novelty_semantic_threshold` cosine of an existing node's. OFF BY DEFAULT: novelty is the agentic\n # Researcher's call, made by READING the actual prior experiments (read_experiment /\n # find_analogous_across_runs / list_experiments), NOT an embedding-similarity auto-reject that can't\n # explain itself and mis-fires on paraphrases. Semantic/param search stays available as TOOLS in the\n # Researcher's context; the LLM decides whether an idea is a true duplicate.\n novelty_semantic: bool = False\n novelty_semantic_threshold: float = Field(default=0.92, ge=0.5, le=1.0)\n # T10: debug-lineage depth bound for every policy — how many error-feedback repairs a failing\n # lineage gets before it is abandoned. The old default (1) abandoned lineages after ONE repair;\n # multi-turn/deeper debugging is a verified lever (AIRA2 ReAct debug +5.5 percentile points).\n debug_depth: int = Field(default=2, ge=1)\n # P4 operator bandit (GreedyTree): replace the fixed merge/ablate cadences with a\n # deterministic UCB over per-operator yield (Δmetric per eval-second, folded from the run).\n # Off by default (the cadences are well-tested and the bandit has no direct published\n # ablation); `thorough` turns it on.\n operator_bandit: bool = False\n # P1 hypothesis ledger: ask the Researcher to state the one-line hypothesis each experiment tests,\n # register deep-research directions as hypotheses, and track them to a verdict on the board. ON by\n # default. The board is shown to the Researcher and can be foresight-ordered, so it can\n # steer later proposals even though it never directly re-ranks already-evaluated nodes. Set False\n # to drop the prompt nudge + registration.\n track_hypotheses: bool = True\n # E4/M2/M3 reflection-memory -> priors (gradient-free cross-run meta-learning): at run end distill\n # the winner into `<memory_dir>/meta_notes.jsonl` AND structured lessons (incl. NEGATIVE results:\n # tested/abandoned hypotheses + failure themes) into `lessons.jsonl` with a task fingerprint; at run\n # start, inject exact-task notes + fingerprint-matched lessons from SIMILAR tasks into the proposal\n # prompt. ON by default — but a NO-OP until `memory_dir` is set (that's where cross-run memory lives).\n reflection_priors: bool = True\n # M6 comparative lessons (MARS \"comparative reflective memory\", doc 13 §7 item 2): distill\n # credit-assigned lessons from PAIRS of solutions — which SPECIFIC change made a child beat\n # (or regress from) its parent, and what fixed a failure — on top of the one-shot run-end\n # reflection. At most one extra LLM call per distillation (all pairs batched); offline a\n # deterministic param-diff credit stands in. Gated on reflection_priors + memory_dir like the\n # rest of cross-run memory — and since memory_dir has a default (~/.looplab/memory), this is\n # ACTIVE out of the box; clear memory_dir to disable cross-run memory wholesale.\n comparative_lessons: bool = True\n # M6 mid-run cadences (doc 13 §7 item 5 — the AgentRxiv live-share pattern): every N created\n # nodes, WRITE comparative lessons to the shared cross-run store during the run (lessons_every\n # — one batched LLM call per firing) and RE-READ the store so lessons distilled by CONCURRENT\n # runs reach this run's proposals (lessons_refresh_every; file re-read, no LLM call, skipped\n # when the store is unchanged). ON by default (every 4 nodes) and, like comparative_lessons\n # above, live out of the box because memory_dir defaults on; 0 = off — run-end write /\n # run-start read only, the pre-M6 behavior.\n lessons_every: int = Field(default=4, ge=0)\n lessons_refresh_every: int = Field(default=4, ge=0)\n # B3 output redaction: mask credentials (known key shapes + high-entropy tokens) in the\n # stdout/stderr tail before it is persisted to the event log / spans / UI — a leaked secret in a\n # print()/traceback must not land in the durable log. Off by default; recommend on for untrusted.\n redact_output: bool = False\n # B5 reward-hacking detector: a host-side monitor that flags suspicious wins (grader/answer-key\n # access, runtime writes to frozen files, suspiciously-perfect metrics) as a `reward_hack_suspected`\n # audit event in the Trust panel. Off by default. Whether a flag CHANGES selection is governed by\n # `trust_gate` below (default: audit-only, never changes selection).\n reward_hack_detect: bool = False\n # Trust enforcement (T2): what a reward-hack / data-leakage flag actually DOES to selection.\n # \"audit\" (default) = surface it in the Trust panel, never change selection (today's behavior);\n # \"gate\" = a flagged node can no longer be selected as BEST **and isn't bred/confirmed from**\n # (breed_excluded — see replay._apply_trust_gate / models.breedable_nodes), but stays\n # FEASIBLE so it still counts for diversity/audit — closes the \"a hacked/leaky node can\n # win OR seed its lineage\" hole;\n # \"block\" = additionally mark it fully INFEASIBLE (removed from feasible_nodes entirely).\n # Deliberately gates only high-precision CHEATING/LEAKAGE signals. Broad `critic:*` findings stay\n # advisory; `critic:hardcoded_metric` is the one narrow high-precision critic exception\n # and gates because it requires a literal metric with no computed assignment anywhere.\n trust_gate: str = \"audit\"\n # I3 data-centric: static code-leakage scan of each evaluated solution (fit-before-split,\n # fit-on-test) surfaced into the Trust panel alongside reward-hack flags. Off by default.\n code_leakage_detect: bool = False\n # 4.4 sandbox instrumentation (needs reward_hack_detect): after each eval, flag a RUNTIME write\n # to a protected/frozen file (an answer key or grader tampered mid-run) — behavioral evidence a\n # static code scan misses. ON by default; only fires when the reward-hack detector is on.\n workdir_audit: bool = True\n # D8 decoupled research Verifier: check every Deep-Research memo's CLAIMS against their cited\n # evidence (node ids / urls) before recording — synthesis is the documented weak link (Kosmos\n # 57.9% accurate). Deterministic layer always; an LLM rubric pass when the DeepResearcher has a\n # client. The verdict cannot change this run's node/champion, but finalize uses aligned supported\n # verdicts as the evidence gate for persisted D8 cross-run claims; unavailable/misaligned stays\n # unverified. ON by default (no-op with no memo/claims).\n research_verify: bool = True\n # C4 independent critic: an execution-free critic of each solution (stub / hardcoded-metric /\n # params-ignored; on host-graded tasks the metric checks become a submission-output check)\n # surfaced in the Trust panel. Broad findings are advisory; `critic:hardcoded_metric` can gate under\n # trust_gate=gate/block. Off by default.\n critic_check: bool = False\n # A7 Strategist (NEW, user-requested): optional LLM/rule meta-controller that picks the search\n # policy/allocator + operator mix + fidelity (+ Developer backend) per situation. Config-first:\n # every choice the Strategist makes is also a direct knob. Defaults to \"agent\" so the agent adapts\n # the search policy/operators/fidelity per situation; set \"off\" for static config-driven search.\n # \"agent\" = a tool-using Strategist that READS the run/data/sibling-runs/KB/memory (B1-guarded)\n # before deciding, instead of the single-shot \"llm\" call over aggregate stats.\n strategist_backend: str = \"agent\" # \"off\" | \"rule\" | \"llm\" | \"agent\" — default AGENTIC: the\n # Strategist READS the run/data/sibling-runs/KB/memory with tools before deciding, not a single-shot\n # call over aggregate stats. \"llm\" = the old non-agentic single-shot; \"rule\"/\"off\" = no LLM.\n strategist_every: int = Field(default=3, ge=1) # consult cadence (created nodes)\n # PART V (F1): concept CLASSIFIER re-tag + consolidation cadence, DECOUPLED from strategist_every. The\n # LLM concept map is heavier and slower-moving than a strategy consult, so it refreshes on its own\n # (sparser) interval. Researcher-authored idea.concepts still fold immediately at node_created — this\n # only paces the classifier-evidence + consolidation refresh (and the concept-coverage pivot snapshot).\n concept_retag_every: int = Field(default=30, ge=1)\n # === Confirmation & holdout ===========================================================\n # Multi-seed confirmation (I12, ADR-15): confirm the top-k under N seeds before\n # finishing. 0 disables (default). Only meaningful when eval has variance.\n confirm_top_k: int = 0\n confirm_seeds: int = 0\n # D1 seed-holdout discipline: the FIRST seed the confirm phase uses. Search evals run with the\n # implicit seed 0 (LOOPLAB_EVAL_SEED unset -> \"0\"), so a base of 1 keeps every confirm split\n # DISJOINT from anything the search optimized against — the confirm metric is then a\n # generalization signal, not a re-measurement (AIRA: selecting on a signal the search saw\n # overfits by 9-13 pp). Set 0 to restore the legacy overlapping seeds 0..N-1.\n confirm_seed_base: int = Field(default=1, ge=0)\n # D1 holdout-gated promotion (B6, Arbor-style): for host-graded tasks, reserve this fraction of\n # the held-out labels as a FINAL holdout partition the search never sees — every search/confirm\n # eval is scored on the remaining rows only, and at finish the val-top-k are re-scored on the\n # holdout partition (free: the predictions already exist). 0.0 = off (legacy full-label scoring).\n holdout_fraction: float = Field(default=0.25, ge=0.0, le=0.9)\n # Champion selection prefers the HOLDOUT metric among the nodes that have one (the val-top-k).\n # Recorded in run_started so replay applies the same rule; old logs fold to False (legacy pick).\n # The per-node val-holdout `generalization_gap` folds into the Trust panel either way.\n holdout_select: bool = True\n # How many val-leaders get a holdout evaluation at finish (host-graded tasks).\n holdout_top_k: int = Field(default=3, ge=1)\n # R1-c / Part IV — calibrated §12-verifier tie-break in best-selection. When a fresh node's robust\n # metric EXACTLY TIES an already-eligible node, the engine runs the §12 verifier (grounded on each\n # node's realized result, `selection_criteria`) and records a soundness score per node. The tie-break\n # then applies across the WHOLE selection path: the mean pick breaks a robust_metric tie by the score,\n # and — when `holdout_select` is on — the holdout-slot ranking prefers the sounder node so it isn't\n # denied a holdout eval, and the final holdout override breaks a holdout_metric tie by it too. Strictly\n # a TIE-BREAK — it can NEVER move a node ahead of a strictly-better metric (§21.7 advisory-never-\n # overrides). Best-effort: verification is LAZY (only fires on an actual tie) and bounded per cadence,\n # so a large/late tie stays on deterministic metric+id order unless one atomic treatment scores every\n # member. Recorded in run_started so\n # replay applies the same rule; old logs fold to False (legacy pick). Opt-in (default off); needs a\n # reachable LLM client. Enable only after validating calibration offline with `verifier.calibrate`.\n # See trust/verifier.py + core/fitness.py.\n select_verifier: bool = False\n # R1-d (§21.19): widen the verifier tie-break from EXACT-metric to a conservative STATISTICAL tie.\n # The band is capped by both the metric leader's SE and the pooled SE-of-difference, so challenger\n # variance cannot widen it and a significant difference is never a tie (§21.7). Off -> exact only.\n verifier_ci_tie: bool = False\n # Verifier sample count for `select_verifier` (the §12 repeated-sampling expectation). 3 tames the\n # single-shot variance; 1 = single-shot (cheaper, noisier).\n select_verifier_samples: int = Field(default=3, ge=1, le=32)\n # Budget (I13): hard wall-clock ceiling; the run aborts cleanly when exceeded.\n max_seconds: float | None = None\n # Eval-compute budget (#2): hard ceiling on cumulative time spent INSIDE evals (training\n # runs), separate from wall-clock. Survives resume (summed from the event log). The real\n # guard against a silent multi-hour sweep when an eval is a minutes-hours training run.\n max_eval_seconds: float | None = None\n # Cross-run memory (I19, ADR-10): if set, the best result of each run is stored as\n # a case here, and the cases become retrievable knowledge for future runs.\n memory_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"memory\"))\n # HITL (I21, ADR-11): pause for human approval of the final best before finishing.\n require_approval: bool = False\n # Diversity archive (I22): niche bucket width in parameter space.\n archive_resolution: float = 1.0\n # Coverage read-model (narrowing signal): at the strategist cadence, compute a breadth summary\n # (themes / param-niches / theme entropy / dominant-theme fraction) from the folded run, record\n # it as a `coverage_snapshot` audit event, and surface it into the Strategist's decision context.\n # Deterministic, cheap, and purely additive context — no search-behavior change on its own. On by\n # default (like the rest of the always-on situational context). See search/coverage.py.\n coverage_context: bool = True\n # PART IV Phase 2a live steering (§21.11/§21.13). When on, the `concept_retag_every` cadence (NOT\n # `strategist_every` — the producer gates on `_should_consult_concepts`, which uses the seed boundary\n # + `concept_retag_every`, default 30) records a\n # concept-graph coverage + uncovered-region snapshot (deterministic heuristic tagger over the\n # task-type skeleton) and, on an `explore` stance, the Researcher's novelty hint names the exact\n # uncovered regions (\"0 coverage in {negatives/external-mining, distillation} — go there\") instead\n # of the vague \"broaden\". ON by default in the product Settings (ce4a379), EngineOptions off: it\n # only enriches an already-mode-gated explore hint, never forces exploration, and no-ops for a task\n # with no curated concept skeleton. The snapshot is auditable, but its prompt cue can change future\n # candidate generation; it does not directly rewrite best-metric selection. See search/concept_graph.py.\n # DESIGN NOTE (2026-07-17 critique): now ON unconditionally, but a toy / non-ML task yields EMPTY concept\n # tags while still paying the per-node LLM concept cost (~10 calls/node with the full Part IV/V bundle).\n # Consider AUTO-QUIESCING: if the heuristic tagger returns empty for the first N nodes (no concept signal),\n # skip the LLM concept work for the run — keeps it on for ML tasks, free on toy/degenerate ones.\n concept_pivot: bool = True\n # PART IV Phase 2b — D3 graded novelty into the LIVE novelty gate (§21.4/§21.13). When on and the\n # task has a curated concept skeleton, the novelty gate first grades a proposal over the concept\n # graph: a level-4 \"same direction, DIFFERENT implementation\" or a level-5 \"re-opens a\n # wrongly-abandoned FAILED direction\" proposal is ALLOWED through unchanged — the flat LLM/semantic\n # dedup gate can't tell \"this DCL tweak\" from \"the whole DCL branch\" and would wrongly reject a\n # legitimate variant or never re-open a sound-but-killed direction (the node_63 archetype). Levels\n # 1/2/3 (identical / near-dup / prior-run) still fall through to the existing gate. Deterministic\n # (heuristic tagger, no LLM), audit event `novelty_graded`, replay-safe. ON by default in the product\n # Settings (ce4a379), EngineOptions off; no-ops for a task with no skeleton. See search/graded_novelty.py.\n graded_novelty: bool = True\n # PART IV Phase 2b — D7 capability-expansion forced-jump DIRECTIVE (§21.8/§21.13, issue #7). When on\n # and the concept-graph cadence detects action-space LOCK-IN (the search has stayed inside one D5\n # branch for a long consecutive streak) on an `explore` stance, the Researcher's novelty hint\n # ESCALATES from \"broaden\" to \"expand the ACTION SPACE — build the missing infra (external mining,\n # a new data pipeline, a different eval), do NOT swap another {locked} lever.\" The same\n # gate also stamps the proposal's authoritative operator as `expand`; that node competes normally\n # under SearchFitness and its separate operator yield is measured. It does not force a winner.\n # Reads the 2a concept-coverage snapshot, so it needs `concept_pivot` to record one. OPT-IN\n # (default off). See engine/proposal_cues.py + search/lock_in.py.\n capability_expansion: bool = False\n # PART IV cross-run Step 0 (§21.20.12/§21.20.13). Universal task-fingerprint tokenization: drop the\n # ASCII-only `[a-z0-9]` allowlist on goal keywords so a non-Latin goal (Russian, CJK, …) is NOT\n # silently dropped from its cross-run fingerprint (today such a goal keys only on kind/dir/metric and\n # never reaches SIMILAR-task priors/lessons/cases). Uses `[^\\W_]+`/`.casefold()` — same splitting as\n # before, any script. ON by default in the product Settings (ce4a379); the bare-library EngineOptions\n # default stays off, so a run pinned to it won't silently re-key a portfolio mid-flight. See engine/memory.py.\n fingerprint_universal: bool = True\n # PART IV cross-run Step 2 (§21.20). Fill graded-novelty's `prior_concepts` from the cross-run\n # ConceptCapsuleStore: at run end write a per-run concept capsule (the shipped `node_concepts` tags +\n # best outcome, keyed by task_fingerprint) to `memory_dir`; when a later SIMILAR run proposes an idea\n # whose concept was tried before, SURFACE the prior outcome as a `cross_run_prior` audit event (never\n # reject — D3 level 3). Audit-only, off the selection path; ON by default in the product Settings\n # (ce4a379), EngineOptions off. Needs a `memory_dir` to share capsules.\n # See engine/memory.py (ConceptCapsuleStore) + engine/novelty.py.\n # PREREQUISITES (this flag is NOT standalone): the WRITE needs per-run concept tags\n # (`node_concepts`, produced by `concept_pivot`/F1 tagging) — no tags => no capsule; the SURFACE path\n # runs inside the graded-novelty precheck, so it also needs `graded_novelty` on. With only this flag +\n # `memory_dir` it persists/surfaces nothing. Enable `concept_pivot` + `graded_novelty` alongside it.\n # A non-Latin (Russian/CJK) portfolio should ALSO set `fingerprint_universal`, else the capsule's goal\n # tokens are dropped and its fingerprint over-matches on kind/dir/metric alone (the novelty direction\n # gate narrows this, but goal-word similarity is still lost).\n cross_run_concepts: bool = True\n # PART V (B): run-base + node-DELTA concept authoring. When on, once the first evaluated node has\n # authored concepts the engine seeds RunState.run_base_concepts from them (a one-shot EV_RUN_CONCEPTS,\n # idempotent because it only fires while the base is empty), and the Researcher's proposal hint tells it\n # to author only `concepts_added`/`concepts_removed` vs the run base + its parent — minimal per-node\n # annotations that inherit down the DAG (a swap propagates). Off -> every node authors its full concept\n # set as before. Product-on; the bare EngineOptions mirror defaults off (test_options_divergence).\n concept_run_base: bool = True\n # PART IV cross-run Step 5 advisory (§21.20.5). Fold the bounded cross-run CONTEXT PACK — evidence-\n # grounded claims with BOTH support and counter-evidence (Step 4) + a portfolio-coverage line (Step 3) —\n # into the Researcher's proposal prompt, exactly like the E4 cross-run prior note. Advisory ONLY: it is\n # prompt-grounding: it can change the Researcher's later candidates, but does not directly rewrite\n # best-metric selection (§21.7). Reads `memory_dir` (lessons.jsonl + concept_capsules.jsonl); \"\" when\n # empty. ON by explicit experimental product choice; no frozen A/B validation is claimed. The\n # bare-library EngineOptions default stays off (engine/options.py, frozen in\n # test_options_divergence). See engine/proposal_cues.py.\n cross_run_advisory: bool = True\n # PART IV cross-run §21.20.13 (full CR of the lean fuzzy claim merge). Switch the claim read-model to the\n # SCOPE+POLARITY-safe STRUCTURED claim key (engine/claim_key.py): claims from different tasks never\n # merge, opposite polarity (\"X helps\" vs \"X never helps\") is surfaced as a CONTRADICTION instead of being\n # collapsed, paraphrase/inflection variants group by exact structured key (no transitive over-merge), and\n # operator governance is scope-precise (a decision in task A cannot reach a same-worded claim in task B).\n # Affects the `cross_run_advisory` context pack only; ON by default in the product Settings (ce4a379);\n # the bare-library EngineOptions default stays off (engine/options.py). See engine/claims.py.\n cross_run_structured_claims: bool = True\n # PART IV cross-run §22.4 (AGENTIC taxonomy steward). At finalize, when an LLM client is available, let\n # the concept steward (engine/concept_steward.py) review the freshly-updated portfolio concept graph and\n # PROPOSE a curation — merge duplicate slugs / split conflated ones / purge noise. Proposals are LOGGED to\n # `concept_curation_log.jsonl` for operator ratification (via the serve/CLI governance surface); the LLM\n # never mutates fold state. The calls run synchronously during finalize, so they add latency and may add\n # paid inference; a caught steward failure does not prevent terminal completion or trigger a retry.\n # ON by explicit experimental product choice when `memory_dir` + an LLM backend are present; the\n # bare-library EngineOptions default stays off (engine/options.py). See engine/lessons.py.\n cross_run_curation: bool = True\n # Deprecated compatibility flag. Steward output is untrusted and finalize is proposal-only regardless\n # of this value; retained so old snapshots still validate and logs can disclose that auto was requested.\n cross_run_curation_auto: bool = False\n # Role backend (ADR-7/14): \"toy\" (offline optimizer) | \"llm\" (live model). Validated in\n # `_check_trust_gate` alongside the other enum-ish strings — every consumer tests `== \"llm\"`\n # exactly (cli/__init__.py, adapters/tasks.py), so an untyped `--set`/file/env typo (or a mis-cased\n # \"LLM\") used to fall through to the OFFLINE toy roles and quietly produce a run that never called\n # the model. Left as `str`, not `Literal`, so old snapshots still validate through the same path.\n backend: str = \"toy\"\n # Developer backend (ADR-7): \"default\" (templated/LLM from the task) or an external\n # CLI coding agent: \"opencode\" | \"aider\" | \"goose\" | \"continue\".\n developer_backend: str = \"default\"\n # C2 best-of-N: generate N candidate implementations per node and keep the best by an\n # execution-free reward (static validity + metric-print). 1 = off. In-house LLM developer only\n # (not external agents — ADR-7 cost rule). The top SWE-bench reliability lever for weak models.\n best_of_n: int = 1\n # D10 list-wise best-of-N selection: when the top static-scorers TIE, break the tie with a\n # comparative LLM selection over the candidates presented together (+~3 pts vs pointwise on\n # GAIA, arXiv:2506.12928). The static score stays the primary filter — the LLM is a weak\n # comparative prior, never the sole oracle. ON by default (no-op when best_of_n==1).\n best_of_n_listwise: bool = True\n # === Foresight / predict-before-execute ===============================================\n # FOREAGENT predict-before-execute (arXiv:2601.05930): use the LLM as an IMPLICIT WORLD MODEL to\n # predict — WITHOUT executing — which candidate / hypothesis will score best, primed with a\n # Verified Data Analysis Report (data profile / brief) + the accumulated experiment memory. Master\n # switch, ON by default; a no-op wherever there is nothing to rank (best_of_n==1 and\n # foresight_panel==1) or no LLM client. See `looplab/search/foresight.py`.\n foresight: bool = True\n # Hypothesis foresight breadth: generate K candidate ideas per proposal and keep the one PREDICTED\n # best pre-execution by the world model — it ranks the STRUCTURAL / text ideas the numeric k-NN\n # surrogate (researcher_panel) is blind to, primed with the data profile + memory (the synergy).\n # >1 enables it (LLM backend only; needs `foresight` on); 2 = on by default at modest cost, 1 = off.\n foresight_panel: int = 2\n # AGENTIC foresight: run the ranking (hypothesis-board prioritization + K-idea pick) as a TOOL-USING\n # loop that can pull actual experiment results / data facts before deciding, instead of a one-shot\n # prediction from a pre-baked report. ON by default (needs foresight + a client; falls back to the\n # one-shot predictor on any hiccup). A few extra LLM calls per proposal; set False for the cheaper\n # single-call ranker.\n foresight_agentic: bool = True\n # §1 foresight confidence gate: the minimum predicted confidence at which a predict-before-execute\n # pick is ACTED ON. Below it the ranker abstains — the K-idea panel falls back to the first\n # proposal, best-of-N falls through to the D10 tie-break — instead of committing a low-confidence\n # choice. 0.0 (default) = OFF (act on every pick, the historical behavior); raise toward ~0.5 to\n # make the world model defer when it isn't sure. Pairs with the foresight track record (§1) the\n # predictor is now primed with. Range 0.0-1.0.\n foresight_min_confidence: float = 0.0\n # PART IV Phase 2c — replace the world model's SELF-REPORTED confidence (measured Pearson≈0 with the\n # realized outcome, §21.12) with a CALIBRATED §12-verifier score. When on and a client is available,\n # after the K-idea ranker picks the predicted-best candidate the §12 verifier scores it (grounded +\n # repeated + criteria-decomposed: `foresight_criteria` — likely to improve the objective, and\n # sound/feasible) and THAT calibrated score becomes the confidence the `foresight_min_confidence` gate\n # and the telemetry use. Degrades to the self-reported confidence without a client or on any verifier\n # error (the telemetry records which source was used). ON by default in the product Settings (ce4a379),\n # EngineOptions off; a few extra LLM calls per acted-on proposal. See looplab/trust/verifier.py + search/foresight.py.\n # REVISIT (cost/quality, not yet resolved): a live classification run measured ~10 LLM calls & ~3.8 min/node\n # on deepseek-v4-flash with the full Part IV/V default bundle; the calibrated verifier is part of that per-node\n # tax. Kept ON deliberately — it raises decision quality (self-reported foresight confidence has ≈0 Pearson\n # with realized outcome), and lowering quality to save calls is the wrong trade. TODO: run a frozen quality/cost\n # A/B (verifier-on vs -off) to quantify the recall@objective gain per extra call before considering a default flip.\n foresight_verify: bool = True\n # Verifier sample count for `foresight_verify` (the §12 repeated-sampling expectation on a no-logprob\n # backend). 3 tames the single-shot variance §21.12 measured; 1 = single-shot (cheaper, noisier).\n # RESUME-critical: this value is persisted in config.snapshot.json, so a hard `le` schema bound would\n # make an old run started with a larger LOOPLAB_FORESIGHT_VERIFY_SAMPLES un-resumable (Settings(**snapshot)\n # would raise on `looplab resume` / finalize-recovery / serve replay). CLAMP into [1, MAX] via a validator\n # instead of rejecting — the runtime cap is preserved without breaking the snapshots/env-compat rule.\n foresight_verify_samples: int = 3\n\n @field_validator(\"foresight_verify_samples\")\n @classmethod\n def _clamp_foresight_verify_samples(cls, v: int) -> int:\n # Coerce a persisted/env value into [1, MAX] rather than reject (see field note above).\n try:\n v = int(v)\n except (TypeError, ValueError):\n return 3\n return max(1, min(v, MAX_FORESIGHT_VERIFY_SAMPLES))\n\n # T7 LLM response cache: serve an identical DETERMINISTIC (temperature 0) request from an\n # in-process content-addressed cache instead of re-hitting the model — cuts cost on\n # retry/panel/verify flows. NEVER caches sampling calls (temp>0: best-of-N / panel / novelty\n # re-propose must vary). Off by default (most role calls run at temperature>0 anyway).\n llm_cache: bool = False\n agent_cmd: str | None = None # override the agent's launcher (path / wrapper)\n # External-agent validation (ADR-7): wrap a CLI-agent Developer with a validator that\n # audits each output (no-op/syntax/crash/timeout), retries with feedback, and falls\n # back to the in-house LLM Developer. Off -> use the raw agent output unchecked.\n validate_agent: bool = True\n agent_max_retries: int = 1 # re-prompts of the agent on an invalid result\n # Patch-gated multi-file agent (ADR-7 Rule 3): run the CLI agent in a git worktree and\n # accept only edits whose paths match `agent_surface` (reject-not-strip out-of-surface\n # touches). Lets the agent create helper modules, not just solution.py. Degrades to\n # whole-file readback if git is unavailable.\n agent_patch_gate: bool = True\n agent_surface: list[str] = [\"*.py\"] # edit-surface allow-list (globs)\n # Trust policy for an agent-authored eval/metric adapter (RepoTask onboarding, Phase 3):\n # \"ratify_freeze\" waits for human approval, \"autonomous\" accepts the proposed spec without that\n # gate, and \"ratify_freeze_drift\" also enforces the configured independent drift cross-check.\n eval_trust_mode: str = \"ratify_freeze\"\n # RepoTask node seeding policy (run-wide fallback; a task/editable `seed_mode` overrides):\n # \"auto\" (default) copies git-tracked files when the editable is a git repo (so a tree bloated\n # with untracked artifacts isn't deep-copied per node), else a full copy; \"tracked\" forces\n # code-only; \"all\" forces a full recursive copy (legacy). Genesis authors per-task from the\n # user's words; this is the global default when unspecified.\n seed_mode: str = \"auto\"\n llm_model: str = \"qwen3:8b\"\n # === LLM / transport ==================================================================\n llm_base_url: str = \"http://localhost:11434/v1\" # Ollama OpenAI-compatible endpoint\n llm_temperature: float = 0.6\n # H3 per-role model presets (5090 recipe): optionally run the Researcher and Developer on\n # DIFFERENT models/endpoints (e.g. Developer=Qwen3-Coder-30B for code, a fast model for breadth).\n # Blank = use the shared llm_model/llm_base_url. Generalizes the role-backend swap to per-role.\n researcher_model: str | None = None\n developer_model: str | None = None\n researcher_base_url: str | None = None\n developer_base_url: str | None = None\n # Per-role sampling temperature (§4.1): the Researcher wants breadth (higher temp = more diverse\n # ideas), the Developer wants determinism (lower temp = fewer syntax slips), the Strategist a\n # steady hand. Each overrides the shared `llm_temperature` for that role only; None = use the\n # shared value (byte-identical to before). Flat + nullable, mirroring the per-role model fields.\n researcher_temperature: float | None = None\n developer_temperature: float | None = None\n strategist_temperature: float | None = None\n # The Strategist had per-role TEMPERATURE but no model/endpoint of its own, so in split mode it\n # always ran on the shared llm_model however the other roles were pointed. These close that gap\n # with the same names/shape as the pair above; blank = its profile, else the shared value.\n strategist_model: str | None = None\n strategist_base_url: str | None = None\n # === Connection profiles (multi-provider) =============================================\n # A \"profile\" is a NAMED connection: {model, base_url, temperature, api_key_env, provider}. Roles\n # point at one by name through `role_profiles`; `llm_profile` is the default for every role that\n # doesn't. All three are EMPTY by default and the resolver short-circuits to the shared\n # llm_model/llm_base_url/llm_api_key path when they are — an operator running one model never\n # needs to know the word \"profile\".\n #\n # A profile stores `api_key_env`, the NAME of an environment variable, never the key itself. That\n # is ADR-11's \"a secret is a reference, not a value\" turned from a convention into a data type:\n # the whole profile map is then safe to write into config.snapshot.json, hand back over HTTP and\n # render into LOOPLAB_* — there is nothing in it to mask. It is also what makes per-role\n # CREDENTIALS expressible at all: two roles on one provider can carry different keys, which a\n # key-per-endpoint map could not say.\n llm_profiles: dict[str, dict] = Field(default_factory=dict)\n llm_profile: str | None = None\n role_profiles: dict[str, str] = Field(default_factory=dict)\n # Unified self-driving agent: one LLM identity that plays Researcher + Developer (+ Strategist)\n # across pipeline stages, choosing its own model/toolset per stage. ON by default — the agent\n # drives the loop (incl. crash triage: repair/abandon/reject_idea) and, via\n # `agent_drives_actions`, picks the next macro action within a pure legal-action gate. Set both\n # to False for the legacy byte-identical split-role behavior. (No-op unless backend=\"llm\".)\n unified_agent: bool = True\n agent_drives_actions: bool = True\n # Layer 3 Card queue ownership. False preserves the existing policy/unified-pilot action path.\n # True is pinned in run_started because changing the selector on resume would mix two search\n # treatments in one run. When both this and agent_drives_actions are true, Card selection wins.\n card_driven_selection: bool = False\n # Layer 5 bounded speculative Card buffer. Zero is a hard OFF switch and preserves the\n # historical alternating build/eval spine; positive values are pinned by run_started so a\n # resume cannot silently mix search treatments after a snapshot/default edit.\n speculation_depth: int = Field(default=0, ge=0, le=64)\n # Local evidence receipt produced by ``looplab speculation-gate``. This is deliberately absent\n # from the curated Settings UI: positive-depth execution is a calibrated rollout decision, not a\n # casual tuning knob. The Engine validates the receipt against the current scorer,\n # implementation, GPU inventory and raw paired-run evidence before it enables speculation.\n speculation_gate_receipt: str | None = Field(default=None, max_length=4096)\n # Per-stage model/endpoint overrides for the unified agent. Recognized keys (see\n # tasks.build_unified_agent): `propose` (researcher), `implement`/`repair` (developer),\n # `strategy`, `pilot`. Unlisted keys are ignored. Empty = the per-role researcher_*/developer_*\n # models, then the shared llm_model. Explicit map wins. No-op unless `unified_agent` is true.\n agent_stage_models: dict = {}\n agent_stage_base_urls: dict = {}\n llm_parser: str = \"tool_call\"\n # Reasoning/thinking toggle sent IN the request (providers differ — see `llm.reasoning_body`):\n # Qwen3 on vLLM/SGLang -> chat_template_kwargs.enable_thinking (bool)\n # OpenAI/Ollama-v1/DeepSeek -> reasoning_effort (low|medium|high|none)\n # `llm_reasoning`: \"\" = send nothing (server default — current behavior); off = actively disable;\n # on = enable at default depth; low|medium|high = enable at that effort.\n # Defaults to \"high\" so the agent reasons before proposing/repairing/triaging (better code, fewer\n # crashes); set \"\" to send nothing (server default) or \"off\" to disable. The model's chain-of-thought\n # is CAPTURED either way (reasoning_content field or inline <think>). NOTE: to get a SEPARATE\n # reasoning_content field from SGLang/vLLM, the server also needs `--reasoning-parser qwen3`.\n llm_reasoning: str = \"high\"\n llm_reasoning_style: str = \"auto\" # auto | qwen | effort | none — how to shape the request param\n llm_reasoning_extra: dict = {} # raw fields merged into the body (escape hatch, e.g. Anthropic thinking)\n # H1: drive structured calls with the endpoint's constrained decoding (vLLM/SGLang guided_json +\n # response_format json_schema) so weak models can't emit invalid JSON. Off for Ollama (default).\n llm_guided_json: bool = False\n # Stream every LLM request (SSE) and reassemble it, so the request `timeout` is an INTER-TOKEN\n # idle timeout rather than a whole-request deadline: a long-but-alive generation is never cut off\n # (tokens keep resetting the timer), while a genuinely STALLED endpoint (no token for `timeout` s)\n # is detected and retried on a fresh connection — the fix for silent multi-minute hangs. Set False\n # to use one blocking read (old per-op timeout semantics) if an endpoint streams badly.\n llm_stream: bool = True\n # LLM IDLE timeout (seconds). Stream mode: the INTER-TOKEN stall limit — a steady generation is\n # never cut off, only a silent endpoint. Non-stream: bounds the whole read. Raise for endpoints\n # with a long prefill on huge prompts; lower to fail fast on a flaky shared endpoint.\n llm_timeout: float = Field(default=180.0, gt=0)\n # First-byte (response-headers) window for STREAM attempts, seconds: an SSE response sends its\n # headers on admission, so no-headers ≈ a black-holed request — fail over to a fresh connection\n # fast instead of waiting the full idle timeout. Clamped to llm_timeout. Non-stream attempts are\n # NOT bounded by this (their headers arrive only after the whole generation). The default mirrors\n # `DEFAULT_HEADER_TIMEOUT_S` in looplab/core/llm.py is the single source (imported above).\n llm_header_timeout: float = Field(default=DEFAULT_HEADER_TIMEOUT_S, gt=0)\n # Honour HTTP(S)_PROXY / NO_PROXY / SSL_CERT_FILE env vars for LLM calls. Default False: the\n # transport connects DIRECTLY (many internal/OLAP endpoints are reachable only by bypassing an\n # ambient proxy — a picked-up proxy yields \"connection refused\"). Set true when the endpoint is\n # reachable ONLY via a corporate proxy or needs a custom CA bundle from the environment.\n llm_trust_env: bool = False\n # H4/C2: compact the growing agentic tool-call history once it exceeds this many chars (auto_summary\n # LLM-summarizes the stale middle; else middle-truncate). Sized to the model's real context window so\n # files read earlier STAY in context instead of being compacted away and re-read: ~1,000,000 chars ≈\n # 250k tokens (≈4 chars/token), matching the configured deepseek-v4-flash (≥200k tokens confirmed).\n # The old 120k-char (~30k-token) default was ~8× too aggressive and drove a read-thrash loop. 0 = off.\n context_budget_chars: int = 1_000_000\n # M5: the Researcher's always-on experiments digest budget (chars). 0 = AUTO — scales with the\n # run size (60 chars/node, bounded [1200, 6000]) instead of one flat cap for an 8-node toy run\n # and a 200-node benchmark run alike. Set a positive value to pin it.\n digest_char_cap: int = Field(default=0, ge=0)\n # D11 compression model slot (open_deep_research's four-slot pattern): a dedicated CHEAP model\n # for agent-history summarization (C2 auto-summary), so compression doesn't pay the main\n # model's price. Blank = use each loop's own model (legacy). `compressor_base_url` blank =\n # the shared llm_base_url.\n compressor_model: str | None = None\n compressor_base_url: str | None = None\n # === Agentic tool-loop ================================================================\n # Agentic tool-loop limits — apply to EVERY tool-using agent (the LLM Researcher, the unified\n # agent's pilot + crash-triage, Deep-Research, the run-chat Boss, the genesis repo-scout, and the\n # cross-run report synthesizer). The loop lets the model call read-only tools across turns before\n # emitting its structured result. Both default to UNLIMITED so the agent takes as many turns / as\n # long as it needs — it is never cut off mid-reasoning; set a positive value here (or per-run, or\n # in the UI settings) to bound latency/cost. These used to be hardcoded per call site (e.g. the\n # boss at max_turns=3 / 45s), which silently dropped a slow reasoning model to a no-op reply.\n agent_max_turns: int = 0 # max tool turns before the emit is forced (0 = unlimited)\n agent_time_budget_s: float = 0.0 # wall-clock ceiling across the loop's turns (0 = no cap)\n # G · Soft convergence SAFETY NET (not a research cap). Two thresholds on the agent's tool-turn count:\n # at `agent_emit_after` the loop NUDGES the agent once (\"you've investigated enough — emit now\"); at\n # `agent_emit_force` it FORCES the emit from what was gathered. Set GENEROUSLY — a focused researcher\n # may legitimately read many files/experiments before it can propose a grounded idea; these only catch\n # a genuine runaway that keeps issuing DIFFERENT calls forever (which stuck-detection, keyed on\n # repeats, misses). NB: the old 499-read runaway (node 63) was 79% REDUNDANT re-reads caused by a\n # broken read_file — fixing read_file (pagination) is the real cure; this is just insurance. 0 = off.\n agent_emit_after: int = 300\n agent_emit_force: int = 500\n # B1 · No-progress / stuck detection — the safety net that makes \"unlimited turns\" safe. The\n # turn/time ceilings above are only BACKSTOPS; this is what actually stops a runaway loop, on the\n # cheapest signal: when the model repeats the SAME tool call (or ping-pongs between two, or keeps\n # hitting the SAME error) with no progress, the loop forces the final emit and finishes. ON by\n # default; reading DIFFERENT files or running ONE long command never trips it. (OpenHands-style.)\n agent_stuck_detection: bool = True\n agent_stuck_repeat: int = 4 # identical calls in a row that count as \"stuck\" (>=2)\n agent_stuck_alternate: int = 4 # ping-pong cycles between two calls that count as \"stuck\" (>=2)\n # C1 · Self-plan (TodoWrite-style): expose an `update_plan` tool so a long-running agent keeps its\n # OWN working TODO and re-surfaces it every `agent_plan_reinject_every` turns — keeps the goal in\n # view across a long loop so the agent \"writes its own context\" (Claude Code style). ON by default.\n agent_self_plan: bool = True\n agent_plan_reinject_every: int = 5\n # C2 · Auto-summary (ON by default): when the tool-loop history grows long, LLM-summarize the\n # stale middle instead of only middle-truncating it. The trigger is `context_budget_chars` if set,\n # else a built-in high-water mark (context_budget.DEFAULT_SUMMARY_CHARS ≈ 120k chars) so short\n # loops are untouched. Falls back to truncation on any summarizer error; one extra model call per\n # over-budget turn.\n agent_auto_summary: bool = True\n # C4 · Repo-developer plan decomposition. A big feature attempted in ONE developer session made a\n # non-converging reasoning model loop indefinitely — it kept writing + exploring without ever\n # emitting `done` (10k+ LLM calls / hours; agentic stuck-detection can't catch VARIED exploration,\n # only literal repeats). Fix: the developer first proposes an ordered plan of ATOMIC steps; a\n # multi-step plan is executed step-by-step, each in a FRESH bounded session with only that step's\n # scope + the files accumulated so far, syntax-validated per write. Every session stays\n # small-context and convergent. ON by default for repo tasks (a 1-step plan == the old single pass).\n developer_plan_decompose: bool = True\n developer_plan_min_steps: int = 2 # a proposed plan with >= this many steps runs step-by-step\n developer_plan_max_steps: int = 8 # cap on plan length (a runaway planner can't spawn 100 steps)\n # Hard per-session backstop for the repo developer — the ONE write-heavy agent that can run away\n # even with stuck-detection on (varied writes/reads never trip the repeat/ping-pong signal). Bounds\n # the plan phase, EACH step, and the single-session fallback. Unlike the global agent_* limits\n # (0 = unlimited, meant for read-only agents), the developer always gets a FINITE ceiling so a\n # model that never emits `done` fails cleanly with the code it wrote so far, instead of looping.\n developer_session_max_turns: int = 500\n developer_session_time_budget_s: float = 1200.0 # 20 min wall-clock per developer session\n # Phase-handoff summaries. Each LLM phase in a node build (Researcher·propose → Developer·stages →\n # plan → implement) ends with ONE extra LLM call that distills its transcript — the repo structure\n # it mapped, files/data confirmed, decisions made — into a brief injected into the NEXT phase (even\n # across the role boundary). The next phase TRUSTS that instead of re-reading the same files, which\n # cuts the biggest source of tool-call thrash (stages/plan/implement each re-exploring the repo).\n # One summary call per phase in exchange for many fewer read calls downstream. ON by default.\n phase_handoff_summary: bool = True\n # Observability (ADR-17): capture each LLM call's full prompt + completion into the active\n # span (spans.jsonl) so the UI can show exactly what the model read and wrote per node.\n # Diagnostics only — `replay.fold` never reads spans. Default on for local single-user; set\n # LOOPLAB_TRACE_LLM_IO=0 to suppress (e.g. to keep spans.jsonl small or avoid storing prompts).\n trace_llm_io: bool = True\n # Run-introspection tools (ON by default): make the LLM Researcher (and DeepResearcher) a\n # tool-using agent that can read its OWN experiments (list/read/find-analogous/themes/code) and\n # the task data (schema/profile/asset) mid-loop, instead of seeing only best+parent. Advisory —\n # never changes best-selection. Off = the legacy single-shot Researcher (richer digest still added).\n researcher_tools: bool = True\n # Cross-run introspection: give the agentic Researcher / pilot read-only tools to look at\n # SIBLING runs (same task_id, same run-root) — list them, read an experiment / its code, and\n # find analogous configs across runs — so a run can build on what neighbouring runs already\n # learned instead of rediscovering it. Advisory; never changes best-selection. Needs the run's\n # own dir wired through (no-op for unit-built roles). Off = the legacy single-run view only.\n cross_run_tools: bool = True\n # Read-only access to EVERY run on this machine ACROSS ALL TASKS (not just same-task siblings):\n # the Developer/Researcher get list_all_runs + read_run_code + read_run_experiment so they can read\n # the code + result of ANY past experiment anywhere and reuse an approach. Broader than\n # `cross_run_tools` (same-task only); the agent decides when a foreign run is relevant. Advisory;\n # never changes best-selection. Needs the run's own dir wired through (no-op for unit-built roles).\n all_runs_tools: bool = True\n # PART V §22 — read-only CROSS-RUN KNOWLEDGE tool for the reasoning roles (Researcher, Strategist,\n # deep-research, and a Developer-scoped variant). Adds `cross_run_prior_attempts` / `cross_run_claims`\n # / `cross_run_atlas` over the §21.20 read-models (concept overview + claim assessments + atlas), read\n # from `memory_dir` (lessons.jsonl + concept_capsules.jsonl). Read-only is a storage-authority\n # boundary, not a behavioral no-op: retrieved content can steer later proposals, and a tool-using\n # reasoning loop can consume additional model calls. The tools never mutate cross-run truth (facts\n # are engine-written, verdicts operator-ratified, §22.4) or directly select a winner. ON by explicit\n # experimental product choice, EngineOptions off; stores only exist once Part-IV features have run.\n cross_run_read_tools: bool = True\n # Agentic retrieval (ADR-16): if set, the LLM Researcher gets grep/kb_search/read\n # tools over this directory of markdown notes and chooses when to use them.\n knowledge_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"knowledge\"))\n # T4 real embeddings: model id for an OpenAI-compatible `/embeddings` endpoint used for\n # kb_search / case retrieval. Blank (default) = the dependency-free lexical `hash_embed` (today's\n # behavior). Set e.g. \"nomic-embed-text\" (Ollama) or \"text-embedding-3-small\" for SEMANTIC\n # retrieval; a misconfigured/offline endpoint degrades back to hash_embed at call time (never\n # crashes a run). `embed_base_url` blank = reuse `llm_base_url`; the shared `llm_api_key` is used.\n embed_model: str | None = None\n embed_base_url: str | None = None\n # Harmonic memory (Memora, ICML'26 — idea import). When ON, cross-run cases and knowledge notes are\n # indexed by a short ABSTRACTION + cue ANCHORS instead of their raw text, near-duplicate memories are\n # CONSOLIDATED into one entry under a matching abstraction, and `kb_search`/case retrieval EXPAND\n # through the top hits' anchors to surface related-but-not-similar memories. ON by default. Set\n # `memora=false` to restore the pre-Memora raw-text index. Never a source of truth — abstractions\n # live only in the derived, rebuildable retrieval index (never the event log or canonical cases.jsonl).\n memora: bool = True\n # Write abstractions with the wired chat model (richer than the deterministic lexical fallback). ON\n # by default; results are CACHED by content hash (see `memora_cache`) so a re-built index doesn't\n # re-call the model on unchanged notes/cases, and any endpoint failure degrades to lexical at call\n # time — so an offline box just gets lexical abstractions, never a crash. Set false to force lexical.\n memora_llm: bool = True\n # === Cross-run memory / Memora ========================================================\n memora_anchors: int = 6 # max cue anchors kept per memory\n # Cosine similarity at/above which a new case is CONSOLIDATED into an existing one (same evolving\n # topic) rather than stored as a separate entry. Only used when `memora` is on.\n memora_consolidate_threshold: float = 0.86\n # Where the LLM-abstraction cache lives (JSON). Blank (default) derives it from `memory_dir`\n # (`<memory_dir>/memora_cache.json`) or else `knowledge_dir` (`<knowledge_dir>/.memora_cache.json`);\n # with neither set the cache is in-memory only (per process). No-op unless `memora_llm` is on.\n memora_cache: str | None = None\n # E3 literature-grounded ideation (network-OPTIONAL): give the agentic Researcher an arXiv search\n # tool to ground ideas in real techniques. Off by default (egress is unreliable on some boxes);\n # fails gracefully if the network is blocked.\n literature_search: bool = False\n # Deep-Research stage (network-OPTIONAL): give the DeepResearcher a general web search/fetch\n # tool (DuckDuckGo) on top of arXiv to read across results + the web before steering the next\n # batch. Off by default (egress is unreliable on some boxes); fails gracefully when blocked.\n web_search: bool = False\n # Cadence for the Deep-Research stage: run it automatically every N created nodes. Zero disables\n # only the cadence; manual `deep_research` and Strategist `request_research` triggers still fire.\n # Default 3: deep research analyzes the accumulating results and steers the next batch (its\n # directions become hints + open hypotheses); paired with `concurrent_research` it overlaps the\n # think with the GPU-bound eval, so the cadence costs LLM tokens but no wall-clock on the search.\n deep_research_every: int = 3\n # Overlap a DUE deep-research \"think\" with the GPU-bound eval instead of running it in its own\n # serial step (the agent is otherwise idle while a node trains). research() computes from a state\n # snapshot; the joined background task records only allowlisted, order-tolerant\n # research/hint/hypothesis events as soon as the memo finishes. ON by default: the LLM is typically\n # remote (no GPU contention with eval), so overlapping hides latency behind long training.\n concurrent_research: bool = True\n # Concurrent-research REPEAT — don't idle a multi-DAY eval. The overlapped \"think\" fires ONCE per\n # window by default; ON here, it RE-RUNS on an adaptive time cadence for the WHOLE eval window\n # (a two-day training must not leave the reasoning agents idle after one memo). Self-paced: it\n # only records a memo whose CONTENT is new (identical re-runs are skipped, so the log/board don't\n # bloat) and backs off geometrically as the analysis converges, capped so it always re-checks.\n # The appended records never rewrite the current metric champion, but their hints/open hypotheses\n # deliberately steer later proposals and replay reconstructs that advice. The library default is\n # one-shot (== today, byte-identical); this product default turns the repetition on.\n concurrent_research_repeat: bool = True\n # Base cadence (seconds) between repeated concurrent-research passes. Research is EXPENSIVE\n # (multi-turn LLM + web/arXiv), so this is a FLOOR, not a ceiling: the effective cadence is\n # max(this, ~5% of the per-experiment time budget) — a two-day eval is re-researched about hourly,\n # a short eval not at all (the first tick outlasts it). Only matters when the repeat is on.\n concurrent_research_interval_s: float = Field(default=1800.0, gt=0)\n # Per-eval-window backstop on repeated-research LLM calls (0 = cadence-only, no cap). The adaptive\n # cadence + convergence backoff are the primary budget control; this bounds a pathological\n # never-converging window. Past it the loop stops calling the LLM (the health monitor still runs).\n concurrent_research_max_calls: int = Field(default=40, ge=0)\n # Concurrent hypothesis-board CONSOLIDATION during the eval window. Repeated research keeps ADDING\n # near-duplicate directions to the open board (each memo registers up to 5 as open hypotheses), so a\n # long eval's board bloats before the between-nodes merge runs. This dedups/merges it on the SAME\n # background loop so the board stays clean for the next proposal. In Card-driven mode the same merge\n # changes ownership/readiness, so it is deferred to the joined main-task cadence instead. Needs the\n # reflect client + `track_hypotheses`.\n # Only active while `concurrent_research` runs the overlap loop. Library default off (== today).\n concurrent_consolidate: bool = True\n # Cadence for the agent-authored run report: regenerate the conclusion-first narrative every N\n # created nodes (0 = off; it still regenerates on a manual `report_refresh` from the UI). The\n # deterministic report always renders from the node set regardless of this knob.\n report_every: int = 3\n # Agent Skills (I18, ADR-9): dir of SKILL.md the Researcher can list/load as tools.\n skills_dir: str | None = None\n # Prompt store (I18, ADR-8): dir of editable, hot-reloaded role prompt .md files.\n prompt_dir: str | None = None\n # API key as a reference, never serialized as a value (ADR-11). Local servers\n # ignore it; default to a placeholder.\n llm_api_key: SecretStr | None = None\n\n @model_validator(mode=\"before\")\n @classmethod\n def _reject_bool_for_numeric_fields(cls, data):\n \"\"\"A JSON boolean is a TYPE ERROR for a numeric setting, not the number 1.\n\n Pydantic runs in lax mode here (`model_config` sets no `strict`), and `isinstance(True, int)`\n is True — so `{\"max_nodes\": true}` from a UI/API type slip validated cleanly as a budget of\n ONE node, and `{\"eval_parallel\": true}` collapsed the width to one. The run then looked\n configured and simply did almost nothing, with no 422 and nothing in the snapshot to show why.\n Rejecting at the boundary covers every numeric field at once, including the many that carry no\n `Field(ge=...)` bound of their own to trip over.\n \"\"\"\n if not isinstance(data, dict):\n return data\n for key, val in data.items():\n if type(val) is bool and _is_numeric_annotation(\n getattr(cls.model_fields.get(key), \"annotation\", None)):\n raise ValueError(f\"{key} must be a number, not a boolean (got {val!r})\")\n return data\n\n @model_validator(mode=\"before\")\n @classmethod\n def _apply_profile(cls, data):\n \"\"\"Expand `profile` into a bundle of setting defaults BEFORE validation.\n\n pydantic-settings hands this validator the fully-merged input from ALL explicit sources\n (init kwargs + LOOPLAB_* env + .env), with fields the user left unset simply ABSENT (they\n fall to field defaults afterward). So a profile value is applied only when its key is not\n already present — i.e. any explicit file/CLI/env setting wins over the profile, and the\n profile wins over the bare field default. This makes `thorough` a true convenience preset,\n never an override of what the user asked for.\"\"\"\n if not isinstance(data, dict):\n return data\n prof = data.get(\"profile\", \"default\")\n # env may deliver the key as \"profile\" (env_prefix stripped) — normalize to a str\n prof = str(prof).strip().lower() if prof is not None else \"default\"\n overrides = PROFILES.get(prof)\n if overrides is None:\n raise ValueError(f\"unknown profile {prof!r}; known: {sorted(PROFILES)}\")\n for key, val in overrides.items():\n if key not in data: # explicit source (file/CLI/env) always wins\n data[key] = val\n return data\n\n @model_validator(mode=\"after\")\n def _check_trust_gate(self):\n if self.trust_gate not in (\"audit\", \"gate\", \"block\"):\n raise ValueError(\n f\"trust_gate must be audit|gate|block, got {self.trust_gate!r}\")\n if self.merge_mode not in (\"auto\", \"mean\", \"ensemble\"):\n raise ValueError(\n f\"merge_mode must be auto|mean|ensemble, got {self.merge_mode!r}\")\n # novelty_mode drives the dedup/novelty gate; an out-of-set value (e.g. a mis-cased\n # LOOPLAB_NOVELTY_MODE=LLM, or \"on\") used to fall through _apply_novelty_gate silently as a\n # NO-OP — turning the gate off with no diagnostic. Fail loudly at config time like trust_gate/\n # merge_mode do. (Other enum-ish string fields — seed_mode/eval_trust_mode/strategist_backend —\n # share the pattern; left unvalidated here only because their full value sets are less settled.)\n if self.novelty_mode not in (\"off\", \"algo\", \"llm\"):\n raise ValueError(\n f\"novelty_mode must be off|algo|llm, got {self.novelty_mode!r}\")\n # The remaining enum-ish string fields (arch-review §5 P3): a typo used to be accepted at\n # construction and only fail-safe/later-loud downstream (e.g. a mis-cased strategist_backend\n # silently ran the default). Fail loudly here like the fields above.\n if self.strategist_backend not in (\"off\", \"rule\", \"llm\", \"agent\"):\n raise ValueError(\n f\"strategist_backend must be off|rule|llm|agent, got {self.strategist_backend!r}\")\n if self.eval_trust_mode not in (\"ratify_freeze\", \"autonomous\", \"ratify_freeze_drift\"):\n raise ValueError(\n \"eval_trust_mode must be ratify_freeze|autonomous|ratify_freeze_drift, \"\n f\"got {self.eval_trust_mode!r}\")\n if self.seed_mode not in (\"auto\", \"tracked\", \"all\"):\n raise ValueError(f\"seed_mode must be auto|tracked|all, got {self.seed_mode!r}\")\n if self.backend not in (\"toy\", \"llm\"):\n raise ValueError(f\"backend must be toy|llm, got {self.backend!r}\")\n self._check_llm_profiles()\n return self\n\n def _check_llm_profiles(self) -> None:\n \"\"\"Validate the connection-profile maps LOUDLY, at construction.\n\n Assignment is not validated anywhere in this project, and these two maps are the kind of\n thing a typo makes into a silent no-op: an unknown role name would simply never be read, and\n a reference to a missing profile would fall back to the shared model while the operator\n believed a second provider was in play. Both are errors here instead.\n\n The `api_key_env` rules exist for a specific reason. It must LOOK like a secret to\n `runtime/sandbox.py::SECRET_ENV`, because that is the filter that strips the operator's keys\n out of the environment handed to generated candidate code — a key parked in a variable named\n `MY_AUTH` would be invisible to it and ride straight into the sandbox. (The pattern is\n duplicated below rather than imported: layering forbids `core` from importing `runtime`.\n `tests/test_secret_env_pattern.py` pins the two together.) And a literal key inside a profile\n is refused outright: the profile map is written verbatim into config.snapshot.json and served\n over HTTP precisely because it is supposed to contain no secrets.\"\"\"\n from looplab.core.llm import LLM_ROLE_KEYS\n if not isinstance(self.llm_profiles, dict):\n raise ValueError(\"llm_profiles must be a map of profile name -> profile\")\n for name, entry in self.llm_profiles.items():\n if not isinstance(entry, dict):\n raise ValueError(f\"llm_profiles[{name!r}] must be an object\")\n unknown = set(entry) - _PROFILE_FIELDS\n if unknown:\n raise ValueError(\n f\"llm_profiles[{name!r}] has unknown field(s) {sorted(unknown)}; \"\n f\"known: {sorted(_PROFILE_FIELDS)}\")\n for banned in (\"api_key\", \"key\", \"token\", \"secret\"):\n if banned in entry:\n raise ValueError(\n f\"llm_profiles[{name!r}] must not contain {banned!r}: a profile stores \"\n \"api_key_env (the NAME of an environment variable), never a key value — \"\n \"the whole map is written to config.snapshot.json and served over HTTP\")\n env = entry.get(\"api_key_env\")\n if env is not None and not _SECRET_ENV_NAME.match(str(env)):\n raise ValueError(\n f\"llm_profiles[{name!r}].api_key_env={env!r} is not a usable variable name: it \"\n \"must be UPPER_SNAKE and contain KEY/SECRET/TOKEN/PASSWORD/PASSWD/CREDENTIAL, so \"\n \"the sandbox's secret-name filter strips it from generated code's environment\")\n temp = entry.get(\"temperature\")\n if temp is not None and (isinstance(temp, bool) or not isinstance(temp, (int, float))):\n raise ValueError(f\"llm_profiles[{name!r}].temperature must be a number\")\n if not isinstance(self.role_profiles, dict):\n raise ValueError(\"role_profiles must be a map of role name -> profile name\")\n for role, profile in self.role_profiles.items():\n if role not in LLM_ROLE_KEYS:\n raise ValueError(\n f\"role_profiles has unknown role {role!r}; known: {sorted(LLM_ROLE_KEYS)}\")\n if profile not in self.llm_profiles:\n raise ValueError(\n f\"role_profiles[{role!r}] names profile {profile!r}, which is not in llm_profiles\")\n if self.llm_profile is not None and self.llm_profile not in self.llm_profiles:\n raise ValueError(\n f\"llm_profile={self.llm_profile!r} is not in llm_profiles\")\n\n def masked_snapshot(self) -> dict:\n # Snapshots are a JSON contract (they are written verbatim to config.snapshot.json), not a\n # Python-object dump. In particular tuple-valued schema fields such as\n # ``inline_repair_reasons`` must become JSON arrays before callers hash the runtime envelope;\n # otherwise the strict speculation-scope canonicalizer sees a different/non-JSON preimage\n # from the one that ``json.dumps`` eventually persists.\n d = self.model_dump(mode=\"json\")\n if self.llm_api_key is not None:\n d[\"llm_api_key\"] = \"***\"\n else:\n d[\"llm_api_key\"] = None\n return d\n\n\n# Pre-versioned snapshots are full Settings dumps, so absence is a reliable per-field version marker.\n# Keep their historical effective behavior when newer product defaults become active: re-entry must not\n# silently add paid calls, interventions, concurrency or a different selection policy to an old run.\nLEGACY_CONFIG_SNAPSHOT_DEFAULTS: dict[str, object] = {\n \"parallel_build\": 1,\n \"eval_parallel\": None,\n \"llm_parallel\": None,\n \"train_monitor\": False,\n \"train_monitor_interval_s\": 600.0,\n \"train_monitor_kill\": False,\n \"train_monitor_kill_confidence\": 0.8,\n \"asha_live\": False,\n \"asha_live_kill\": False,\n \"asha_live_quantile\": 0.5,\n \"asha_live_min_siblings\": 3,\n # Before this operator ceiling existed, the sandbox's 24-hour process limit was authoritative.\n \"max_eval_timeout\": 24 * 3600.0,\n \"watchdog_reflection\": False,\n \"card_driven_selection\": False,\n \"speculation_depth\": 0,\n \"speculation_gate_receipt\": None,\n \"concurrent_research_repeat\": False,\n \"concurrent_research_interval_s\": 1800.0,\n # Zero means unlimited; 40 was the first bounded default, and is latent while repeat is disabled.\n \"concurrent_research_max_calls\": 40,\n \"concurrent_consolidate\": False,\n}\n\n\ndef migrate_config_snapshot(data: dict) -> dict:\n \"\"\"Return an effective copy of one legacy/current snapshot without rewriting evidence bytes.\"\"\"\n if not isinstance(data, dict):\n raise TypeError(\"config snapshot must be a JSON object\")\n migrated = dict(data)\n for key, value in LEGACY_CONFIG_SNAPSHOT_DEFAULTS.items():\n migrated.setdefault(key, value)\n return migrated\n\n\ndef settings_from_snapshot(data: dict) -> Settings:\n # CODEX AGENT: snapshots have no schema/runtime version and Settings ignores unknown fields.\n # An older binary can silently drop newer behavior-affecting controls and resume the same event\n # history with different paid/concurrency/selection semantics. Version snapshots and fail closed\n # on an unsupported downgrade before migration/default filling.\n \"\"\"Build re-entry Settings from a masked snapshot under its historical missing-field contract.\"\"\"\n migrated = migrate_config_snapshot(data)\n migrated.pop(\"llm_api_key\", None)\n return Settings(**migrated)\n"}, "beb18654880410f228a4922f7dd793e505dee30c": {"t": 1785768758.6561673, "n": 102143, "h": "0f9552d562327014", "c": "\"\"\"Engine config: the schema (what settings exist). (I0, ADR-11)\n\nOne pydantic-settings class declaring every engine knob, its default, and its docs. The loader —\nhow a run file / CLI / env becomes a `Settings` — is `looplab.core.appconfig`, which also owns\nthe config-precedence order. A resolved, secret-masked snapshot is written next to each run for\nreproducibility. (No real secrets in P0, but the masking discipline is in place.)\n\"\"\"\nfrom __future__ import annotations\n\nimport types\nimport typing\nfrom pathlib import Path\n\nfrom pydantic import Field, SecretStr, field_validator, model_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n# Single source of the LLM first-byte (response-headers) default — see core/llm.py.\nfrom looplab.core.llm import DEFAULT_HEADER_TIMEOUT_S\n\n# Default home for cross-run memory + the knowledge base (shared across every run). Both are ON by\n# default now (a user asked for it) so a fresh install accumulates + consults knowledge with no setup;\n# set the env var to \"\" to disable, or to a path to relocate. `~/.looplab/` keeps them out of any one\n# project + shared across projects.\n_LL_HOME = Path.home() / \".looplab\"\n\n\ndef _is_numeric_annotation(ann) -> bool:\n \"\"\"True when a field's annotation admits int/float but NOT bool.\n\n Used by `Settings._reject_bool_for_numeric_fields`: pydantic resolves annotations at class build,\n so `model_fields[...].annotation` is a real type object here even under\n `from __future__ import annotations`. Optionals are numeric when every non-None member is\n (`int | None` yes, `int | str` no), so a boolean can never slip in through a union arm.\"\"\"\n if ann is bool:\n return False # a bool field legitimately takes a bool\n if ann is int or ann is float:\n return True\n args = [a for a in typing.get_args(ann) if a is not type(None)]\n origin = typing.get_origin(ann)\n if args and origin in (typing.Union, types.UnionType):\n return all(_is_numeric_annotation(a) for a in args)\n return False\n\n# Autonomous roles that may be granted per-setting write access (see Settings.agent_control).\n# strategist = A7 meta-controller (run-wide); boss = run-chat operator-proxy (run-wide);\n# researcher = per-experiment proposer (per-node sizing, e.g. eval timeout for a heavy model).\nAGENT_ROLES = (\"strategist\", \"boss\", \"researcher\")\n\n# A foresight score is computed for every acted-on proposal. Keep this lower than the generic\n# verifier's emergency ceiling so a typo cannot multiply paid synchronous calls across a run.\nMAX_FORESIGHT_VERIFY_SAMPLES = 8\n\n\n# Layer-2 parallelism renamed two public settings without removing the durable legacy fields. Keep\n# the equivalence in one place: config-source precedence, governance migration, and live strategy\n# application must all agree which names describe the same authority/value.\nPARALLELISM_ALIASES: dict[str, str] = {\n \"max_parallel\": \"eval_parallel\",\n \"parallel_build\": \"llm_parallel\",\n}\n\n\ndef parallelism_aliases(setting: str) -> tuple[str, ...]:\n \"\"\"Return every spelling in ``setting``'s alias family, canonical name first.\"\"\"\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if setting in (legacy, canonical):\n return canonical, legacy\n return (setting,)\n\n\ndef canonicalize_parallelism_source(\n values: dict, *, promote_build_to_llm_parallel: bool = False,\n) -> dict:\n \"\"\"Promote legacy parallel keys *within one config-precedence source*.\n\n Applying this only after file/CLI/env were flattened would let a lower-priority canonical env\n value shadow a higher-priority legacy file value (or a file canonical value shadow a CLI legacy\n override), so callers with sub-layers normalize each layer independently.\n\n ``max_parallel``/``eval_parallel`` are true aliases (pure eval width, no extra semantics) and are\n always promoted. ``parallel_build``/``llm_parallel`` are NOT: a positive ``llm_parallel`` also flips\n on the finite shared LLM broker (``orchestrator._startup_llm_total``), while legacy ``parallel_build``\n means \"legacy build width, unbounded overlap, no shared total\". So promoting a legacy-only\n ``parallel_build`` layer (e.g. just ``LOOPLAB_PARALLEL_BUILD=1`` in env) would silently serialize\n every provider call — contradicting llm_broker.py's docstring, orchestrator's \"legacy-only preserves\n unbounded overlap\" note, the broker-neutral live-control path, and the configuration.md ``llm_parallel``\n row (\"falls back ... without enabling a shared total\"). Config/startup loads therefore keep broker\n opt-in tied to an EXPLICITLY-spelled canonical ``llm_parallel``; the orchestrator's own width fallback\n (``llm_parallel if not None else parallel_build``) still carries the legacy build width. Only the\n Strategist live-control path (``canonicalize_strategy_parallelism``) opts into\n ``promote_build_to_llm_parallel``, where ``parallel_build`` is a deliberate full alias of the\n canonical knob (see strategy.py ``_apply_strategy``).\n \"\"\"\n if not isinstance(values, dict):\n return values\n out = dict(values)\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if canonical == \"llm_parallel\" and not promote_build_to_llm_parallel:\n continue\n # None is the durable legacy mode for llm_parallel, not a missing value. A normal snapshot\n # contains llm_parallel:null plus parallel_build:1; promoting null to 1 would unexpectedly enable\n # the global one-call broker. Promote aliases only when the canonical key is absent, and pin\n # snapshot round-trip parity. Canonicalize inside each precedence layer, never across the\n # flattened result, so the alias migration cannot invert CLI > file > env precedence.\n if canonical not in out and out.get(legacy) is not None:\n out[canonical] = out[legacy]\n # PRECEDENCE REPAIR for the non-promoted pair. Skipping promotion above is right (a legacy-only\n # layer must not flip on the shared broker), but it also let a LOWER-priority canonical value\n # survive a HIGHER-priority legacy override: file `llm_parallel: 2` + CLI `-s parallel_build=8`\n # flattened to BOTH keys, and the orchestrator prefers `llm_parallel`, so the operator's explicit\n # CLI width was silently discarded — exactly the inversion `build_settings`' docstring says this\n # per-layer canonicalization exists to prevent. Mask it with the LEGACY SENTINEL instead of\n # promoting: `llm_parallel=None` is the durable \"legacy mode — take the build width from\n # parallel_build, no shared total\", so the higher layer wins with the semantics it asked for.\n if (not promote_build_to_llm_parallel and \"llm_parallel\" not in out\n and out.get(\"parallel_build\") is not None):\n out[\"llm_parallel\"] = None\n return out\n\n# Settings whose semantics are committed by ``run_started`` and restored from the folded event log\n# on every re-entry. Changing one only in ``config.snapshot.json`` would mix incompatible evidence\n# (a different holdout split) or make the live producer disagree with replay's selection policy.\n# The HTTP config editor and the engine both consume this one contract; keep deliberately mutable\n# controls such as ``trust_gate`` out (that field has its own durable change event).\nRUN_START_PINNED_FIELDS = frozenset({\n \"card_driven_selection\",\n \"speculation_depth\",\n \"holdout_fraction\",\n \"holdout_select\",\n \"select_verifier\",\n \"select_verifier_samples\",\n \"verifier_ci_tie\",\n})\n\n\ndef run_start_pinned_settings(state) -> dict:\n \"\"\"Return the effective Settings names committed by a folded run-start record.\n\n Pre-pin legacy logs have no recorded holdout fraction, so their snapshot remains authoritative\n for both holdout knobs. Verifier fields, however, have always been re-pinned from the legacy-safe\n fold defaults whenever a valid ``run_started`` identity exists.\n \"\"\"\n if not getattr(state, \"run_id\", \"\"):\n return {}\n values = {\n # Layer 3 changes which queue owns macro-action selection. Once a run starts, replay and\n # resume must keep the same owner even if the snapshot or ambient default changes later.\n \"card_driven_selection\": bool(getattr(state, \"card_driven_selection\", False)),\n # Layer 5 overlaps Card production with evaluation only when this pinned treatment is\n # positive. Old logs omit it and therefore retain the exact serial build/eval spine.\n \"speculation_depth\": int(getattr(state, \"speculation_depth\", 0)),\n \"select_verifier\": bool(getattr(state, \"select_verifier_tiebreak\", False)),\n \"select_verifier_samples\": int(getattr(state, \"select_verifier_samples\", 3)),\n \"verifier_ci_tie\": bool(getattr(state, \"verifier_ci_tie\", False)),\n }\n holdout_fraction = getattr(state, \"holdout_fraction\", None)\n if holdout_fraction is not None:\n values.update({\n \"holdout_fraction\": holdout_fraction,\n \"holdout_select\": bool(getattr(state, \"holdout_select\", False)),\n })\n if not values.keys() <= RUN_START_PINNED_FIELDS:\n raise RuntimeError(\"folded run-start pinned settings contract drifted\")\n return values\n\n# --- Run profiles (config-first presets) -------------------------------------------------------\n# A `profile` is a NAMED BUNDLE of setting defaults — nothing more. It only fills fields the user\n# did NOT set explicitly (via file / --set / LOOPLAB_* env / init-kwargs), so any explicit knob\n# always wins (see `_apply_profile`). Most engine intelligence lives behind individual flags. The\n# PART IV/V cross-run + concept-graph machinery (concept_pivot, graded_novelty, cross_run_concepts /\n# _advisory / _structured_claims / _curation / _read_tools, fingerprint_universal, foresight_verify)\n# now ships ON in the product `Settings` default. That is an explicit experimental product choice,\n# not a claim that a frozen A/B or workload-wide cost/quality gate has cleared: the bundle can steer\n# prompts and candidate generation, change graded-novelty admission / pre-execution choices, and add\n# paid LLM work when its client and memory prerequisites are present. The bare-library `EngineOptions`\n# default stays lean (see tests/test_options_divergence.py — product side is the aggressive one) so a\n# toy `Engine(...)` in a test doesn't fire cross-run LLM work unasked. The trust/confirm/ablate QUALITY\n# bundle still ships OFF and is turned on by `thorough`. Every value here is reachable by hand — the\n# profile is a convenience, never a hidden mode.\n# keep this rationale behavioral and evidence-based; do not relabel read-only storage\n# access or proposal-only governance as a no-op for agent behavior, cost, or proposal admission.\n#\n# `thorough` deliberately touches only QUALITY/TRUST machinery, not spend: it does NOT raise\n# max_nodes / eval_parallel (those are cost knobs the user owns). It enables multi-seed confirmation\n# (demote seed-lucky leaders), the reward-hack / leakage / critic monitors AND flips them from\n# advisory to *gating* (`trust_gate=\"gate\"` — a\n# flagged win can no longer be selected as best), ablation-driven refinement, and the prompt cues\n# (complexity / budget / failure-reflection / watchdog-reflection). Agentic novelty and the experimental\n# Part IV/V bundle are already product defaults, not profile changes. Effect is large; marginal cost is a\n# few extra evals.\nPROFILES: dict[str, dict] = {\n \"default\": {}, # explicit no-op alias for \"ship-as-is\" (== omitting profile)\n \"fast\": {}, # today's lean defaults, named for symmetry with `thorough`\n \"thorough\": {\n \"confirm_top_k\": 3,\n \"confirm_seeds\": 3,\n # novelty_gate intentionally NOT enabled here: duplicate-detection is the agentic Researcher's\n # job (it reads past experiments + find_analogous_across_runs and DECIDES), not an algorithmic\n # param-distance / embedding auto-reject that mis-fired (it nudged a good fresh idea onto a\n # twice-dead lineage — live node 61). Search stays as tools; the LLM judges novelty.\n \"operator_bandit\": True, # P4: adaptive operator mix over folded yields (GreedyTree)\n \"reward_hack_detect\": True,\n \"code_leakage_detect\": True,\n \"critic_check\": True,\n \"trust_gate\": \"gate\", # detectors stop gating a flagged node from WINNING (not just audit)\n \"ablate_every\": 3,\n \"complexity_cue\": True,\n \"budget_aware\": True,\n \"failure_reflection\": True,\n \"watchdog_reflection\": True, # feed recent live-watchdog (train-monitor/ASHA) flags to proposals\n \"reflection_priors\": True, # no-op unless memory_dir is set (cross-run priors)\n },\n}\n\n\n# The shipped agent-governance matrix (who may change which setting at runtime). Defined as a module\n# constant so the Engine's direct-construction default matches this shipped default exactly (the\n# EngineOptions \"Engine() == shipped defaults\" invariant) instead of diverging to an all-locked map.\nDEFAULT_AGENT_CONTROL: dict[str, list[str]] = {\n \"timeout\": [\"researcher\", \"strategist\"],\n # Layer-2 canonical names are the presented/default governance surface. `_agent_may` falls back\n # to an old snapshot's legacy grant only when its canonical counterpart is absent, so removing\n # duplicate defaults preserves resume authority while an explicit canonical revocation still wins.\n \"eval_parallel\": [\"strategist\"],\n \"llm_parallel\": [\"strategist\"],\n # Strategy-only pseudo-setting: the closed per-lane allocation inside the canonical LLM total.\n # It is not a launch Settings field; operators pin it through `set_strategy` and the Strategist\n # may reallocate it only while this grant is present.\n \"llm_lane_limits\": [\"strategist\"],\n \"max_nodes\": [\"strategist\", \"boss\"],\n \"max_eval_seconds\": [\"strategist\", \"boss\"],\n \"policy\": [\"strategist\"],\n # The policy NAME and its PARAMS are gated independently in `_apply_strategy`; grant both by default\n # so the Strategist that may switch to (say) MCTS may also apply the `c`/`eta` it decided — else its\n # tuned params are silently dropped and the recorded `active_strategy` diverges from the live engine.\n \"policy_params\": [\"strategist\"],\n \"n_seeds\": [\"strategist\"],\n \"ablate_every\": [\"strategist\"],\n \"merge_mode\": [\"strategist\"],\n \"complexity_cue\": [\"strategist\"],\n \"ablate_code_blocks\": [\"strategist\"],\n \"prefer_sweep\": [\"strategist\"],\n \"novelty_stance\": [\"strategist\"],\n \"developer\": [\"strategist\"],\n \"fidelity\": [\"strategist\", \"researcher\"],\n}\n\n\ndef default_agent_control() -> dict[str, list[str]]:\n \"\"\"A FRESH deep-ish copy of the shipped governance matrix (each role list copied so a caller can\n mutate its own map without touching the module constant). The SINGLE constructor of the default,\n called by both `Settings.agent_control`'s default_factory AND the Engine's direct-construction\n default — so \"Engine() == shipped defaults\" can't drift if the copy depth ever needs to change\n (e.g. nested values requiring copy.deepcopy), instead of two verbatim copy expressions.\"\"\"\n return {k: list(v) for k, v in DEFAULT_AGENT_CONTROL.items()}\n\n\nclass Settings(BaseSettings):\n \"\"\"The engine settings schema (every knob a run accepts).\n\n Timeout family — six distinct knobs, each owned by a different subsystem:\n - `timeout`: per-eval wall-clock budget for ONE experiment's evaluation (engine/eval).\n - `max_eval_timeout`: hard ceiling for a governed per-node Researcher timeout override.\n - `llm_timeout`: LLM request idle timeout — inter-token stall limit in stream mode\n (core.llm OpenAICompatibleClient).\n - `llm_header_timeout`: LLM first-byte (response-headers) window for stream attempts (core.llm).\n - `agent_time_budget_s`: wall-clock ceiling across a tool-using agent loop's turns (agents).\n - `dep_install_timeout`: per-package budget for auto-installing a missing dep (env self-prep).\n\n Config-source precedence is owned by `looplab.core.appconfig` (the loader) — see its module\n docstring for the one canonical order.\n \"\"\"\n # Config precedence: see appconfig.py — the loader owns the full order. The `.env` is read\n # so `looplab run`/`looplab ui` pick up LOOPLAB_LLM_BASE_URL etc. without exporting them by hand\n # (keys MUST carry the LOOPLAB_ prefix, same as the env vars). utf-8-sig tolerates a BOM from\n # Windows editors. The test suite disables this (tests/conftest.py) so a dev's real .env in the\n # repo root can't leak into assertions.\n model_config = SettingsConfigDict(\n env_prefix=\"LOOPLAB_\",\n env_file=\".env\",\n env_file_encoding=\"utf-8-sig\",\n extra=\"ignore\",\n allow_inf_nan=False,\n )\n\n @classmethod\n def settings_customise_sources(\n cls, settings_cls, init_settings, env_settings, dotenv_settings,\n file_secret_settings):\n \"\"\"Preserve source precedence across canonical/legacy parallelism names.\n\n Pydantic ranks init > env > dotenv correctly, but it ranks field names rather than semantic\n alias families. Promoting a legacy value inside each source before the merge makes an old\n explicit snapshot/file value beat a lower-priority canonical env value. ``appconfig`` also\n promotes its file/typed/--set sub-layers before combining them into the init source.\n \"\"\"\n def _with_parallel_aliases(source):\n return lambda: canonicalize_parallelism_source(source())\n\n return (\n _with_parallel_aliases(init_settings),\n _with_parallel_aliases(env_settings),\n _with_parallel_aliases(dotenv_settings),\n _with_parallel_aliases(file_secret_settings),\n )\n\n # Run profile (config-first preset, see PROFILES above). \"default\"/\"fast\" = today's lean\n # defaults; \"thorough\" turns the built quality/trust machinery ON in one word. A profile only\n # fills fields the user did NOT set — every knob it touches stays individually overridable.\n profile: str = \"default\"\n\n # Bounds (review C/⚪): a non-positive node/seed budget or timeout is never valid. Upper bounds are\n # equally load-bearing — the UI /start path writes these straight into the engine's env, and an\n # unbounded `eval_parallel` (or legacy `max_parallel`; e.g. 100000 from a crafted preflight) would\n # fan out that many sandboxes → resource exhaustion. Reject at config time, not mid-run. Ceilings\n # are generous; tests top out near 50.\n # Pydantic's lax mode coerces a JSON boolean into any numeric field (`true` -> 1), so a UI/API type\n # error used to silently collapse a run budget to ONE node instead of returning 422. Rejected for\n # EVERY numeric field by `_reject_bool_for_numeric_fields` below, not just the ones bounded here.\n n_seeds: int = Field(default=3, ge=1, le=1024)\n max_nodes: int = Field(default=8, ge=1, le=1_000_000)\n # legacy evaluation-width input. `eval_parallel` below is canonical and wins when set;\n # keep this field for old env/config/snapshots and direct Engine call sites. A width > 1 admits\n # concurrent evals, whose resource reservations may be CPU-only or span one/more GPUs — there is no\n # one-node/one-GPU promise. `max_parallel=0` is launch-time AUTO (detected GPU count, at least 1).\n max_parallel: int = Field(default=1, ge=0, le=1024)\n # legacy build-width input. `llm_parallel` below is canonical and also controls the\n # shared provider-call total when positive. A width > 1 uses fresh pooled researcher/developer pairs;\n # it says nothing about the later eval's CPU/GPU reservation. Startup `0` derives build fan-out from\n # resolved eval width; a live Strategist/operator zero settles to 1. Without role_factory it clamps to 1.\n parallel_build: int = Field(default=1, ge=0, le=64)\n # Hypothesis-card Kanban re-architecture (docs/23, Layer 2). Two INDEPENDENT concurrency axes,\n # decoupled from each other so cost (LLM work) can be tuned separately from throughput (GPU evals).\n # These are the CANONICAL names going forward; `max_parallel`/`parallel_build` above are kept as thin\n # back-compat aliases (env vars / snapshots / ~100 Engine(...) call sites depend on the exact names,\n # so they cannot be renamed — CLAUDE.md). Resolution (orchestrator.__init__): a NEW name, when set,\n # WINS over its legacy alias; None => fall back to the legacy field => byte-identical to today.\n # `eval_parallel`: concurrent EVALS (the GPU/experiment consumer). None -> max_parallel; 0 = AUTO\n # (one experiment per detected GPU). This is the in-Run eval-concurrency ceiling; the resource\n # scheduler bin-packs declared footprints separately. Sibling local GPU-owning Runs serialize\n # through the conservative host-pool lease; external execution needs its own admission boundary.\n # `llm_parallel`: total concurrent LLM provider calls + BUILD fan-out. None -> legacy\n # parallel_build semantics without a shared total; 0 = startup AUTO (= resolved eval_parallel\n # for build fan-out, historical research overlap unbounded). A positive canonical value enables\n # the named-lane broker the Strategist can reallocate at cadence.\n eval_parallel: int | None = Field(default=None, ge=0, le=1024)\n llm_parallel: int | None = Field(default=None, ge=0, le=64)\n # Training-log monitor (I-series watchdog family): a per-eval background observer that tails the live\n # training log while a (often multi-hour) declared stage runs in a worker thread. ON in the product\n # surface (Settings). Its alert event is fold-ignored and cannot directly change lifecycle/champion;\n # with watchdog_reflection on, that alert is nevertheless advice in a later Researcher\n # prompt and can change future proposals. It only fires on the command-eval path with an LLM client;\n # bare-library EngineOptions stays OFF. See test_options_divergence.\n # `_interval_s` is the BASE tick cadence (the effective one adapts to the per-experiment budget).\n train_monitor: bool = True\n train_monitor_interval_s: float = Field(default=600.0, gt=0)\n # Phase 3 — INTERVENTION (opt-in, separate from observation): let the monitor tree-kill a training the\n # LLM judges 'broken' (diverged / silent CPU fallback / not learning) EARLY, instead of burning the\n # whole budget. Off by default — the observer only WATCHES unless this is on. A kill only fires on a\n # 'broken' verdict (a plateau is 'watch', never killed) with confidence >= the threshold; the node then\n # fails normally (reason='monitor_broken'), so replay reconstructs it from that one terminal event.\n train_monitor_kill: bool = False\n train_monitor_kill_confidence: float = Field(default=0.8, ge=0.0, le=1.0)\n # ASHA live-curve watchdog (sibling of the training monitor): reads the latest INTERMEDIATE value of\n # the objective metric off the live log (reusing the eval's OWN metric reader). Finished-endpoint rank\n # remains advisory. An opt-in KILL additionally requires an operator-declared metric.resource_key and\n # enough sibling observations at exactly that resource; absent comparable evidence cannot stop a run.\n # Records a fold-ignored `asha_rank` diagnostic + trace span; the library default is OFF (off == today).\n asha_live: bool = True\n # Opt-in INTERVENTION (separate from the advisory signal): let the watchdog tree-kill a node whose\n # intermediate metric stays below the SAME-RESOURCE bar past a short grace window. Off by default —\n # it only surfaces endpoint rank unless this is on. A kill fails normally (asha_underperforming).\n asha_live_kill: bool = False\n # The bar sits at `asha_live_quantile` along a WORST->BEST ordering of the finished siblings' finals:\n # 0.5 = the median (a node worse than the median peer flags); a SMALLER value is more conservative —\n # it lowers the bar toward the WORST finished peer, so only a node worse than nearly all peers flags\n # (0.0 = worse than the worst). `min_siblings` is the floor of finished peers required before it ranks\n # at all, so it never acts on too little evidence.\n asha_live_quantile: float = Field(default=0.5, ge=0.0, le=1.0)\n asha_live_min_siblings: int = Field(default=3, ge=1)\n timeout: float = Field(default=30.0, gt=0)\n # Hard ceiling for the Researcher's governed per-node `Idea.eval_timeout`. One hour preserves the\n # existing heavy/neural-net authoring example while staying well below the sandbox's defensive\n # 24-hour subprocess ceiling. This is operator-owned run config: agents may request less or more,\n # but the accepted action is always clamped here after the `agent_control.timeout` permission gate.\n max_eval_timeout: float = Field(default=3600.0, gt=0, le=24 * 3600.0)\n # Intra-node sweep: a sweep node runs a whole grid in one process, so it gets this multiple of\n # `timeout` as its wall-clock budget (solution.py path; RepoTasks use their per-profile timeout).\n sweep_timeout_mult: float = Field(default=8.0, ge=1)\n # Eval STALL watchdog CAP (seconds): a stage that is completely SILENT on stdout/stderr for this\n # long — while still alive and BELOW its wall-clock deadline — is tree-killed early with a STALLED\n # marker (a hung dataloader/deadlock dies in minutes instead of burning a multi-hour timeout). The\n # per-stage window is `min(this, the stage's own timeout)`, so a short scorer's deadline fires first.\n # `0` DISABLES the watchdog entirely (only the hard deadline applies) — for a legitimately quiet\n # non-Python stage (block-buffered stdout, a script that logs only to its own file). Threaded into\n # the eval and surfaced to the Developer so its code can emit periodic progress to stay alive.\n eval_stall_timeout_s: float = Field(default=1800.0, ge=0)\n # Sandbox tier (ADR-13): \"trusted_local\" (subprocess, no Docker) for the CLI;\n # \"untrusted\" (Docker --network none, shared-kernel runtime) for hosted/multi-tenant UI;\n # \"hostile\" (untrusted + a true-isolation OCI runtime, gVisor `runsc` by default / Kata) for\n # running untrusted code. See runtime/sandbox.py::make_sandbox for the tier selection.\n trust_mode: str = \"trusted_local\"\n # Image for the untrusted command-eval tier (RepoTask, Phase 4): the framework's deps\n # should be baked in (the container runs --network none, so a pip setup can't fetch).\n docker_image: str = \"python:3.12-slim\"\n # Resource caps for the untrusted/hostile Docker tier (passed to `docker run --memory/--cpus`).\n # The default 4g bound protects other tenants from an OOM, but a model-training MLE-bench eval\n # routinely needs more — raise it (e.g. \"16g\") for such runs. \"\" disables the cap (unbounded).\n # Ignored by the trusted_local (subprocess) tier.\n sandbox_memory: str = \"4g\"\n sandbox_cpus: str = \"\"\n # Best-effort host-OOM guard for the TRUSTED-LOCAL (subprocess) tier. When set (e.g. \"8g\"), each\n # eval child gets an RLIMIT_AS address-space cap so a runaway trainer hits MemoryError instead of\n # OOM-killing the whole host (and the engine). POSIX only; a no-op on Windows. Default \"\" = OFF:\n # RLIMIT_AS bounds VIRTUAL memory, and CUDA/torch reserve tens of GB of virtual without using it,\n # so a default cap would spuriously kill GPU evals — leave it off for those and use the Docker\n # tier's real --memory cgroup bound (sandbox_memory) instead. Ideal for CPU/numpy/sklearn runs.\n sandbox_memory_local: str = \"\"\n # Best-effort disk-fill guard for the TRUSTED-LOCAL tier (P1-5): an RLIMIT_FSIZE cap on the size of\n # any single file an eval child writes (e.g. \"2g\"), so a runaway that writes an unbounded file gets\n # SIGXFSZ instead of filling the host disk. POSIX only. Default \"\" = OFF (large model checkpoints\n # need big files); set it for tasks that write only small artifacts.\n sandbox_fsize_local: str = \"\"\n # Search policy (ADR-2): \"greedy\" | \"evolutionary\" | \"mcts\" | \"asha\".\n policy: str = \"greedy\"\n # Ablation-driven refinement (I7): every N improves, ablate the best to find the\n # highest-impact parameter and refine it. 0 = off. (GreedyTree only.)\n ablate_every: int = 0\n # A0a (MLE-STAR): when ablating, treat each generated pipeline *code block* (not just a\n # numeric param) as an ablation unit — neutralize a block, measure the metric delta, refine\n # the highest-impact block. Off => the classic numeric-param ablation. Config-first knob.\n ablate_code_blocks: bool = False\n # A0b: real merge/ensembling. \"mean\" = legacy mean-param merge; \"ensemble\" = the Developer\n # writes a code-recombination ensemble over the two parents' solutions (verified: agent-proposed\n # ensembling 37.9%->43.9%). \"auto\" (default) = ensemble whenever the Developer actually\n # generates code (LLM/agent backends; declared via `is_code_generating`), mean for the\n # templated/toy developers where a code ensemble is meaningless — the evidence says code\n # recombination is the strongest single merge (removing it costs ~9 pp), so it is the default\n # wherever it can work. Falls back to mean when the Developer can't ensemble.\n merge_mode: str = \"auto\"\n # A0d (AIRA): inject a dynamic complexity hint into the draft/improve prompt keyed on the\n # node's child count (few children -> keep minimal; many -> escalate to ensembling/HPO).\n complexity_cue: bool = False\n # I1 (CAAFE-style) LLM feature-engineering: instruct the proposer/developer to add\n # semantically-meaningful engineered features (ratios, interactions, aggregations) as code. The\n # existing cross-validation eval + best-selection is the CV GATE — a feature set that doesn't\n # improve CV is never selected (feature engineering is non-universal, so this gate is mandatory).\n # Tabular tasks (those exposing column data). Off by default.\n feature_engineering: bool = False\n # A5: surface the REMAINING eval-compute budget into the proposal prompt so the Researcher can\n # reason over how much compute is left (explore broad while flush, exploit/cheapen when low).\n # No-op unless a max_eval_seconds budget is set.\n budget_aware: bool = False\n # A4 (LATS-style): feed a summary of the most recent FAILED branches (operator + error reason)\n # back into the proposal prompt so the proposer reflects on and avoids repeating them. ON by\n # default: it is SELECTIVE by construction (injects only when recent failures exist — the\n # evidenced form of reflection; reflecting at every step shows no gains, arXiv:2506.12928).\n failure_reflection: bool = True\n # Feed the LIVE-WATCHDOG observations (train-monitor health verdicts + ASHA intermediate-rank flags)\n # of recent experiments back into the proposal prompt, so the Researcher avoids re-proposing a config\n # whose TRAINING was already observed to be weak — even when the watchdog kills are off (default) and\n # the node ran to completion (those diagnostics are fold-ignored, so failure_reflection never sees\n # them). ON by default and SELECTIVE like failure_reflection (injects only when a recent flag exists).\n watchdog_reflection: bool = True\n # C3 deep test-driven repair: hand the Developer the failure taxonomy + a structured \"reproduce\n # then fix\" directive on debug, not just the raw stderr tail. ON by default (product surface); the\n # conservative library default in EngineOptions stays off, per that module's contract.\n deep_repair: bool = True\n # === Inline crash repair ==============================================================\n # Hybrid in-node crash repair: when an LLM-generated node CRASHES at runtime (mechanical errors\n # — bad import, removed kwarg, typo), the agent triages it and may repair the code IN PLACE within\n # the same eval (cheap; does NOT consume max_nodes and does NOT add a node to the search tree).\n # Semantic failures still flow to a new idea/debug node via the policy. ON by default; set False\n # to restore the prior behavior (every crash waits for a budgeted inter-node debug node).\n inline_repair: bool = True\n # Max in-place repair attempts per node before it fails normally. Default 0 = UNLIMITED: the same\n # agent keeps fixing + re-evaluating IN THE SAME node until it produces a clean metric — no new\n # node just to retry. Runaway is prevented not by a count but by the anti-stuck guard\n # (`inline_repair_stuck_repeat`): when the SAME error signature repeats with no progress, or the\n # agent's crash-triage says \"abandon\", the node fails. Set a positive N to cap the retries instead.\n inline_repair_attempts: int = Field(default=0, ge=0)\n # Anti-stuck: abandon in-node repair when the SAME error recurs this many times in a row (no\n # progress). Keeps \"unlimited\" repair from looping forever on an unfixable error.\n inline_repair_stuck_repeat: int = Field(default=4, ge=2)\n # Which failure reasons (_failure_reason: crash|timeout|oom|setup|no_metric|drift) are eligible for\n # inline repair. Default: mechanical crashes, timeouts AND OOM-kills — a timeout/OOM means the code\n # was too slow / too memory-hungry for the budget (or the pod's cgroup limit), not that the idea is\n # wrong, so the agent repairs it by reducing compute/memory (fewer estimators/epochs/folds/seeds, a\n # lighter model, a smaller batch, subsampling) instead of letting the node die unrecovered. Env\n # override expects a JSON array.\n inline_repair_reasons: tuple[str, ...] = (\"crash\", \"timeout\", \"oom\")\n # Cost ceiling for in-node repair of a MULTI-STAGE eval: the max number of FULL pipeline re-runs\n # (i.e. re-trains from the first stage) the inline-repair loop may do before abandoning to the\n # inter-node debug operator. A late-stage failure (a broken `score`/eval script that didn't touch\n # `train`) REUSES the completed train checkpoint and re-runs only from the failed stage — that is\n # CHEAP and NOT counted here; only a repair that changes an EARLIER stage's code (train.py/loss.py/…)\n # forces a full re-train, which each costs the whole training time. Without this bound, a repair that\n # keeps changing training code could burn many full trains (the anti-stuck guard is error-signature\n # based, not cost based). 0 = unlimited (legacy behavior). Single-command evals ignore it.\n inline_repair_retrain_cap: int = Field(default=2, ge=0)\n # Environment self-prep: when a solution crashes purely because a KNOWN library isn't installed\n # (ModuleNotFoundError), the engine pip-installs it into the eval interpreter and re-runs — so a\n # torch/XGBoost/CatBoost idea (e.g. a GRU model) actually runs on a fresh box instead of being\n # thrown away as `idea_rejected`. Trusted_local tier only (the untrusted/hostile Docker tiers run\n # --network none). A typo'd or local helper module is NOT installed (that's a code bug to repair).\n auto_install_deps: bool = True\n # Per-package wall-clock budget for an auto-install (seconds). Generous: large wheels like torch\n # can take minutes to download/build.\n dep_install_timeout: float = Field(default=900.0, gt=0)\n # --- Agent governance: who may change a setting at runtime (per-setting allow-list of roles) ---\n # Roles: \"strategist\" (A7 meta-controller; run-wide), \"boss\" (run-chat operator-proxy; run-wide),\n # \"researcher\" (per-experiment proposer; PER-NODE — e.g. set a longer eval timeout for a neural-net\n # idea). A setting ABSENT from this map is LOCKED — no agent may change it, only a human via the\n # snapshot/UI. The sole conditional exception is Strategy-only `card_scoring`: Card mode grants\n # it implicitly without changing the default flag-off snapshot, while an explicit [] revokes it.\n # The gate is ENFORCED at every AGENT seam via `_agent_may` — the strategist's whole\n # control surface (policy/operators/novelty_stance/prefer_sweep/developer/fidelity/timeout/\n # eval_parallel/llm_parallel) is checked in `_apply_strategy`, so removing a role from a knob locks it,\n # not just greys out the UI pill. The boss/max_* grants document boss-relevant knobs, but a\n # `budget_extend` is a HUMAN control intent (the boss action-builder can only emit `add_nodes`), so\n # it is applied as-is — the human always wins via the UI/snapshot. Defaults are conservative: resource/\n # search-shape knobs the agents already reason about; provider infra (llm_model/base_url/api_key,\n # docker_image) stays locked. The UI shows S/B/R pills per setting. `timeout` granted to the\n # researcher IS the \"auto\" per-node mode — the researcher sizes each experiment's wall-clock\n # (Idea.eval_timeout); the config `timeout` is the fallback for nodes it doesn't size. Env override\n # expects a JSON object. (`fidelity`/`novelty_stance`/`prefer_sweep`/`developer` are governance keys\n # for the strategist's per-node dials — not 1:1 Settings fields, but gated the same way.) The default\n # lives in the module-level DEFAULT_AGENT_CONTROL so the Engine's direct-construction default matches.\n agent_control: dict[str, list[str]] = Field(default_factory=default_agent_control)\n # C1 (Agentless localization): for repo tasks, rank the source files most relevant to the most\n # recent failure (traceback + identifiers) and surface them in the proposal/repair prompt so the\n # Developer edits the right place. Off by default; repo tasks only.\n localize_faults: bool = False\n # A2 surrogate-guided proposal (BO-lite): fit a k-NN surrogate over (params->metric) and propose\n # by acquisition instead of random/hill-climb. Numeric-bounds tasks only; bootstraps via the\n # base Researcher. Off by default. `surrogate_explore` = UCB-style exploration weight.\n surrogate_proposer: bool = False\n surrogate_explore: float = 0.1\n # E2 researcher panel: generate K candidate ideas per proposal and keep the one ranked best by a\n # cheap empirical surrogate over the (params->metric) history (NOT an LLM-judge). 1 = off.\n researcher_panel: int = 1\n # A1 ASHA / successive-halving: reduction factor (keep top 1/eta per rung) and rung budget.\n asha_eta: int = Field(default=3, ge=2)\n # Rung-0 width for ASHA/BOHB (the wide base of cheap drafts). 0 = use n_seeds (default,\n # preserving prior behavior); set >0 to seed a wider/narrower base independent of n_seeds.\n asha_rung_nodes: int = Field(default=0, ge=0)\n # A6 proxy/predictive scoring: cheaply rank a candidate's potential from early-stage signals\n # and skip a full eval for the doomed bottom fraction. 0.0 = off (no candidate skipped).\n proxy_scoring: bool = False\n proxy_kill_fraction: float = Field(default=0.0, ge=0.0, le=0.9)\n # === Novelty / dedup ==================================================================\n # E1 novelty/dedup gate: reject near-duplicate proposals (normalized param-space distance below\n # `novelty_epsilon`) by deterministically nudging them off the duplicate — stops the search\n # wasting evals re-trying the same idea. Audit event `novelty_rejected`. Off by default.\n # Novelty/dedup MODE selector: how a fresh proposal is checked against already-tried experiments.\n # \"off\" — no explicit gate; the agentic Researcher's own read-the-history judgment is trusted.\n # \"algo\" — the deterministic gate: numeric param-distance (`novelty_epsilon`) + optional embedding\n # similarity (`novelty_semantic`). Cheap, no LLM call, but can't explain itself.\n # \"llm\" — an LLM adjudicates (reads the real experiments via tools) whether the idea is a\n # near-duplicate and, if so, asks the Researcher once more for something different. One\n # extra LLM call per proposal — highest quality, follows the \"let the LLM decide\" line.\n novelty_mode: str = \"llm\"\n # Legacy sub-toggles, honored by the \"algo\" mode (and back-compat: novelty_gate=True forces \"algo\"):\n novelty_gate: bool = False\n novelty_epsilon: float = 0.05\n # T5 semantic novelty (active whenever the deterministic gate runs — novelty_mode=algo,\n # novelty_gate=true which is the legacy alias for algo, OR the Strategist novelty stance is\n # \"explore\"; a no-op only under novelty_mode=llm/off with a non-explore stance): reject a proposal\n # whose idea TEXT embeds within\n # `novelty_semantic_threshold` cosine of an existing node's. OFF BY DEFAULT: novelty is the agentic\n # Researcher's call, made by READING the actual prior experiments (read_experiment /\n # find_analogous_across_runs / list_experiments), NOT an embedding-similarity auto-reject that can't\n # explain itself and mis-fires on paraphrases. Semantic/param search stays available as TOOLS in the\n # Researcher's context; the LLM decides whether an idea is a true duplicate.\n novelty_semantic: bool = False\n novelty_semantic_threshold: float = Field(default=0.92, ge=0.5, le=1.0)\n # T10: debug-lineage depth bound for every policy — how many error-feedback repairs a failing\n # lineage gets before it is abandoned. The old default (1) abandoned lineages after ONE repair;\n # multi-turn/deeper debugging is a verified lever (AIRA2 ReAct debug +5.5 percentile points).\n debug_depth: int = Field(default=2, ge=1)\n # P4 operator bandit (GreedyTree): replace the fixed merge/ablate cadences with a\n # deterministic UCB over per-operator yield (Δmetric per eval-second, folded from the run).\n # Off by default (the cadences are well-tested and the bandit has no direct published\n # ablation); `thorough` turns it on.\n operator_bandit: bool = False\n # P1 hypothesis ledger: ask the Researcher to state the one-line hypothesis each experiment tests,\n # register deep-research directions as hypotheses, and track them to a verdict on the board. ON by\n # default. The board is shown to the Researcher and can be foresight-ordered, so it can\n # steer later proposals even though it never directly re-ranks already-evaluated nodes. Set False\n # to drop the prompt nudge + registration.\n track_hypotheses: bool = True\n # E4/M2/M3 reflection-memory -> priors (gradient-free cross-run meta-learning): at run end distill\n # the winner into `<memory_dir>/meta_notes.jsonl` AND structured lessons (incl. NEGATIVE results:\n # tested/abandoned hypotheses + failure themes) into `lessons.jsonl` with a task fingerprint; at run\n # start, inject exact-task notes + fingerprint-matched lessons from SIMILAR tasks into the proposal\n # prompt. ON by default — but a NO-OP until `memory_dir` is set (that's where cross-run memory lives).\n reflection_priors: bool = True\n # M6 comparative lessons (MARS \"comparative reflective memory\", doc 13 §7 item 2): distill\n # credit-assigned lessons from PAIRS of solutions — which SPECIFIC change made a child beat\n # (or regress from) its parent, and what fixed a failure — on top of the one-shot run-end\n # reflection. At most one extra LLM call per distillation (all pairs batched); offline a\n # deterministic param-diff credit stands in. Gated on reflection_priors + memory_dir like the\n # rest of cross-run memory — and since memory_dir has a default (~/.looplab/memory), this is\n # ACTIVE out of the box; clear memory_dir to disable cross-run memory wholesale.\n comparative_lessons: bool = True\n # M6 mid-run cadences (doc 13 §7 item 5 — the AgentRxiv live-share pattern): every N created\n # nodes, WRITE comparative lessons to the shared cross-run store during the run (lessons_every\n # — one batched LLM call per firing) and RE-READ the store so lessons distilled by CONCURRENT\n # runs reach this run's proposals (lessons_refresh_every; file re-read, no LLM call, skipped\n # when the store is unchanged). ON by default (every 4 nodes) and, like comparative_lessons\n # above, live out of the box because memory_dir defaults on; 0 = off — run-end write /\n # run-start read only, the pre-M6 behavior.\n lessons_every: int = Field(default=4, ge=0)\n lessons_refresh_every: int = Field(default=4, ge=0)\n # B3 output redaction: mask credentials (known key shapes + high-entropy tokens) in the\n # stdout/stderr tail before it is persisted to the event log / spans / UI — a leaked secret in a\n # print()/traceback must not land in the durable log. Off by default; recommend on for untrusted.\n redact_output: bool = False\n # B5 reward-hacking detector: a host-side monitor that flags suspicious wins (grader/answer-key\n # access, runtime writes to frozen files, suspiciously-perfect metrics) as a `reward_hack_suspected`\n # audit event in the Trust panel. Off by default. Whether a flag CHANGES selection is governed by\n # `trust_gate` below (default: audit-only, never changes selection).\n reward_hack_detect: bool = False\n # Trust enforcement (T2): what a reward-hack / data-leakage flag actually DOES to selection.\n # \"audit\" (default) = surface it in the Trust panel, never change selection (today's behavior);\n # \"gate\" = a flagged node can no longer be selected as BEST **and isn't bred/confirmed from**\n # (breed_excluded — see replay._apply_trust_gate / models.breedable_nodes), but stays\n # FEASIBLE so it still counts for diversity/audit — closes the \"a hacked/leaky node can\n # win OR seed its lineage\" hole;\n # \"block\" = additionally mark it fully INFEASIBLE (removed from feasible_nodes entirely).\n # Deliberately gates only high-precision CHEATING/LEAKAGE signals. Broad `critic:*` findings stay\n # advisory; `critic:hardcoded_metric` is the one narrow high-precision critic exception\n # and gates because it requires a literal metric with no computed assignment anywhere.\n trust_gate: str = \"audit\"\n # I3 data-centric: static code-leakage scan of each evaluated solution (fit-before-split,\n # fit-on-test) surfaced into the Trust panel alongside reward-hack flags. Off by default.\n code_leakage_detect: bool = False\n # 4.4 sandbox instrumentation (needs reward_hack_detect): after each eval, flag a RUNTIME write\n # to a protected/frozen file (an answer key or grader tampered mid-run) — behavioral evidence a\n # static code scan misses. ON by default; only fires when the reward-hack detector is on.\n workdir_audit: bool = True\n # D8 decoupled research Verifier: check every Deep-Research memo's CLAIMS against their cited\n # evidence (node ids / urls) before recording — synthesis is the documented weak link (Kosmos\n # 57.9% accurate). Deterministic layer always; an LLM rubric pass when the DeepResearcher has a\n # client. The verdict cannot change this run's node/champion, but finalize uses aligned supported\n # verdicts as the evidence gate for persisted D8 cross-run claims; unavailable/misaligned stays\n # unverified. ON by default (no-op with no memo/claims).\n research_verify: bool = True\n # C4 independent critic: an execution-free critic of each solution (stub / hardcoded-metric /\n # params-ignored; on host-graded tasks the metric checks become a submission-output check)\n # surfaced in the Trust panel. Broad findings are advisory; `critic:hardcoded_metric` can gate under\n # trust_gate=gate/block. Off by default.\n critic_check: bool = False\n # A7 Strategist (NEW, user-requested): optional LLM/rule meta-controller that picks the search\n # policy/allocator + operator mix + fidelity (+ Developer backend) per situation. Config-first:\n # every choice the Strategist makes is also a direct knob. Defaults to \"agent\" so the agent adapts\n # the search policy/operators/fidelity per situation; set \"off\" for static config-driven search.\n # \"agent\" = a tool-using Strategist that READS the run/data/sibling-runs/KB/memory (B1-guarded)\n # before deciding, instead of the single-shot \"llm\" call over aggregate stats.\n strategist_backend: str = \"agent\" # \"off\" | \"rule\" | \"llm\" | \"agent\" — default AGENTIC: the\n # Strategist READS the run/data/sibling-runs/KB/memory with tools before deciding, not a single-shot\n # call over aggregate stats. \"llm\" = the old non-agentic single-shot; \"rule\"/\"off\" = no LLM.\n strategist_every: int = Field(default=3, ge=1) # consult cadence (created nodes)\n # PART V (F1): concept CLASSIFIER re-tag + consolidation cadence, DECOUPLED from strategist_every. The\n # LLM concept map is heavier and slower-moving than a strategy consult, so it refreshes on its own\n # (sparser) interval. Researcher-authored idea.concepts still fold immediately at node_created — this\n # only paces the classifier-evidence + consolidation refresh (and the concept-coverage pivot snapshot).\n concept_retag_every: int = Field(default=30, ge=1)\n # === Confirmation & holdout ===========================================================\n # Multi-seed confirmation (I12, ADR-15): confirm the top-k under N seeds before\n # finishing. 0 disables (default). Only meaningful when eval has variance.\n confirm_top_k: int = 0\n confirm_seeds: int = 0\n # D1 seed-holdout discipline: the FIRST seed the confirm phase uses. Search evals run with the\n # implicit seed 0 (LOOPLAB_EVAL_SEED unset -> \"0\"), so a base of 1 keeps every confirm split\n # DISJOINT from anything the search optimized against — the confirm metric is then a\n # generalization signal, not a re-measurement (AIRA: selecting on a signal the search saw\n # overfits by 9-13 pp). Set 0 to restore the legacy overlapping seeds 0..N-1.\n confirm_seed_base: int = Field(default=1, ge=0)\n # D1 holdout-gated promotion (B6, Arbor-style): for host-graded tasks, reserve this fraction of\n # the held-out labels as a FINAL holdout partition the search never sees — every search/confirm\n # eval is scored on the remaining rows only, and at finish the val-top-k are re-scored on the\n # holdout partition (free: the predictions already exist). 0.0 = off (legacy full-label scoring).\n holdout_fraction: float = Field(default=0.25, ge=0.0, le=0.9)\n # Champion selection prefers the HOLDOUT metric among the nodes that have one (the val-top-k).\n # Recorded in run_started so replay applies the same rule; old logs fold to False (legacy pick).\n # The per-node val-holdout `generalization_gap` folds into the Trust panel either way.\n holdout_select: bool = True\n # How many val-leaders get a holdout evaluation at finish (host-graded tasks).\n holdout_top_k: int = Field(default=3, ge=1)\n # R1-c / Part IV — calibrated §12-verifier tie-break in best-selection. When a fresh node's robust\n # metric EXACTLY TIES an already-eligible node, the engine runs the §12 verifier (grounded on each\n # node's realized result, `selection_criteria`) and records a soundness score per node. The tie-break\n # then applies across the WHOLE selection path: the mean pick breaks a robust_metric tie by the score,\n # and — when `holdout_select` is on — the holdout-slot ranking prefers the sounder node so it isn't\n # denied a holdout eval, and the final holdout override breaks a holdout_metric tie by it too. Strictly\n # a TIE-BREAK — it can NEVER move a node ahead of a strictly-better metric (§21.7 advisory-never-\n # overrides). Best-effort: verification is LAZY (only fires on an actual tie) and bounded per cadence,\n # so a large/late tie stays on deterministic metric+id order unless one atomic treatment scores every\n # member. Recorded in run_started so\n # replay applies the same rule; old logs fold to False (legacy pick). Opt-in (default off); needs a\n # reachable LLM client. Enable only after validating calibration offline with `verifier.calibrate`.\n # See trust/verifier.py + core/fitness.py.\n select_verifier: bool = False\n # R1-d (§21.19): widen the verifier tie-break from EXACT-metric to a conservative STATISTICAL tie.\n # The band is capped by both the metric leader's SE and the pooled SE-of-difference, so challenger\n # variance cannot widen it and a significant difference is never a tie (§21.7). Off -> exact only.\n verifier_ci_tie: bool = False\n # Verifier sample count for `select_verifier` (the §12 repeated-sampling expectation). 3 tames the\n # single-shot variance; 1 = single-shot (cheaper, noisier).\n select_verifier_samples: int = Field(default=3, ge=1, le=32)\n # Budget (I13): hard wall-clock ceiling; the run aborts cleanly when exceeded.\n max_seconds: float | None = None\n # Eval-compute budget (#2): hard ceiling on cumulative time spent INSIDE evals (training\n # runs), separate from wall-clock. Survives resume (summed from the event log). The real\n # guard against a silent multi-hour sweep when an eval is a minutes-hours training run.\n max_eval_seconds: float | None = None\n # Cross-run memory (I19, ADR-10): if set, the best result of each run is stored as\n # a case here, and the cases become retrievable knowledge for future runs.\n memory_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"memory\"))\n # HITL (I21, ADR-11): pause for human approval of the final best before finishing.\n require_approval: bool = False\n # Diversity archive (I22): niche bucket width in parameter space.\n archive_resolution: float = 1.0\n # Coverage read-model (narrowing signal): at the strategist cadence, compute a breadth summary\n # (themes / param-niches / theme entropy / dominant-theme fraction) from the folded run, record\n # it as a `coverage_snapshot` audit event, and surface it into the Strategist's decision context.\n # Deterministic, cheap, and purely additive context — no search-behavior change on its own. On by\n # default (like the rest of the always-on situational context). See search/coverage.py.\n coverage_context: bool = True\n # PART IV Phase 2a live steering (§21.11/§21.13). When on, the `concept_retag_every` cadence (NOT\n # `strategist_every` — the producer gates on `_should_consult_concepts`, which uses the seed boundary\n # + `concept_retag_every`, default 30) records a\n # concept-graph coverage + uncovered-region snapshot (deterministic heuristic tagger over the\n # task-type skeleton) and, on an `explore` stance, the Researcher's novelty hint names the exact\n # uncovered regions (\"0 coverage in {negatives/external-mining, distillation} — go there\") instead\n # of the vague \"broaden\". ON by default in the product Settings (ce4a379), EngineOptions off: it\n # only enriches an already-mode-gated explore hint, never forces exploration, and no-ops for a task\n # with no curated concept skeleton. The snapshot is auditable, but its prompt cue can change future\n # candidate generation; it does not directly rewrite best-metric selection. See search/concept_graph.py.\n # DESIGN NOTE (2026-07-17 critique): now ON unconditionally, but a toy / non-ML task yields EMPTY concept\n # tags while still paying the per-node LLM concept cost (~10 calls/node with the full Part IV/V bundle).\n # Consider AUTO-QUIESCING: if the heuristic tagger returns empty for the first N nodes (no concept signal),\n # skip the LLM concept work for the run — keeps it on for ML tasks, free on toy/degenerate ones.\n concept_pivot: bool = True\n # PART IV Phase 2b — D3 graded novelty into the LIVE novelty gate (§21.4/§21.13). When on and the\n # task has a curated concept skeleton, the novelty gate first grades a proposal over the concept\n # graph: a level-4 \"same direction, DIFFERENT implementation\" or a level-5 \"re-opens a\n # wrongly-abandoned FAILED direction\" proposal is ALLOWED through unchanged — the flat LLM/semantic\n # dedup gate can't tell \"this DCL tweak\" from \"the whole DCL branch\" and would wrongly reject a\n # legitimate variant or never re-open a sound-but-killed direction (the node_63 archetype). Levels\n # 1/2/3 (identical / near-dup / prior-run) still fall through to the existing gate. Deterministic\n # (heuristic tagger, no LLM), audit event `novelty_graded`, replay-safe. ON by default in the product\n # Settings (ce4a379), EngineOptions off; no-ops for a task with no skeleton. See search/graded_novelty.py.\n graded_novelty: bool = True\n # PART IV Phase 2b — D7 capability-expansion forced-jump DIRECTIVE (§21.8/§21.13, issue #7). When on\n # and the concept-graph cadence detects action-space LOCK-IN (the search has stayed inside one D5\n # branch for a long consecutive streak) on an `explore` stance, the Researcher's novelty hint\n # ESCALATES from \"broaden\" to \"expand the ACTION SPACE — build the missing infra (external mining,\n # a new data pipeline, a different eval), do NOT swap another {locked} lever.\" The same\n # gate also stamps the proposal's authoritative operator as `expand`; that node competes normally\n # under SearchFitness and its separate operator yield is measured. It does not force a winner.\n # Reads the 2a concept-coverage snapshot, so it needs `concept_pivot` to record one. OPT-IN\n # (default off). See engine/proposal_cues.py + search/lock_in.py.\n capability_expansion: bool = False\n # PART IV cross-run Step 0 (§21.20.12/§21.20.13). Universal task-fingerprint tokenization: drop the\n # ASCII-only `[a-z0-9]` allowlist on goal keywords so a non-Latin goal (Russian, CJK, …) is NOT\n # silently dropped from its cross-run fingerprint (today such a goal keys only on kind/dir/metric and\n # never reaches SIMILAR-task priors/lessons/cases). Uses `[^\\W_]+`/`.casefold()` — same splitting as\n # before, any script. ON by default in the product Settings (ce4a379); the bare-library EngineOptions\n # default stays off, so a run pinned to it won't silently re-key a portfolio mid-flight. See engine/memory.py.\n fingerprint_universal: bool = True\n # PART IV cross-run Step 2 (§21.20). Fill graded-novelty's `prior_concepts` from the cross-run\n # ConceptCapsuleStore: at run end write a per-run concept capsule (the shipped `node_concepts` tags +\n # best outcome, keyed by task_fingerprint) to `memory_dir`; when a later SIMILAR run proposes an idea\n # whose concept was tried before, SURFACE the prior outcome as a `cross_run_prior` audit event (never\n # reject — D3 level 3). Audit-only, off the selection path; ON by default in the product Settings\n # (ce4a379), EngineOptions off. Needs a `memory_dir` to share capsules.\n # See engine/memory.py (ConceptCapsuleStore) + engine/novelty.py.\n # PREREQUISITES (this flag is NOT standalone): the WRITE needs per-run concept tags\n # (`node_concepts`, produced by `concept_pivot`/F1 tagging) — no tags => no capsule; the SURFACE path\n # runs inside the graded-novelty precheck, so it also needs `graded_novelty` on. With only this flag +\n # `memory_dir` it persists/surfaces nothing. Enable `concept_pivot` + `graded_novelty` alongside it.\n # A non-Latin (Russian/CJK) portfolio should ALSO set `fingerprint_universal`, else the capsule's goal\n # tokens are dropped and its fingerprint over-matches on kind/dir/metric alone (the novelty direction\n # gate narrows this, but goal-word similarity is still lost).\n cross_run_concepts: bool = True\n # PART V (B): run-base + node-DELTA concept authoring. When on, once the first evaluated node has\n # authored concepts the engine seeds RunState.run_base_concepts from them (a one-shot EV_RUN_CONCEPTS,\n # idempotent because it only fires while the base is empty), and the Researcher's proposal hint tells it\n # to author only `concepts_added`/`concepts_removed` vs the run base + its parent — minimal per-node\n # annotations that inherit down the DAG (a swap propagates). Off -> every node authors its full concept\n # set as before. Product-on; the bare EngineOptions mirror defaults off (test_options_divergence).\n concept_run_base: bool = True\n # PART IV cross-run Step 5 advisory (§21.20.5). Fold the bounded cross-run CONTEXT PACK — evidence-\n # grounded claims with BOTH support and counter-evidence (Step 4) + a portfolio-coverage line (Step 3) —\n # into the Researcher's proposal prompt, exactly like the E4 cross-run prior note. Advisory ONLY: it is\n # prompt-grounding: it can change the Researcher's later candidates, but does not directly rewrite\n # best-metric selection (§21.7). Reads `memory_dir` (lessons.jsonl + concept_capsules.jsonl); \"\" when\n # empty. ON by explicit experimental product choice; no frozen A/B validation is claimed. The\n # bare-library EngineOptions default stays off (engine/options.py, frozen in\n # test_options_divergence). See engine/proposal_cues.py.\n cross_run_advisory: bool = True\n # PART IV cross-run §21.20.13 (full CR of the lean fuzzy claim merge). Switch the claim read-model to the\n # SCOPE+POLARITY-safe STRUCTURED claim key (engine/claim_key.py): claims from different tasks never\n # merge, opposite polarity (\"X helps\" vs \"X never helps\") is surfaced as a CONTRADICTION instead of being\n # collapsed, paraphrase/inflection variants group by exact structured key (no transitive over-merge), and\n # operator governance is scope-precise (a decision in task A cannot reach a same-worded claim in task B).\n # Affects the `cross_run_advisory` context pack only; ON by default in the product Settings (ce4a379);\n # the bare-library EngineOptions default stays off (engine/options.py). See engine/claims.py.\n cross_run_structured_claims: bool = True\n # PART IV cross-run §22.4 (AGENTIC taxonomy steward). At finalize, when an LLM client is available, let\n # the concept steward (engine/concept_steward.py) review the freshly-updated portfolio concept graph and\n # PROPOSE a curation — merge duplicate slugs / split conflated ones / purge noise. Proposals are LOGGED to\n # `concept_curation_log.jsonl` for operator ratification (via the serve/CLI governance surface); the LLM\n # never mutates fold state. The calls run synchronously during finalize, so they add latency and may add\n # paid inference; a caught steward failure does not prevent terminal completion or trigger a retry.\n # ON by explicit experimental product choice when `memory_dir` + an LLM backend are present; the\n # bare-library EngineOptions default stays off (engine/options.py). See engine/lessons.py.\n cross_run_curation: bool = True\n # Deprecated compatibility flag. Steward output is untrusted and finalize is proposal-only regardless\n # of this value; retained so old snapshots still validate and logs can disclose that auto was requested.\n cross_run_curation_auto: bool = False\n # Role backend (ADR-7/14): \"toy\" (offline optimizer) | \"llm\" (live model). Validated in\n # `_check_trust_gate` alongside the other enum-ish strings — every consumer tests `== \"llm\"`\n # exactly (cli/__init__.py, adapters/tasks.py), so an untyped `--set`/file/env typo (or a mis-cased\n # \"LLM\") used to fall through to the OFFLINE toy roles and quietly produce a run that never called\n # the model. Left as `str`, not `Literal`, so old snapshots still validate through the same path.\n backend: str = \"toy\"\n # Developer backend (ADR-7): \"default\" (templated/LLM from the task) or an external\n # CLI coding agent: \"opencode\" | \"aider\" | \"goose\" | \"continue\".\n developer_backend: str = \"default\"\n # C2 best-of-N: generate N candidate implementations per node and keep the best by an\n # execution-free reward (static validity + metric-print). 1 = off. In-house LLM developer only\n # (not external agents — ADR-7 cost rule). The top SWE-bench reliability lever for weak models.\n best_of_n: int = 1\n # D10 list-wise best-of-N selection: when the top static-scorers TIE, break the tie with a\n # comparative LLM selection over the candidates presented together (+~3 pts vs pointwise on\n # GAIA, arXiv:2506.12928). The static score stays the primary filter — the LLM is a weak\n # comparative prior, never the sole oracle. ON by default (no-op when best_of_n==1).\n best_of_n_listwise: bool = True\n # === Foresight / predict-before-execute ===============================================\n # FOREAGENT predict-before-execute (arXiv:2601.05930): use the LLM as an IMPLICIT WORLD MODEL to\n # predict — WITHOUT executing — which candidate / hypothesis will score best, primed with a\n # Verified Data Analysis Report (data profile / brief) + the accumulated experiment memory. Master\n # switch, ON by default; a no-op wherever there is nothing to rank (best_of_n==1 and\n # foresight_panel==1) or no LLM client. See `looplab/search/foresight.py`.\n foresight: bool = True\n # Hypothesis foresight breadth: generate K candidate ideas per proposal and keep the one PREDICTED\n # best pre-execution by the world model — it ranks the STRUCTURAL / text ideas the numeric k-NN\n # surrogate (researcher_panel) is blind to, primed with the data profile + memory (the synergy).\n # >1 enables it (LLM backend only; needs `foresight` on); 2 = on by default at modest cost, 1 = off.\n foresight_panel: int = 2\n # AGENTIC foresight: run the ranking (hypothesis-board prioritization + K-idea pick) as a TOOL-USING\n # loop that can pull actual experiment results / data facts before deciding, instead of a one-shot\n # prediction from a pre-baked report. ON by default (needs foresight + a client; falls back to the\n # one-shot predictor on any hiccup). A few extra LLM calls per proposal; set False for the cheaper\n # single-call ranker.\n foresight_agentic: bool = True\n # §1 foresight confidence gate: the minimum predicted confidence at which a predict-before-execute\n # pick is ACTED ON. Below it the ranker abstains — the K-idea panel falls back to the first\n # proposal, best-of-N falls through to the D10 tie-break — instead of committing a low-confidence\n # choice. 0.0 (default) = OFF (act on every pick, the historical behavior); raise toward ~0.5 to\n # make the world model defer when it isn't sure. Pairs with the foresight track record (§1) the\n # predictor is now primed with. Range 0.0-1.0.\n foresight_min_confidence: float = 0.0\n # PART IV Phase 2c — replace the world model's SELF-REPORTED confidence (measured Pearson≈0 with the\n # realized outcome, §21.12) with a CALIBRATED §12-verifier score. When on and a client is available,\n # after the K-idea ranker picks the predicted-best candidate the §12 verifier scores it (grounded +\n # repeated + criteria-decomposed: `foresight_criteria` — likely to improve the objective, and\n # sound/feasible) and THAT calibrated score becomes the confidence the `foresight_min_confidence` gate\n # and the telemetry use. Degrades to the self-reported confidence without a client or on any verifier\n # error (the telemetry records which source was used). ON by default in the product Settings (ce4a379),\n # EngineOptions off; a few extra LLM calls per acted-on proposal. See looplab/trust/verifier.py + search/foresight.py.\n # REVISIT (cost/quality, not yet resolved): a live classification run measured ~10 LLM calls & ~3.8 min/node\n # on deepseek-v4-flash with the full Part IV/V default bundle; the calibrated verifier is part of that per-node\n # tax. Kept ON deliberately — it raises decision quality (self-reported foresight confidence has ≈0 Pearson\n # with realized outcome), and lowering quality to save calls is the wrong trade. TODO: run a frozen quality/cost\n # A/B (verifier-on vs -off) to quantify the recall@objective gain per extra call before considering a default flip.\n foresight_verify: bool = True\n # Verifier sample count for `foresight_verify` (the §12 repeated-sampling expectation on a no-logprob\n # backend). 3 tames the single-shot variance §21.12 measured; 1 = single-shot (cheaper, noisier).\n # RESUME-critical: this value is persisted in config.snapshot.json, so a hard `le` schema bound would\n # make an old run started with a larger LOOPLAB_FORESIGHT_VERIFY_SAMPLES un-resumable (Settings(**snapshot)\n # would raise on `looplab resume` / finalize-recovery / serve replay). CLAMP into [1, MAX] via a validator\n # instead of rejecting — the runtime cap is preserved without breaking the snapshots/env-compat rule.\n foresight_verify_samples: int = 3\n\n @field_validator(\"foresight_verify_samples\")\n @classmethod\n def _clamp_foresight_verify_samples(cls, v: int) -> int:\n # Coerce a persisted/env value into [1, MAX] rather than reject (see field note above).\n try:\n v = int(v)\n except (TypeError, ValueError):\n return 3\n return max(1, min(v, MAX_FORESIGHT_VERIFY_SAMPLES))\n\n # T7 LLM response cache: serve an identical DETERMINISTIC (temperature 0) request from an\n # in-process content-addressed cache instead of re-hitting the model — cuts cost on\n # retry/panel/verify flows. NEVER caches sampling calls (temp>0: best-of-N / panel / novelty\n # re-propose must vary). Off by default (most role calls run at temperature>0 anyway).\n llm_cache: bool = False\n agent_cmd: str | None = None # override the agent's launcher (path / wrapper)\n # External-agent validation (ADR-7): wrap a CLI-agent Developer with a validator that\n # audits each output (no-op/syntax/crash/timeout), retries with feedback, and falls\n # back to the in-house LLM Developer. Off -> use the raw agent output unchecked.\n validate_agent: bool = True\n agent_max_retries: int = 1 # re-prompts of the agent on an invalid result\n # Patch-gated multi-file agent (ADR-7 Rule 3): run the CLI agent in a git worktree and\n # accept only edits whose paths match `agent_surface` (reject-not-strip out-of-surface\n # touches). Lets the agent create helper modules, not just solution.py. Degrades to\n # whole-file readback if git is unavailable.\n agent_patch_gate: bool = True\n agent_surface: list[str] = [\"*.py\"] # edit-surface allow-list (globs)\n # Trust policy for an agent-authored eval/metric adapter (RepoTask onboarding, Phase 3):\n # \"ratify_freeze\" waits for human approval, \"autonomous\" accepts the proposed spec without that\n # gate, and \"ratify_freeze_drift\" also enforces the configured independent drift cross-check.\n eval_trust_mode: str = \"ratify_freeze\"\n # RepoTask node seeding policy (run-wide fallback; a task/editable `seed_mode` overrides):\n # \"auto\" (default) copies git-tracked files when the editable is a git repo (so a tree bloated\n # with untracked artifacts isn't deep-copied per node), else a full copy; \"tracked\" forces\n # code-only; \"all\" forces a full recursive copy (legacy). Genesis authors per-task from the\n # user's words; this is the global default when unspecified.\n seed_mode: str = \"auto\"\n llm_model: str = \"qwen3:8b\"\n # === LLM / transport ==================================================================\n llm_base_url: str = \"http://localhost:11434/v1\" # Ollama OpenAI-compatible endpoint\n llm_temperature: float = 0.6\n # H3 per-role model presets (5090 recipe): optionally run the Researcher and Developer on\n # DIFFERENT models/endpoints (e.g. Developer=Qwen3-Coder-30B for code, a fast model for breadth).\n # Blank = use the shared llm_model/llm_base_url. Generalizes the role-backend swap to per-role.\n researcher_model: str | None = None\n developer_model: str | None = None\n researcher_base_url: str | None = None\n developer_base_url: str | None = None\n # Per-role sampling temperature (§4.1): the Researcher wants breadth (higher temp = more diverse\n # ideas), the Developer wants determinism (lower temp = fewer syntax slips), the Strategist a\n # steady hand. Each overrides the shared `llm_temperature` for that role only; None = use the\n # shared value (byte-identical to before). Flat + nullable, mirroring the per-role model fields.\n researcher_temperature: float | None = None\n developer_temperature: float | None = None\n strategist_temperature: float | None = None\n # Unified self-driving agent: one LLM identity that plays Researcher + Developer (+ Strategist)\n # across pipeline stages, choosing its own model/toolset per stage. ON by default — the agent\n # drives the loop (incl. crash triage: repair/abandon/reject_idea) and, via\n # `agent_drives_actions`, picks the next macro action within a pure legal-action gate. Set both\n # to False for the legacy byte-identical split-role behavior. (No-op unless backend=\"llm\".)\n unified_agent: bool = True\n agent_drives_actions: bool = True\n # Layer 3 Card queue ownership. False preserves the existing policy/unified-pilot action path.\n # True is pinned in run_started because changing the selector on resume would mix two search\n # treatments in one run. When both this and agent_drives_actions are true, Card selection wins.\n card_driven_selection: bool = False\n # Layer 5 bounded speculative Card buffer. Zero is a hard OFF switch and preserves the\n # historical alternating build/eval spine; positive values are pinned by run_started so a\n # resume cannot silently mix search treatments after a snapshot/default edit.\n speculation_depth: int = Field(default=0, ge=0, le=64)\n # Local evidence receipt produced by ``looplab speculation-gate``. This is deliberately absent\n # from the curated Settings UI: positive-depth execution is a calibrated rollout decision, not a\n # casual tuning knob. The Engine validates the receipt against the current scorer,\n # implementation, GPU inventory and raw paired-run evidence before it enables speculation.\n speculation_gate_receipt: str | None = Field(default=None, max_length=4096)\n # Per-stage model/endpoint overrides for the unified agent. Recognized keys (see\n # tasks.build_unified_agent): `propose` (researcher), `implement`/`repair` (developer),\n # `strategy`, `pilot`. Unlisted keys are ignored. Empty = the per-role researcher_*/developer_*\n # models, then the shared llm_model. Explicit map wins. No-op unless `unified_agent` is true.\n agent_stage_models: dict = {}\n agent_stage_base_urls: dict = {}\n llm_parser: str = \"tool_call\"\n # Reasoning/thinking toggle sent IN the request (providers differ — see `llm.reasoning_body`):\n # Qwen3 on vLLM/SGLang -> chat_template_kwargs.enable_thinking (bool)\n # OpenAI/Ollama-v1/DeepSeek -> reasoning_effort (low|medium|high|none)\n # `llm_reasoning`: \"\" = send nothing (server default — current behavior); off = actively disable;\n # on = enable at default depth; low|medium|high = enable at that effort.\n # Defaults to \"high\" so the agent reasons before proposing/repairing/triaging (better code, fewer\n # crashes); set \"\" to send nothing (server default) or \"off\" to disable. The model's chain-of-thought\n # is CAPTURED either way (reasoning_content field or inline <think>). NOTE: to get a SEPARATE\n # reasoning_content field from SGLang/vLLM, the server also needs `--reasoning-parser qwen3`.\n llm_reasoning: str = \"high\"\n llm_reasoning_style: str = \"auto\" # auto | qwen | effort | none — how to shape the request param\n llm_reasoning_extra: dict = {} # raw fields merged into the body (escape hatch, e.g. Anthropic thinking)\n # H1: drive structured calls with the endpoint's constrained decoding (vLLM/SGLang guided_json +\n # response_format json_schema) so weak models can't emit invalid JSON. Off for Ollama (default).\n llm_guided_json: bool = False\n # Stream every LLM request (SSE) and reassemble it, so the request `timeout` is an INTER-TOKEN\n # idle timeout rather than a whole-request deadline: a long-but-alive generation is never cut off\n # (tokens keep resetting the timer), while a genuinely STALLED endpoint (no token for `timeout` s)\n # is detected and retried on a fresh connection — the fix for silent multi-minute hangs. Set False\n # to use one blocking read (old per-op timeout semantics) if an endpoint streams badly.\n llm_stream: bool = True\n # LLM IDLE timeout (seconds). Stream mode: the INTER-TOKEN stall limit — a steady generation is\n # never cut off, only a silent endpoint. Non-stream: bounds the whole read. Raise for endpoints\n # with a long prefill on huge prompts; lower to fail fast on a flaky shared endpoint.\n llm_timeout: float = Field(default=180.0, gt=0)\n # First-byte (response-headers) window for STREAM attempts, seconds: an SSE response sends its\n # headers on admission, so no-headers ≈ a black-holed request — fail over to a fresh connection\n # fast instead of waiting the full idle timeout. Clamped to llm_timeout. Non-stream attempts are\n # NOT bounded by this (their headers arrive only after the whole generation). The default mirrors\n # `DEFAULT_HEADER_TIMEOUT_S` in looplab/core/llm.py is the single source (imported above).\n llm_header_timeout: float = Field(default=DEFAULT_HEADER_TIMEOUT_S, gt=0)\n # Honour HTTP(S)_PROXY / NO_PROXY / SSL_CERT_FILE env vars for LLM calls. Default False: the\n # transport connects DIRECTLY (many internal/OLAP endpoints are reachable only by bypassing an\n # ambient proxy — a picked-up proxy yields \"connection refused\"). Set true when the endpoint is\n # reachable ONLY via a corporate proxy or needs a custom CA bundle from the environment.\n llm_trust_env: bool = False\n # H4/C2: compact the growing agentic tool-call history once it exceeds this many chars (auto_summary\n # LLM-summarizes the stale middle; else middle-truncate). Sized to the model's real context window so\n # files read earlier STAY in context instead of being compacted away and re-read: ~1,000,000 chars ≈\n # 250k tokens (≈4 chars/token), matching the configured deepseek-v4-flash (≥200k tokens confirmed).\n # The old 120k-char (~30k-token) default was ~8× too aggressive and drove a read-thrash loop. 0 = off.\n context_budget_chars: int = 1_000_000\n # M5: the Researcher's always-on experiments digest budget (chars). 0 = AUTO — scales with the\n # run size (60 chars/node, bounded [1200, 6000]) instead of one flat cap for an 8-node toy run\n # and a 200-node benchmark run alike. Set a positive value to pin it.\n digest_char_cap: int = Field(default=0, ge=0)\n # D11 compression model slot (open_deep_research's four-slot pattern): a dedicated CHEAP model\n # for agent-history summarization (C2 auto-summary), so compression doesn't pay the main\n # model's price. Blank = use each loop's own model (legacy). `compressor_base_url` blank =\n # the shared llm_base_url.\n compressor_model: str | None = None\n compressor_base_url: str | None = None\n # === Agentic tool-loop ================================================================\n # Agentic tool-loop limits — apply to EVERY tool-using agent (the LLM Researcher, the unified\n # agent's pilot + crash-triage, Deep-Research, the run-chat Boss, the genesis repo-scout, and the\n # cross-run report synthesizer). The loop lets the model call read-only tools across turns before\n # emitting its structured result. Both default to UNLIMITED so the agent takes as many turns / as\n # long as it needs — it is never cut off mid-reasoning; set a positive value here (or per-run, or\n # in the UI settings) to bound latency/cost. These used to be hardcoded per call site (e.g. the\n # boss at max_turns=3 / 45s), which silently dropped a slow reasoning model to a no-op reply.\n agent_max_turns: int = 0 # max tool turns before the emit is forced (0 = unlimited)\n agent_time_budget_s: float = 0.0 # wall-clock ceiling across the loop's turns (0 = no cap)\n # G · Soft convergence SAFETY NET (not a research cap). Two thresholds on the agent's tool-turn count:\n # at `agent_emit_after` the loop NUDGES the agent once (\"you've investigated enough — emit now\"); at\n # `agent_emit_force` it FORCES the emit from what was gathered. Set GENEROUSLY — a focused researcher\n # may legitimately read many files/experiments before it can propose a grounded idea; these only catch\n # a genuine runaway that keeps issuing DIFFERENT calls forever (which stuck-detection, keyed on\n # repeats, misses). NB: the old 499-read runaway (node 63) was 79% REDUNDANT re-reads caused by a\n # broken read_file — fixing read_file (pagination) is the real cure; this is just insurance. 0 = off.\n agent_emit_after: int = 300\n agent_emit_force: int = 500\n # B1 · No-progress / stuck detection — the safety net that makes \"unlimited turns\" safe. The\n # turn/time ceilings above are only BACKSTOPS; this is what actually stops a runaway loop, on the\n # cheapest signal: when the model repeats the SAME tool call (or ping-pongs between two, or keeps\n # hitting the SAME error) with no progress, the loop forces the final emit and finishes. ON by\n # default; reading DIFFERENT files or running ONE long command never trips it. (OpenHands-style.)\n agent_stuck_detection: bool = True\n agent_stuck_repeat: int = 4 # identical calls in a row that count as \"stuck\" (>=2)\n agent_stuck_alternate: int = 4 # ping-pong cycles between two calls that count as \"stuck\" (>=2)\n # C1 · Self-plan (TodoWrite-style): expose an `update_plan` tool so a long-running agent keeps its\n # OWN working TODO and re-surfaces it every `agent_plan_reinject_every` turns — keeps the goal in\n # view across a long loop so the agent \"writes its own context\" (Claude Code style). ON by default.\n agent_self_plan: bool = True\n agent_plan_reinject_every: int = 5\n # C2 · Auto-summary (ON by default): when the tool-loop history grows long, LLM-summarize the\n # stale middle instead of only middle-truncating it. The trigger is `context_budget_chars` if set,\n # else a built-in high-water mark (context_budget.DEFAULT_SUMMARY_CHARS ≈ 120k chars) so short\n # loops are untouched. Falls back to truncation on any summarizer error; one extra model call per\n # over-budget turn.\n agent_auto_summary: bool = True\n # C4 · Repo-developer plan decomposition. A big feature attempted in ONE developer session made a\n # non-converging reasoning model loop indefinitely — it kept writing + exploring without ever\n # emitting `done` (10k+ LLM calls / hours; agentic stuck-detection can't catch VARIED exploration,\n # only literal repeats). Fix: the developer first proposes an ordered plan of ATOMIC steps; a\n # multi-step plan is executed step-by-step, each in a FRESH bounded session with only that step's\n # scope + the files accumulated so far, syntax-validated per write. Every session stays\n # small-context and convergent. ON by default for repo tasks (a 1-step plan == the old single pass).\n developer_plan_decompose: bool = True\n developer_plan_min_steps: int = 2 # a proposed plan with >= this many steps runs step-by-step\n developer_plan_max_steps: int = 8 # cap on plan length (a runaway planner can't spawn 100 steps)\n # Hard per-session backstop for the repo developer — the ONE write-heavy agent that can run away\n # even with stuck-detection on (varied writes/reads never trip the repeat/ping-pong signal). Bounds\n # the plan phase, EACH step, and the single-session fallback. Unlike the global agent_* limits\n # (0 = unlimited, meant for read-only agents), the developer always gets a FINITE ceiling so a\n # model that never emits `done` fails cleanly with the code it wrote so far, instead of looping.\n developer_session_max_turns: int = 500\n developer_session_time_budget_s: float = 1200.0 # 20 min wall-clock per developer session\n # Phase-handoff summaries. Each LLM phase in a node build (Researcher·propose → Developer·stages →\n # plan → implement) ends with ONE extra LLM call that distills its transcript — the repo structure\n # it mapped, files/data confirmed, decisions made — into a brief injected into the NEXT phase (even\n # across the role boundary). The next phase TRUSTS that instead of re-reading the same files, which\n # cuts the biggest source of tool-call thrash (stages/plan/implement each re-exploring the repo).\n # One summary call per phase in exchange for many fewer read calls downstream. ON by default.\n phase_handoff_summary: bool = True\n # Observability (ADR-17): capture each LLM call's full prompt + completion into the active\n # span (spans.jsonl) so the UI can show exactly what the model read and wrote per node.\n # Diagnostics only — `replay.fold` never reads spans. Default on for local single-user; set\n # LOOPLAB_TRACE_LLM_IO=0 to suppress (e.g. to keep spans.jsonl small or avoid storing prompts).\n trace_llm_io: bool = True\n # Run-introspection tools (ON by default): make the LLM Researcher (and DeepResearcher) a\n # tool-using agent that can read its OWN experiments (list/read/find-analogous/themes/code) and\n # the task data (schema/profile/asset) mid-loop, instead of seeing only best+parent. Advisory —\n # never changes best-selection. Off = the legacy single-shot Researcher (richer digest still added).\n researcher_tools: bool = True\n # Cross-run introspection: give the agentic Researcher / pilot read-only tools to look at\n # SIBLING runs (same task_id, same run-root) — list them, read an experiment / its code, and\n # find analogous configs across runs — so a run can build on what neighbouring runs already\n # learned instead of rediscovering it. Advisory; never changes best-selection. Needs the run's\n # own dir wired through (no-op for unit-built roles). Off = the legacy single-run view only.\n cross_run_tools: bool = True\n # Read-only access to EVERY run on this machine ACROSS ALL TASKS (not just same-task siblings):\n # the Developer/Researcher get list_all_runs + read_run_code + read_run_experiment so they can read\n # the code + result of ANY past experiment anywhere and reuse an approach. Broader than\n # `cross_run_tools` (same-task only); the agent decides when a foreign run is relevant. Advisory;\n # never changes best-selection. Needs the run's own dir wired through (no-op for unit-built roles).\n all_runs_tools: bool = True\n # PART V §22 — read-only CROSS-RUN KNOWLEDGE tool for the reasoning roles (Researcher, Strategist,\n # deep-research, and a Developer-scoped variant). Adds `cross_run_prior_attempts` / `cross_run_claims`\n # / `cross_run_atlas` over the §21.20 read-models (concept overview + claim assessments + atlas), read\n # from `memory_dir` (lessons.jsonl + concept_capsules.jsonl). Read-only is a storage-authority\n # boundary, not a behavioral no-op: retrieved content can steer later proposals, and a tool-using\n # reasoning loop can consume additional model calls. The tools never mutate cross-run truth (facts\n # are engine-written, verdicts operator-ratified, §22.4) or directly select a winner. ON by explicit\n # experimental product choice, EngineOptions off; stores only exist once Part-IV features have run.\n cross_run_read_tools: bool = True\n # Agentic retrieval (ADR-16): if set, the LLM Researcher gets grep/kb_search/read\n # tools over this directory of markdown notes and chooses when to use them.\n knowledge_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"knowledge\"))\n # T4 real embeddings: model id for an OpenAI-compatible `/embeddings` endpoint used for\n # kb_search / case retrieval. Blank (default) = the dependency-free lexical `hash_embed` (today's\n # behavior). Set e.g. \"nomic-embed-text\" (Ollama) or \"text-embedding-3-small\" for SEMANTIC\n # retrieval; a misconfigured/offline endpoint degrades back to hash_embed at call time (never\n # crashes a run). `embed_base_url` blank = reuse `llm_base_url`; the shared `llm_api_key` is used.\n embed_model: str | None = None\n embed_base_url: str | None = None\n # Harmonic memory (Memora, ICML'26 — idea import). When ON, cross-run cases and knowledge notes are\n # indexed by a short ABSTRACTION + cue ANCHORS instead of their raw text, near-duplicate memories are\n # CONSOLIDATED into one entry under a matching abstraction, and `kb_search`/case retrieval EXPAND\n # through the top hits' anchors to surface related-but-not-similar memories. ON by default. Set\n # `memora=false` to restore the pre-Memora raw-text index. Never a source of truth — abstractions\n # live only in the derived, rebuildable retrieval index (never the event log or canonical cases.jsonl).\n memora: bool = True\n # Write abstractions with the wired chat model (richer than the deterministic lexical fallback). ON\n # by default; results are CACHED by content hash (see `memora_cache`) so a re-built index doesn't\n # re-call the model on unchanged notes/cases, and any endpoint failure degrades to lexical at call\n # time — so an offline box just gets lexical abstractions, never a crash. Set false to force lexical.\n memora_llm: bool = True\n # === Cross-run memory / Memora ========================================================\n memora_anchors: int = 6 # max cue anchors kept per memory\n # Cosine similarity at/above which a new case is CONSOLIDATED into an existing one (same evolving\n # topic) rather than stored as a separate entry. Only used when `memora` is on.\n memora_consolidate_threshold: float = 0.86\n # Where the LLM-abstraction cache lives (JSON). Blank (default) derives it from `memory_dir`\n # (`<memory_dir>/memora_cache.json`) or else `knowledge_dir` (`<knowledge_dir>/.memora_cache.json`);\n # with neither set the cache is in-memory only (per process). No-op unless `memora_llm` is on.\n memora_cache: str | None = None\n # E3 literature-grounded ideation (network-OPTIONAL): give the agentic Researcher an arXiv search\n # tool to ground ideas in real techniques. Off by default (egress is unreliable on some boxes);\n # fails gracefully if the network is blocked.\n literature_search: bool = False\n # Deep-Research stage (network-OPTIONAL): give the DeepResearcher a general web search/fetch\n # tool (DuckDuckGo) on top of arXiv to read across results + the web before steering the next\n # batch. Off by default (egress is unreliable on some boxes); fails gracefully when blocked.\n web_search: bool = False\n # Cadence for the Deep-Research stage: run it automatically every N created nodes. Zero disables\n # only the cadence; manual `deep_research` and Strategist `request_research` triggers still fire.\n # Default 3: deep research analyzes the accumulating results and steers the next batch (its\n # directions become hints + open hypotheses); paired with `concurrent_research` it overlaps the\n # think with the GPU-bound eval, so the cadence costs LLM tokens but no wall-clock on the search.\n deep_research_every: int = 3\n # Overlap a DUE deep-research \"think\" with the GPU-bound eval instead of running it in its own\n # serial step (the agent is otherwise idle while a node trains). research() computes from a state\n # snapshot; the joined background task records only allowlisted, order-tolerant\n # research/hint/hypothesis events as soon as the memo finishes. ON by default: the LLM is typically\n # remote (no GPU contention with eval), so overlapping hides latency behind long training.\n concurrent_research: bool = True\n # Concurrent-research REPEAT — don't idle a multi-DAY eval. The overlapped \"think\" fires ONCE per\n # window by default; ON here, it RE-RUNS on an adaptive time cadence for the WHOLE eval window\n # (a two-day training must not leave the reasoning agents idle after one memo). Self-paced: it\n # only records a memo whose CONTENT is new (identical re-runs are skipped, so the log/board don't\n # bloat) and backs off geometrically as the analysis converges, capped so it always re-checks.\n # The appended records never rewrite the current metric champion, but their hints/open hypotheses\n # deliberately steer later proposals and replay reconstructs that advice. The library default is\n # one-shot (== today, byte-identical); this product default turns the repetition on.\n concurrent_research_repeat: bool = True\n # Base cadence (seconds) between repeated concurrent-research passes. Research is EXPENSIVE\n # (multi-turn LLM + web/arXiv), so this is a FLOOR, not a ceiling: the effective cadence is\n # max(this, ~5% of the per-experiment time budget) — a two-day eval is re-researched about hourly,\n # a short eval not at all (the first tick outlasts it). Only matters when the repeat is on.\n concurrent_research_interval_s: float = Field(default=1800.0, gt=0)\n # Per-eval-window backstop on repeated-research LLM calls (0 = cadence-only, no cap). The adaptive\n # cadence + convergence backoff are the primary budget control; this bounds a pathological\n # never-converging window. Past it the loop stops calling the LLM (the health monitor still runs).\n concurrent_research_max_calls: int = Field(default=40, ge=0)\n # Concurrent hypothesis-board CONSOLIDATION during the eval window. Repeated research keeps ADDING\n # near-duplicate directions to the open board (each memo registers up to 5 as open hypotheses), so a\n # long eval's board bloats before the between-nodes merge runs. This dedups/merges it on the SAME\n # background loop so the board stays clean for the next proposal. In Card-driven mode the same merge\n # changes ownership/readiness, so it is deferred to the joined main-task cadence instead. Needs the\n # reflect client + `track_hypotheses`.\n # Only active while `concurrent_research` runs the overlap loop. Library default off (== today).\n concurrent_consolidate: bool = True\n # Cadence for the agent-authored run report: regenerate the conclusion-first narrative every N\n # created nodes (0 = off; it still regenerates on a manual `report_refresh` from the UI). The\n # deterministic report always renders from the node set regardless of this knob.\n report_every: int = 3\n # Agent Skills (I18, ADR-9): dir of SKILL.md the Researcher can list/load as tools.\n skills_dir: str | None = None\n # Prompt store (I18, ADR-8): dir of editable, hot-reloaded role prompt .md files.\n prompt_dir: str | None = None\n # API key as a reference, never serialized as a value (ADR-11). Local servers\n # ignore it; default to a placeholder.\n llm_api_key: SecretStr | None = None\n\n @model_validator(mode=\"before\")\n @classmethod\n def _reject_bool_for_numeric_fields(cls, data):\n \"\"\"A JSON boolean is a TYPE ERROR for a numeric setting, not the number 1.\n\n Pydantic runs in lax mode here (`model_config` sets no `strict`), and `isinstance(True, int)`\n is True — so `{\"max_nodes\": true}` from a UI/API type slip validated cleanly as a budget of\n ONE node, and `{\"eval_parallel\": true}` collapsed the width to one. The run then looked\n configured and simply did almost nothing, with no 422 and nothing in the snapshot to show why.\n Rejecting at the boundary covers every numeric field at once, including the many that carry no\n `Field(ge=...)` bound of their own to trip over.\n \"\"\"\n if not isinstance(data, dict):\n return data\n for key, val in data.items():\n if type(val) is bool and _is_numeric_annotation(\n getattr(cls.model_fields.get(key), \"annotation\", None)):\n raise ValueError(f\"{key} must be a number, not a boolean (got {val!r})\")\n return data\n\n @model_validator(mode=\"before\")\n @classmethod\n def _apply_profile(cls, data):\n \"\"\"Expand `profile` into a bundle of setting defaults BEFORE validation.\n\n pydantic-settings hands this validator the fully-merged input from ALL explicit sources\n (init kwargs + LOOPLAB_* env + .env), with fields the user left unset simply ABSENT (they\n fall to field defaults afterward). So a profile value is applied only when its key is not\n already present — i.e. any explicit file/CLI/env setting wins over the profile, and the\n profile wins over the bare field default. This makes `thorough` a true convenience preset,\n never an override of what the user asked for.\"\"\"\n if not isinstance(data, dict):\n return data\n prof = data.get(\"profile\", \"default\")\n # env may deliver the key as \"profile\" (env_prefix stripped) — normalize to a str\n prof = str(prof).strip().lower() if prof is not None else \"default\"\n overrides = PROFILES.get(prof)\n if overrides is None:\n raise ValueError(f\"unknown profile {prof!r}; known: {sorted(PROFILES)}\")\n for key, val in overrides.items():\n if key not in data: # explicit source (file/CLI/env) always wins\n data[key] = val\n return data\n\n @model_validator(mode=\"after\")\n def _check_trust_gate(self):\n if self.trust_gate not in (\"audit\", \"gate\", \"block\"):\n raise ValueError(\n f\"trust_gate must be audit|gate|block, got {self.trust_gate!r}\")\n if self.merge_mode not in (\"auto\", \"mean\", \"ensemble\"):\n raise ValueError(\n f\"merge_mode must be auto|mean|ensemble, got {self.merge_mode!r}\")\n # novelty_mode drives the dedup/novelty gate; an out-of-set value (e.g. a mis-cased\n # LOOPLAB_NOVELTY_MODE=LLM, or \"on\") used to fall through _apply_novelty_gate silently as a\n # NO-OP — turning the gate off with no diagnostic. Fail loudly at config time like trust_gate/\n # merge_mode do. (Other enum-ish string fields — seed_mode/eval_trust_mode/strategist_backend —\n # share the pattern; left unvalidated here only because their full value sets are less settled.)\n if self.novelty_mode not in (\"off\", \"algo\", \"llm\"):\n raise ValueError(\n f\"novelty_mode must be off|algo|llm, got {self.novelty_mode!r}\")\n # The remaining enum-ish string fields (arch-review §5 P3): a typo used to be accepted at\n # construction and only fail-safe/later-loud downstream (e.g. a mis-cased strategist_backend\n # silently ran the default). Fail loudly here like the fields above.\n if self.strategist_backend not in (\"off\", \"rule\", \"llm\", \"agent\"):\n raise ValueError(\n f\"strategist_backend must be off|rule|llm|agent, got {self.strategist_backend!r}\")\n if self.eval_trust_mode not in (\"ratify_freeze\", \"autonomous\", \"ratify_freeze_drift\"):\n raise ValueError(\n \"eval_trust_mode must be ratify_freeze|autonomous|ratify_freeze_drift, \"\n f\"got {self.eval_trust_mode!r}\")\n if self.seed_mode not in (\"auto\", \"tracked\", \"all\"):\n raise ValueError(f\"seed_mode must be auto|tracked|all, got {self.seed_mode!r}\")\n if self.backend not in (\"toy\", \"llm\"):\n raise ValueError(f\"backend must be toy|llm, got {self.backend!r}\")\n return self\n\n def masked_snapshot(self) -> dict:\n # Snapshots are a JSON contract (they are written verbatim to config.snapshot.json), not a\n # Python-object dump. In particular tuple-valued schema fields such as\n # ``inline_repair_reasons`` must become JSON arrays before callers hash the runtime envelope;\n # otherwise the strict speculation-scope canonicalizer sees a different/non-JSON preimage\n # from the one that ``json.dumps`` eventually persists.\n d = self.model_dump(mode=\"json\")\n if self.llm_api_key is not None:\n d[\"llm_api_key\"] = \"***\"\n else:\n d[\"llm_api_key\"] = None\n return d\n\n\n# Pre-versioned snapshots are full Settings dumps, so absence is a reliable per-field version marker.\n# Keep their historical effective behavior when newer product defaults become active: re-entry must not\n# silently add paid calls, interventions, concurrency or a different selection policy to an old run.\nLEGACY_CONFIG_SNAPSHOT_DEFAULTS: dict[str, object] = {\n \"parallel_build\": 1,\n \"eval_parallel\": None,\n \"llm_parallel\": None,\n \"train_monitor\": False,\n \"train_monitor_interval_s\": 600.0,\n \"train_monitor_kill\": False,\n \"train_monitor_kill_confidence\": 0.8,\n \"asha_live\": False,\n \"asha_live_kill\": False,\n \"asha_live_quantile\": 0.5,\n \"asha_live_min_siblings\": 3,\n # Before this operator ceiling existed, the sandbox's 24-hour process limit was authoritative.\n \"max_eval_timeout\": 24 * 3600.0,\n \"watchdog_reflection\": False,\n \"card_driven_selection\": False,\n \"speculation_depth\": 0,\n \"speculation_gate_receipt\": None,\n \"concurrent_research_repeat\": False,\n \"concurrent_research_interval_s\": 1800.0,\n # Zero means unlimited; 40 was the first bounded default, and is latent while repeat is disabled.\n \"concurrent_research_max_calls\": 40,\n \"concurrent_consolidate\": False,\n}\n\n\ndef migrate_config_snapshot(data: dict) -> dict:\n \"\"\"Return an effective copy of one legacy/current snapshot without rewriting evidence bytes.\"\"\"\n if not isinstance(data, dict):\n raise TypeError(\"config snapshot must be a JSON object\")\n migrated = dict(data)\n for key, value in LEGACY_CONFIG_SNAPSHOT_DEFAULTS.items():\n migrated.setdefault(key, value)\n return migrated\n\n\ndef settings_from_snapshot(data: dict) -> Settings:\n # CODEX AGENT: snapshots have no schema/runtime version and Settings ignores unknown fields.\n # An older binary can silently drop newer behavior-affecting controls and resume the same event\n # history with different paid/concurrency/selection semantics. Version snapshots and fail closed\n # on an unsupported downgrade before migration/default filling.\n \"\"\"Build re-entry Settings from a masked snapshot under its historical missing-field contract.\"\"\"\n migrated = migrate_config_snapshot(data)\n migrated.pop(\"llm_api_key\", None)\n return Settings(**migrated)\n"}, "f9c516fcf3b65b922f1608cc49cf16571da29790": {"t": 1785768761.8488405, "n": 99776, "h": "ba93c44c0ca1bb36", "c": "\"\"\"Engine config: the schema (what settings exist). (I0, ADR-11)\n\nOne pydantic-settings class declaring every engine knob, its default, and its docs. The loader —\nhow a run file / CLI / env becomes a `Settings` — is `looplab.core.appconfig`, which also owns\nthe config-precedence order. A resolved, secret-masked snapshot is written next to each run for\nreproducibility. (No real secrets in P0, but the masking discipline is in place.)\n\"\"\"\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nfrom pydantic import Field, SecretStr, field_validator, model_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n# Single source of the LLM first-byte (response-headers) default — see core/llm.py.\nfrom looplab.core.llm import DEFAULT_HEADER_TIMEOUT_S\n\n# Default home for cross-run memory + the knowledge base (shared across every run). Both are ON by\n# default now (a user asked for it) so a fresh install accumulates + consults knowledge with no setup;\n# set the env var to \"\" to disable, or to a path to relocate. `~/.looplab/` keeps them out of any one\n# project + shared across projects.\n_LL_HOME = Path.home() / \".looplab\"\n\n# Autonomous roles that may be granted per-setting write access (see Settings.agent_control).\n# strategist = A7 meta-controller (run-wide); boss = run-chat operator-proxy (run-wide);\n# researcher = per-experiment proposer (per-node sizing, e.g. eval timeout for a heavy model).\nAGENT_ROLES = (\"strategist\", \"boss\", \"researcher\")\n\n# A foresight score is computed for every acted-on proposal. Keep this lower than the generic\n# verifier's emergency ceiling so a typo cannot multiply paid synchronous calls across a run.\nMAX_FORESIGHT_VERIFY_SAMPLES = 8\n\n\n# Layer-2 parallelism renamed two public settings without removing the durable legacy fields. Keep\n# the equivalence in one place: config-source precedence, governance migration, and live strategy\n# application must all agree which names describe the same authority/value.\nPARALLELISM_ALIASES: dict[str, str] = {\n \"max_parallel\": \"eval_parallel\",\n \"parallel_build\": \"llm_parallel\",\n}\n\n\ndef parallelism_aliases(setting: str) -> tuple[str, ...]:\n \"\"\"Return every spelling in ``setting``'s alias family, canonical name first.\"\"\"\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if setting in (legacy, canonical):\n return canonical, legacy\n return (setting,)\n\n\ndef canonicalize_parallelism_source(\n values: dict, *, promote_build_to_llm_parallel: bool = False,\n) -> dict:\n \"\"\"Promote legacy parallel keys *within one config-precedence source*.\n\n Applying this only after file/CLI/env were flattened would let a lower-priority canonical env\n value shadow a higher-priority legacy file value (or a file canonical value shadow a CLI legacy\n override), so callers with sub-layers normalize each layer independently.\n\n ``max_parallel``/``eval_parallel`` are true aliases (pure eval width, no extra semantics) and are\n always promoted. ``parallel_build``/``llm_parallel`` are NOT: a positive ``llm_parallel`` also flips\n on the finite shared LLM broker (``orchestrator._startup_llm_total``), while legacy ``parallel_build``\n means \"legacy build width, unbounded overlap, no shared total\". So promoting a legacy-only\n ``parallel_build`` layer (e.g. just ``LOOPLAB_PARALLEL_BUILD=1`` in env) would silently serialize\n every provider call — contradicting llm_broker.py's docstring, orchestrator's \"legacy-only preserves\n unbounded overlap\" note, the broker-neutral live-control path, and the configuration.md ``llm_parallel``\n row (\"falls back ... without enabling a shared total\"). Config/startup loads therefore keep broker\n opt-in tied to an EXPLICITLY-spelled canonical ``llm_parallel``; the orchestrator's own width fallback\n (``llm_parallel if not None else parallel_build``) still carries the legacy build width. Only the\n Strategist live-control path (``canonicalize_strategy_parallelism``) opts into\n ``promote_build_to_llm_parallel``, where ``parallel_build`` is a deliberate full alias of the\n canonical knob (see strategy.py ``_apply_strategy``).\n \"\"\"\n if not isinstance(values, dict):\n return values\n out = dict(values)\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if canonical == \"llm_parallel\" and not promote_build_to_llm_parallel:\n continue\n # None is the durable legacy mode for llm_parallel, not a missing value. A normal snapshot\n # contains llm_parallel:null plus parallel_build:1; promoting null to 1 would unexpectedly enable\n # the global one-call broker. Promote aliases only when the canonical key is absent, and pin\n # snapshot round-trip parity. Canonicalize inside each precedence layer, never across the\n # flattened result, so the alias migration cannot invert CLI > file > env precedence.\n if canonical not in out and out.get(legacy) is not None:\n out[canonical] = out[legacy]\n # PRECEDENCE REPAIR for the non-promoted pair. Skipping promotion above is right (a legacy-only\n # layer must not flip on the shared broker), but it also let a LOWER-priority canonical value\n # survive a HIGHER-priority legacy override: file `llm_parallel: 2` + CLI `-s parallel_build=8`\n # flattened to BOTH keys, and the orchestrator prefers `llm_parallel`, so the operator's explicit\n # CLI width was silently discarded — exactly the inversion `build_settings`' docstring says this\n # per-layer canonicalization exists to prevent. Mask it with the LEGACY SENTINEL instead of\n # promoting: `llm_parallel=None` is the durable \"legacy mode — take the build width from\n # parallel_build, no shared total\", so the higher layer wins with the semantics it asked for.\n if (not promote_build_to_llm_parallel and \"llm_parallel\" not in out\n and out.get(\"parallel_build\") is not None):\n out[\"llm_parallel\"] = None\n return out\n\n# Settings whose semantics are committed by ``run_started`` and restored from the folded event log\n# on every re-entry. Changing one only in ``config.snapshot.json`` would mix incompatible evidence\n# (a different holdout split) or make the live producer disagree with replay's selection policy.\n# The HTTP config editor and the engine both consume this one contract; keep deliberately mutable\n# controls such as ``trust_gate`` out (that field has its own durable change event).\nRUN_START_PINNED_FIELDS = frozenset({\n \"card_driven_selection\",\n \"speculation_depth\",\n \"holdout_fraction\",\n \"holdout_select\",\n \"select_verifier\",\n \"select_verifier_samples\",\n \"verifier_ci_tie\",\n})\n\n\ndef run_start_pinned_settings(state) -> dict:\n \"\"\"Return the effective Settings names committed by a folded run-start record.\n\n Pre-pin legacy logs have no recorded holdout fraction, so their snapshot remains authoritative\n for both holdout knobs. Verifier fields, however, have always been re-pinned from the legacy-safe\n fold defaults whenever a valid ``run_started`` identity exists.\n \"\"\"\n if not getattr(state, \"run_id\", \"\"):\n return {}\n values = {\n # Layer 3 changes which queue owns macro-action selection. Once a run starts, replay and\n # resume must keep the same owner even if the snapshot or ambient default changes later.\n \"card_driven_selection\": bool(getattr(state, \"card_driven_selection\", False)),\n # Layer 5 overlaps Card production with evaluation only when this pinned treatment is\n # positive. Old logs omit it and therefore retain the exact serial build/eval spine.\n \"speculation_depth\": int(getattr(state, \"speculation_depth\", 0)),\n \"select_verifier\": bool(getattr(state, \"select_verifier_tiebreak\", False)),\n \"select_verifier_samples\": int(getattr(state, \"select_verifier_samples\", 3)),\n \"verifier_ci_tie\": bool(getattr(state, \"verifier_ci_tie\", False)),\n }\n holdout_fraction = getattr(state, \"holdout_fraction\", None)\n if holdout_fraction is not None:\n values.update({\n \"holdout_fraction\": holdout_fraction,\n \"holdout_select\": bool(getattr(state, \"holdout_select\", False)),\n })\n if not values.keys() <= RUN_START_PINNED_FIELDS:\n raise RuntimeError(\"folded run-start pinned settings contract drifted\")\n return values\n\n# --- Run profiles (config-first presets) -------------------------------------------------------\n# A `profile` is a NAMED BUNDLE of setting defaults — nothing more. It only fills fields the user\n# did NOT set explicitly (via file / --set / LOOPLAB_* env / init-kwargs), so any explicit knob\n# always wins (see `_apply_profile`). Most engine intelligence lives behind individual flags. The\n# PART IV/V cross-run + concept-graph machinery (concept_pivot, graded_novelty, cross_run_concepts /\n# _advisory / _structured_claims / _curation / _read_tools, fingerprint_universal, foresight_verify)\n# now ships ON in the product `Settings` default. That is an explicit experimental product choice,\n# not a claim that a frozen A/B or workload-wide cost/quality gate has cleared: the bundle can steer\n# prompts and candidate generation, change graded-novelty admission / pre-execution choices, and add\n# paid LLM work when its client and memory prerequisites are present. The bare-library `EngineOptions`\n# default stays lean (see tests/test_options_divergence.py — product side is the aggressive one) so a\n# toy `Engine(...)` in a test doesn't fire cross-run LLM work unasked. The trust/confirm/ablate QUALITY\n# bundle still ships OFF and is turned on by `thorough`. Every value here is reachable by hand — the\n# profile is a convenience, never a hidden mode.\n# keep this rationale behavioral and evidence-based; do not relabel read-only storage\n# access or proposal-only governance as a no-op for agent behavior, cost, or proposal admission.\n#\n# `thorough` deliberately touches only QUALITY/TRUST machinery, not spend: it does NOT raise\n# max_nodes / eval_parallel (those are cost knobs the user owns). It enables multi-seed confirmation\n# (demote seed-lucky leaders), the reward-hack / leakage / critic monitors AND flips them from\n# advisory to *gating* (`trust_gate=\"gate\"` — a\n# flagged win can no longer be selected as best), ablation-driven refinement, and the prompt cues\n# (complexity / budget / failure-reflection / watchdog-reflection). Agentic novelty and the experimental\n# Part IV/V bundle are already product defaults, not profile changes. Effect is large; marginal cost is a\n# few extra evals.\nPROFILES: dict[str, dict] = {\n \"default\": {}, # explicit no-op alias for \"ship-as-is\" (== omitting profile)\n \"fast\": {}, # today's lean defaults, named for symmetry with `thorough`\n \"thorough\": {\n \"confirm_top_k\": 3,\n \"confirm_seeds\": 3,\n # novelty_gate intentionally NOT enabled here: duplicate-detection is the agentic Researcher's\n # job (it reads past experiments + find_analogous_across_runs and DECIDES), not an algorithmic\n # param-distance / embedding auto-reject that mis-fired (it nudged a good fresh idea onto a\n # twice-dead lineage — live node 61). Search stays as tools; the LLM judges novelty.\n \"operator_bandit\": True, # P4: adaptive operator mix over folded yields (GreedyTree)\n \"reward_hack_detect\": True,\n \"code_leakage_detect\": True,\n \"critic_check\": True,\n \"trust_gate\": \"gate\", # detectors stop gating a flagged node from WINNING (not just audit)\n \"ablate_every\": 3,\n \"complexity_cue\": True,\n \"budget_aware\": True,\n \"failure_reflection\": True,\n \"watchdog_reflection\": True, # feed recent live-watchdog (train-monitor/ASHA) flags to proposals\n \"reflection_priors\": True, # no-op unless memory_dir is set (cross-run priors)\n },\n}\n\n\n# The shipped agent-governance matrix (who may change which setting at runtime). Defined as a module\n# constant so the Engine's direct-construction default matches this shipped default exactly (the\n# EngineOptions \"Engine() == shipped defaults\" invariant) instead of diverging to an all-locked map.\nDEFAULT_AGENT_CONTROL: dict[str, list[str]] = {\n \"timeout\": [\"researcher\", \"strategist\"],\n # Layer-2 canonical names are the presented/default governance surface. `_agent_may` falls back\n # to an old snapshot's legacy grant only when its canonical counterpart is absent, so removing\n # duplicate defaults preserves resume authority while an explicit canonical revocation still wins.\n \"eval_parallel\": [\"strategist\"],\n \"llm_parallel\": [\"strategist\"],\n # Strategy-only pseudo-setting: the closed per-lane allocation inside the canonical LLM total.\n # It is not a launch Settings field; operators pin it through `set_strategy` and the Strategist\n # may reallocate it only while this grant is present.\n \"llm_lane_limits\": [\"strategist\"],\n \"max_nodes\": [\"strategist\", \"boss\"],\n \"max_eval_seconds\": [\"strategist\", \"boss\"],\n \"policy\": [\"strategist\"],\n # The policy NAME and its PARAMS are gated independently in `_apply_strategy`; grant both by default\n # so the Strategist that may switch to (say) MCTS may also apply the `c`/`eta` it decided — else its\n # tuned params are silently dropped and the recorded `active_strategy` diverges from the live engine.\n \"policy_params\": [\"strategist\"],\n \"n_seeds\": [\"strategist\"],\n \"ablate_every\": [\"strategist\"],\n \"merge_mode\": [\"strategist\"],\n \"complexity_cue\": [\"strategist\"],\n \"ablate_code_blocks\": [\"strategist\"],\n \"prefer_sweep\": [\"strategist\"],\n \"novelty_stance\": [\"strategist\"],\n \"developer\": [\"strategist\"],\n \"fidelity\": [\"strategist\", \"researcher\"],\n}\n\n\ndef default_agent_control() -> dict[str, list[str]]:\n \"\"\"A FRESH deep-ish copy of the shipped governance matrix (each role list copied so a caller can\n mutate its own map without touching the module constant). The SINGLE constructor of the default,\n called by both `Settings.agent_control`'s default_factory AND the Engine's direct-construction\n default — so \"Engine() == shipped defaults\" can't drift if the copy depth ever needs to change\n (e.g. nested values requiring copy.deepcopy), instead of two verbatim copy expressions.\"\"\"\n return {k: list(v) for k, v in DEFAULT_AGENT_CONTROL.items()}\n\n\nclass Settings(BaseSettings):\n \"\"\"The engine settings schema (every knob a run accepts).\n\n Timeout family — six distinct knobs, each owned by a different subsystem:\n - `timeout`: per-eval wall-clock budget for ONE experiment's evaluation (engine/eval).\n - `max_eval_timeout`: hard ceiling for a governed per-node Researcher timeout override.\n - `llm_timeout`: LLM request idle timeout — inter-token stall limit in stream mode\n (core.llm OpenAICompatibleClient).\n - `llm_header_timeout`: LLM first-byte (response-headers) window for stream attempts (core.llm).\n - `agent_time_budget_s`: wall-clock ceiling across a tool-using agent loop's turns (agents).\n - `dep_install_timeout`: per-package budget for auto-installing a missing dep (env self-prep).\n\n Config-source precedence is owned by `looplab.core.appconfig` (the loader) — see its module\n docstring for the one canonical order.\n \"\"\"\n # Config precedence: see appconfig.py — the loader owns the full order. The `.env` is read\n # so `looplab run`/`looplab ui` pick up LOOPLAB_LLM_BASE_URL etc. without exporting them by hand\n # (keys MUST carry the LOOPLAB_ prefix, same as the env vars). utf-8-sig tolerates a BOM from\n # Windows editors. The test suite disables this (tests/conftest.py) so a dev's real .env in the\n # repo root can't leak into assertions.\n model_config = SettingsConfigDict(\n env_prefix=\"LOOPLAB_\",\n env_file=\".env\",\n env_file_encoding=\"utf-8-sig\",\n extra=\"ignore\",\n allow_inf_nan=False,\n )\n\n @classmethod\n def settings_customise_sources(\n cls, settings_cls, init_settings, env_settings, dotenv_settings,\n file_secret_settings):\n \"\"\"Preserve source precedence across canonical/legacy parallelism names.\n\n Pydantic ranks init > env > dotenv correctly, but it ranks field names rather than semantic\n alias families. Promoting a legacy value inside each source before the merge makes an old\n explicit snapshot/file value beat a lower-priority canonical env value. ``appconfig`` also\n promotes its file/typed/--set sub-layers before combining them into the init source.\n \"\"\"\n def _with_parallel_aliases(source):\n return lambda: canonicalize_parallelism_source(source())\n\n return (\n _with_parallel_aliases(init_settings),\n _with_parallel_aliases(env_settings),\n _with_parallel_aliases(dotenv_settings),\n _with_parallel_aliases(file_secret_settings),\n )\n\n # Run profile (config-first preset, see PROFILES above). \"default\"/\"fast\" = today's lean\n # defaults; \"thorough\" turns the built quality/trust machinery ON in one word. A profile only\n # fills fields the user did NOT set — every knob it touches stays individually overridable.\n profile: str = \"default\"\n\n # Bounds (review C/⚪): a non-positive node/seed budget or timeout is never valid. Upper bounds are\n # equally load-bearing — the UI /start path writes these straight into the engine's env, and an\n # unbounded `eval_parallel` (or legacy `max_parallel`; e.g. 100000 from a crafted preflight) would\n # fan out that many sandboxes → resource exhaustion. Reject at config time, not mid-run. Ceilings\n # are generous; tests top out near 50.\n # CODEX AGENT: Pydantic coerces JSON booleans into these numeric fields (`true` -> 1/1.0), so a UI\n # type error can silently collapse run/eval budgets instead of returning 422. Apply shared\n # before-validation that rejects bool at every numeric settings boundary.\n n_seeds: int = Field(default=3, ge=1, le=1024)\n max_nodes: int = Field(default=8, ge=1, le=1_000_000)\n # legacy evaluation-width input. `eval_parallel` below is canonical and wins when set;\n # keep this field for old env/config/snapshots and direct Engine call sites. A width > 1 admits\n # concurrent evals, whose resource reservations may be CPU-only or span one/more GPUs — there is no\n # one-node/one-GPU promise. `max_parallel=0` is launch-time AUTO (detected GPU count, at least 1).\n max_parallel: int = Field(default=1, ge=0, le=1024)\n # legacy build-width input. `llm_parallel` below is canonical and also controls the\n # shared provider-call total when positive. A width > 1 uses fresh pooled researcher/developer pairs;\n # it says nothing about the later eval's CPU/GPU reservation. Startup `0` derives build fan-out from\n # resolved eval width; a live Strategist/operator zero settles to 1. Without role_factory it clamps to 1.\n parallel_build: int = Field(default=1, ge=0, le=64)\n # Hypothesis-card Kanban re-architecture (docs/23, Layer 2). Two INDEPENDENT concurrency axes,\n # decoupled from each other so cost (LLM work) can be tuned separately from throughput (GPU evals).\n # These are the CANONICAL names going forward; `max_parallel`/`parallel_build` above are kept as thin\n # back-compat aliases (env vars / snapshots / ~100 Engine(...) call sites depend on the exact names,\n # so they cannot be renamed — CLAUDE.md). Resolution (orchestrator.__init__): a NEW name, when set,\n # WINS over its legacy alias; None => fall back to the legacy field => byte-identical to today.\n # `eval_parallel`: concurrent EVALS (the GPU/experiment consumer). None -> max_parallel; 0 = AUTO\n # (one experiment per detected GPU). This is the in-Run eval-concurrency ceiling; the resource\n # scheduler bin-packs declared footprints separately. Sibling local GPU-owning Runs serialize\n # through the conservative host-pool lease; external execution needs its own admission boundary.\n # `llm_parallel`: total concurrent LLM provider calls + BUILD fan-out. None -> legacy\n # parallel_build semantics without a shared total; 0 = startup AUTO (= resolved eval_parallel\n # for build fan-out, historical research overlap unbounded). A positive canonical value enables\n # the named-lane broker the Strategist can reallocate at cadence.\n eval_parallel: int | None = Field(default=None, ge=0, le=1024)\n llm_parallel: int | None = Field(default=None, ge=0, le=64)\n # Training-log monitor (I-series watchdog family): a per-eval background observer that tails the live\n # training log while a (often multi-hour) declared stage runs in a worker thread. ON in the product\n # surface (Settings). Its alert event is fold-ignored and cannot directly change lifecycle/champion;\n # with watchdog_reflection on, that alert is nevertheless advice in a later Researcher\n # prompt and can change future proposals. It only fires on the command-eval path with an LLM client;\n # bare-library EngineOptions stays OFF. See test_options_divergence.\n # `_interval_s` is the BASE tick cadence (the effective one adapts to the per-experiment budget).\n train_monitor: bool = True\n train_monitor_interval_s: float = Field(default=600.0, gt=0)\n # Phase 3 — INTERVENTION (opt-in, separate from observation): let the monitor tree-kill a training the\n # LLM judges 'broken' (diverged / silent CPU fallback / not learning) EARLY, instead of burning the\n # whole budget. Off by default — the observer only WATCHES unless this is on. A kill only fires on a\n # 'broken' verdict (a plateau is 'watch', never killed) with confidence >= the threshold; the node then\n # fails normally (reason='monitor_broken'), so replay reconstructs it from that one terminal event.\n train_monitor_kill: bool = False\n train_monitor_kill_confidence: float = Field(default=0.8, ge=0.0, le=1.0)\n # ASHA live-curve watchdog (sibling of the training monitor): reads the latest INTERMEDIATE value of\n # the objective metric off the live log (reusing the eval's OWN metric reader). Finished-endpoint rank\n # remains advisory. An opt-in KILL additionally requires an operator-declared metric.resource_key and\n # enough sibling observations at exactly that resource; absent comparable evidence cannot stop a run.\n # Records a fold-ignored `asha_rank` diagnostic + trace span; the library default is OFF (off == today).\n asha_live: bool = True\n # Opt-in INTERVENTION (separate from the advisory signal): let the watchdog tree-kill a node whose\n # intermediate metric stays below the SAME-RESOURCE bar past a short grace window. Off by default —\n # it only surfaces endpoint rank unless this is on. A kill fails normally (asha_underperforming).\n asha_live_kill: bool = False\n # The bar sits at `asha_live_quantile` along a WORST->BEST ordering of the finished siblings' finals:\n # 0.5 = the median (a node worse than the median peer flags); a SMALLER value is more conservative —\n # it lowers the bar toward the WORST finished peer, so only a node worse than nearly all peers flags\n # (0.0 = worse than the worst). `min_siblings` is the floor of finished peers required before it ranks\n # at all, so it never acts on too little evidence.\n asha_live_quantile: float = Field(default=0.5, ge=0.0, le=1.0)\n asha_live_min_siblings: int = Field(default=3, ge=1)\n timeout: float = Field(default=30.0, gt=0)\n # Hard ceiling for the Researcher's governed per-node `Idea.eval_timeout`. One hour preserves the\n # existing heavy/neural-net authoring example while staying well below the sandbox's defensive\n # 24-hour subprocess ceiling. This is operator-owned run config: agents may request less or more,\n # but the accepted action is always clamped here after the `agent_control.timeout` permission gate.\n max_eval_timeout: float = Field(default=3600.0, gt=0, le=24 * 3600.0)\n # Intra-node sweep: a sweep node runs a whole grid in one process, so it gets this multiple of\n # `timeout` as its wall-clock budget (solution.py path; RepoTasks use their per-profile timeout).\n sweep_timeout_mult: float = Field(default=8.0, ge=1)\n # Eval STALL watchdog CAP (seconds): a stage that is completely SILENT on stdout/stderr for this\n # long — while still alive and BELOW its wall-clock deadline — is tree-killed early with a STALLED\n # marker (a hung dataloader/deadlock dies in minutes instead of burning a multi-hour timeout). The\n # per-stage window is `min(this, the stage's own timeout)`, so a short scorer's deadline fires first.\n # `0` DISABLES the watchdog entirely (only the hard deadline applies) — for a legitimately quiet\n # non-Python stage (block-buffered stdout, a script that logs only to its own file). Threaded into\n # the eval and surfaced to the Developer so its code can emit periodic progress to stay alive.\n eval_stall_timeout_s: float = Field(default=1800.0, ge=0)\n # Sandbox tier (ADR-13): \"trusted_local\" (subprocess, no Docker) for the CLI;\n # \"untrusted\" (Docker --network none, shared-kernel runtime) for hosted/multi-tenant UI;\n # \"hostile\" (untrusted + a true-isolation OCI runtime, gVisor `runsc` by default / Kata) for\n # running untrusted code. See runtime/sandbox.py::make_sandbox for the tier selection.\n trust_mode: str = \"trusted_local\"\n # Image for the untrusted command-eval tier (RepoTask, Phase 4): the framework's deps\n # should be baked in (the container runs --network none, so a pip setup can't fetch).\n docker_image: str = \"python:3.12-slim\"\n # Resource caps for the untrusted/hostile Docker tier (passed to `docker run --memory/--cpus`).\n # The default 4g bound protects other tenants from an OOM, but a model-training MLE-bench eval\n # routinely needs more — raise it (e.g. \"16g\") for such runs. \"\" disables the cap (unbounded).\n # Ignored by the trusted_local (subprocess) tier.\n sandbox_memory: str = \"4g\"\n sandbox_cpus: str = \"\"\n # Best-effort host-OOM guard for the TRUSTED-LOCAL (subprocess) tier. When set (e.g. \"8g\"), each\n # eval child gets an RLIMIT_AS address-space cap so a runaway trainer hits MemoryError instead of\n # OOM-killing the whole host (and the engine). POSIX only; a no-op on Windows. Default \"\" = OFF:\n # RLIMIT_AS bounds VIRTUAL memory, and CUDA/torch reserve tens of GB of virtual without using it,\n # so a default cap would spuriously kill GPU evals — leave it off for those and use the Docker\n # tier's real --memory cgroup bound (sandbox_memory) instead. Ideal for CPU/numpy/sklearn runs.\n sandbox_memory_local: str = \"\"\n # Best-effort disk-fill guard for the TRUSTED-LOCAL tier (P1-5): an RLIMIT_FSIZE cap on the size of\n # any single file an eval child writes (e.g. \"2g\"), so a runaway that writes an unbounded file gets\n # SIGXFSZ instead of filling the host disk. POSIX only. Default \"\" = OFF (large model checkpoints\n # need big files); set it for tasks that write only small artifacts.\n sandbox_fsize_local: str = \"\"\n # Search policy (ADR-2): \"greedy\" | \"evolutionary\" | \"mcts\" | \"asha\".\n policy: str = \"greedy\"\n # Ablation-driven refinement (I7): every N improves, ablate the best to find the\n # highest-impact parameter and refine it. 0 = off. (GreedyTree only.)\n ablate_every: int = 0\n # A0a (MLE-STAR): when ablating, treat each generated pipeline *code block* (not just a\n # numeric param) as an ablation unit — neutralize a block, measure the metric delta, refine\n # the highest-impact block. Off => the classic numeric-param ablation. Config-first knob.\n ablate_code_blocks: bool = False\n # A0b: real merge/ensembling. \"mean\" = legacy mean-param merge; \"ensemble\" = the Developer\n # writes a code-recombination ensemble over the two parents' solutions (verified: agent-proposed\n # ensembling 37.9%->43.9%). \"auto\" (default) = ensemble whenever the Developer actually\n # generates code (LLM/agent backends; declared via `is_code_generating`), mean for the\n # templated/toy developers where a code ensemble is meaningless — the evidence says code\n # recombination is the strongest single merge (removing it costs ~9 pp), so it is the default\n # wherever it can work. Falls back to mean when the Developer can't ensemble.\n merge_mode: str = \"auto\"\n # A0d (AIRA): inject a dynamic complexity hint into the draft/improve prompt keyed on the\n # node's child count (few children -> keep minimal; many -> escalate to ensembling/HPO).\n complexity_cue: bool = False\n # I1 (CAAFE-style) LLM feature-engineering: instruct the proposer/developer to add\n # semantically-meaningful engineered features (ratios, interactions, aggregations) as code. The\n # existing cross-validation eval + best-selection is the CV GATE — a feature set that doesn't\n # improve CV is never selected (feature engineering is non-universal, so this gate is mandatory).\n # Tabular tasks (those exposing column data). Off by default.\n feature_engineering: bool = False\n # A5: surface the REMAINING eval-compute budget into the proposal prompt so the Researcher can\n # reason over how much compute is left (explore broad while flush, exploit/cheapen when low).\n # No-op unless a max_eval_seconds budget is set.\n budget_aware: bool = False\n # A4 (LATS-style): feed a summary of the most recent FAILED branches (operator + error reason)\n # back into the proposal prompt so the proposer reflects on and avoids repeating them. ON by\n # default: it is SELECTIVE by construction (injects only when recent failures exist — the\n # evidenced form of reflection; reflecting at every step shows no gains, arXiv:2506.12928).\n failure_reflection: bool = True\n # Feed the LIVE-WATCHDOG observations (train-monitor health verdicts + ASHA intermediate-rank flags)\n # of recent experiments back into the proposal prompt, so the Researcher avoids re-proposing a config\n # whose TRAINING was already observed to be weak — even when the watchdog kills are off (default) and\n # the node ran to completion (those diagnostics are fold-ignored, so failure_reflection never sees\n # them). ON by default and SELECTIVE like failure_reflection (injects only when a recent flag exists).\n watchdog_reflection: bool = True\n # C3 deep test-driven repair: hand the Developer the failure taxonomy + a structured \"reproduce\n # then fix\" directive on debug, not just the raw stderr tail. ON by default (product surface); the\n # conservative library default in EngineOptions stays off, per that module's contract.\n deep_repair: bool = True\n # === Inline crash repair ==============================================================\n # Hybrid in-node crash repair: when an LLM-generated node CRASHES at runtime (mechanical errors\n # — bad import, removed kwarg, typo), the agent triages it and may repair the code IN PLACE within\n # the same eval (cheap; does NOT consume max_nodes and does NOT add a node to the search tree).\n # Semantic failures still flow to a new idea/debug node via the policy. ON by default; set False\n # to restore the prior behavior (every crash waits for a budgeted inter-node debug node).\n inline_repair: bool = True\n # Max in-place repair attempts per node before it fails normally. Default 0 = UNLIMITED: the same\n # agent keeps fixing + re-evaluating IN THE SAME node until it produces a clean metric — no new\n # node just to retry. Runaway is prevented not by a count but by the anti-stuck guard\n # (`inline_repair_stuck_repeat`): when the SAME error signature repeats with no progress, or the\n # agent's crash-triage says \"abandon\", the node fails. Set a positive N to cap the retries instead.\n inline_repair_attempts: int = Field(default=0, ge=0)\n # Anti-stuck: abandon in-node repair when the SAME error recurs this many times in a row (no\n # progress). Keeps \"unlimited\" repair from looping forever on an unfixable error.\n inline_repair_stuck_repeat: int = Field(default=4, ge=2)\n # Which failure reasons (_failure_reason: crash|timeout|oom|setup|no_metric|drift) are eligible for\n # inline repair. Default: mechanical crashes, timeouts AND OOM-kills — a timeout/OOM means the code\n # was too slow / too memory-hungry for the budget (or the pod's cgroup limit), not that the idea is\n # wrong, so the agent repairs it by reducing compute/memory (fewer estimators/epochs/folds/seeds, a\n # lighter model, a smaller batch, subsampling) instead of letting the node die unrecovered. Env\n # override expects a JSON array.\n inline_repair_reasons: tuple[str, ...] = (\"crash\", \"timeout\", \"oom\")\n # Cost ceiling for in-node repair of a MULTI-STAGE eval: the max number of FULL pipeline re-runs\n # (i.e. re-trains from the first stage) the inline-repair loop may do before abandoning to the\n # inter-node debug operator. A late-stage failure (a broken `score`/eval script that didn't touch\n # `train`) REUSES the completed train checkpoint and re-runs only from the failed stage — that is\n # CHEAP and NOT counted here; only a repair that changes an EARLIER stage's code (train.py/loss.py/…)\n # forces a full re-train, which each costs the whole training time. Without this bound, a repair that\n # keeps changing training code could burn many full trains (the anti-stuck guard is error-signature\n # based, not cost based). 0 = unlimited (legacy behavior). Single-command evals ignore it.\n inline_repair_retrain_cap: int = Field(default=2, ge=0)\n # Environment self-prep: when a solution crashes purely because a KNOWN library isn't installed\n # (ModuleNotFoundError), the engine pip-installs it into the eval interpreter and re-runs — so a\n # torch/XGBoost/CatBoost idea (e.g. a GRU model) actually runs on a fresh box instead of being\n # thrown away as `idea_rejected`. Trusted_local tier only (the untrusted/hostile Docker tiers run\n # --network none). A typo'd or local helper module is NOT installed (that's a code bug to repair).\n auto_install_deps: bool = True\n # Per-package wall-clock budget for an auto-install (seconds). Generous: large wheels like torch\n # can take minutes to download/build.\n dep_install_timeout: float = Field(default=900.0, gt=0)\n # --- Agent governance: who may change a setting at runtime (per-setting allow-list of roles) ---\n # Roles: \"strategist\" (A7 meta-controller; run-wide), \"boss\" (run-chat operator-proxy; run-wide),\n # \"researcher\" (per-experiment proposer; PER-NODE — e.g. set a longer eval timeout for a neural-net\n # idea). A setting ABSENT from this map is LOCKED — no agent may change it, only a human via the\n # snapshot/UI. The sole conditional exception is Strategy-only `card_scoring`: Card mode grants\n # it implicitly without changing the default flag-off snapshot, while an explicit [] revokes it.\n # The gate is ENFORCED at every AGENT seam via `_agent_may` — the strategist's whole\n # control surface (policy/operators/novelty_stance/prefer_sweep/developer/fidelity/timeout/\n # eval_parallel/llm_parallel) is checked in `_apply_strategy`, so removing a role from a knob locks it,\n # not just greys out the UI pill. The boss/max_* grants document boss-relevant knobs, but a\n # `budget_extend` is a HUMAN control intent (the boss action-builder can only emit `add_nodes`), so\n # it is applied as-is — the human always wins via the UI/snapshot. Defaults are conservative: resource/\n # search-shape knobs the agents already reason about; provider infra (llm_model/base_url/api_key,\n # docker_image) stays locked. The UI shows S/B/R pills per setting. `timeout` granted to the\n # researcher IS the \"auto\" per-node mode — the researcher sizes each experiment's wall-clock\n # (Idea.eval_timeout); the config `timeout` is the fallback for nodes it doesn't size. Env override\n # expects a JSON object. (`fidelity`/`novelty_stance`/`prefer_sweep`/`developer` are governance keys\n # for the strategist's per-node dials — not 1:1 Settings fields, but gated the same way.) The default\n # lives in the module-level DEFAULT_AGENT_CONTROL so the Engine's direct-construction default matches.\n agent_control: dict[str, list[str]] = Field(default_factory=default_agent_control)\n # C1 (Agentless localization): for repo tasks, rank the source files most relevant to the most\n # recent failure (traceback + identifiers) and surface them in the proposal/repair prompt so the\n # Developer edits the right place. Off by default; repo tasks only.\n localize_faults: bool = False\n # A2 surrogate-guided proposal (BO-lite): fit a k-NN surrogate over (params->metric) and propose\n # by acquisition instead of random/hill-climb. Numeric-bounds tasks only; bootstraps via the\n # base Researcher. Off by default. `surrogate_explore` = UCB-style exploration weight.\n surrogate_proposer: bool = False\n surrogate_explore: float = 0.1\n # E2 researcher panel: generate K candidate ideas per proposal and keep the one ranked best by a\n # cheap empirical surrogate over the (params->metric) history (NOT an LLM-judge). 1 = off.\n researcher_panel: int = 1\n # A1 ASHA / successive-halving: reduction factor (keep top 1/eta per rung) and rung budget.\n asha_eta: int = Field(default=3, ge=2)\n # Rung-0 width for ASHA/BOHB (the wide base of cheap drafts). 0 = use n_seeds (default,\n # preserving prior behavior); set >0 to seed a wider/narrower base independent of n_seeds.\n asha_rung_nodes: int = Field(default=0, ge=0)\n # A6 proxy/predictive scoring: cheaply rank a candidate's potential from early-stage signals\n # and skip a full eval for the doomed bottom fraction. 0.0 = off (no candidate skipped).\n proxy_scoring: bool = False\n proxy_kill_fraction: float = Field(default=0.0, ge=0.0, le=0.9)\n # === Novelty / dedup ==================================================================\n # E1 novelty/dedup gate: reject near-duplicate proposals (normalized param-space distance below\n # `novelty_epsilon`) by deterministically nudging them off the duplicate — stops the search\n # wasting evals re-trying the same idea. Audit event `novelty_rejected`. Off by default.\n # Novelty/dedup MODE selector: how a fresh proposal is checked against already-tried experiments.\n # \"off\" — no explicit gate; the agentic Researcher's own read-the-history judgment is trusted.\n # \"algo\" — the deterministic gate: numeric param-distance (`novelty_epsilon`) + optional embedding\n # similarity (`novelty_semantic`). Cheap, no LLM call, but can't explain itself.\n # \"llm\" — an LLM adjudicates (reads the real experiments via tools) whether the idea is a\n # near-duplicate and, if so, asks the Researcher once more for something different. One\n # extra LLM call per proposal — highest quality, follows the \"let the LLM decide\" line.\n novelty_mode: str = \"llm\"\n # Legacy sub-toggles, honored by the \"algo\" mode (and back-compat: novelty_gate=True forces \"algo\"):\n novelty_gate: bool = False\n novelty_epsilon: float = 0.05\n # T5 semantic novelty (active whenever the deterministic gate runs — novelty_mode=algo,\n # novelty_gate=true which is the legacy alias for algo, OR the Strategist novelty stance is\n # \"explore\"; a no-op only under novelty_mode=llm/off with a non-explore stance): reject a proposal\n # whose idea TEXT embeds within\n # `novelty_semantic_threshold` cosine of an existing node's. OFF BY DEFAULT: novelty is the agentic\n # Researcher's call, made by READING the actual prior experiments (read_experiment /\n # find_analogous_across_runs / list_experiments), NOT an embedding-similarity auto-reject that can't\n # explain itself and mis-fires on paraphrases. Semantic/param search stays available as TOOLS in the\n # Researcher's context; the LLM decides whether an idea is a true duplicate.\n novelty_semantic: bool = False\n novelty_semantic_threshold: float = Field(default=0.92, ge=0.5, le=1.0)\n # T10: debug-lineage depth bound for every policy — how many error-feedback repairs a failing\n # lineage gets before it is abandoned. The old default (1) abandoned lineages after ONE repair;\n # multi-turn/deeper debugging is a verified lever (AIRA2 ReAct debug +5.5 percentile points).\n debug_depth: int = Field(default=2, ge=1)\n # P4 operator bandit (GreedyTree): replace the fixed merge/ablate cadences with a\n # deterministic UCB over per-operator yield (Δmetric per eval-second, folded from the run).\n # Off by default (the cadences are well-tested and the bandit has no direct published\n # ablation); `thorough` turns it on.\n operator_bandit: bool = False\n # P1 hypothesis ledger: ask the Researcher to state the one-line hypothesis each experiment tests,\n # register deep-research directions as hypotheses, and track them to a verdict on the board. ON by\n # default. The board is shown to the Researcher and can be foresight-ordered, so it can\n # steer later proposals even though it never directly re-ranks already-evaluated nodes. Set False\n # to drop the prompt nudge + registration.\n track_hypotheses: bool = True\n # E4/M2/M3 reflection-memory -> priors (gradient-free cross-run meta-learning): at run end distill\n # the winner into `<memory_dir>/meta_notes.jsonl` AND structured lessons (incl. NEGATIVE results:\n # tested/abandoned hypotheses + failure themes) into `lessons.jsonl` with a task fingerprint; at run\n # start, inject exact-task notes + fingerprint-matched lessons from SIMILAR tasks into the proposal\n # prompt. ON by default — but a NO-OP until `memory_dir` is set (that's where cross-run memory lives).\n reflection_priors: bool = True\n # M6 comparative lessons (MARS \"comparative reflective memory\", doc 13 §7 item 2): distill\n # credit-assigned lessons from PAIRS of solutions — which SPECIFIC change made a child beat\n # (or regress from) its parent, and what fixed a failure — on top of the one-shot run-end\n # reflection. At most one extra LLM call per distillation (all pairs batched); offline a\n # deterministic param-diff credit stands in. Gated on reflection_priors + memory_dir like the\n # rest of cross-run memory — and since memory_dir has a default (~/.looplab/memory), this is\n # ACTIVE out of the box; clear memory_dir to disable cross-run memory wholesale.\n comparative_lessons: bool = True\n # M6 mid-run cadences (doc 13 §7 item 5 — the AgentRxiv live-share pattern): every N created\n # nodes, WRITE comparative lessons to the shared cross-run store during the run (lessons_every\n # — one batched LLM call per firing) and RE-READ the store so lessons distilled by CONCURRENT\n # runs reach this run's proposals (lessons_refresh_every; file re-read, no LLM call, skipped\n # when the store is unchanged). ON by default (every 4 nodes) and, like comparative_lessons\n # above, live out of the box because memory_dir defaults on; 0 = off — run-end write /\n # run-start read only, the pre-M6 behavior.\n lessons_every: int = Field(default=4, ge=0)\n lessons_refresh_every: int = Field(default=4, ge=0)\n # B3 output redaction: mask credentials (known key shapes + high-entropy tokens) in the\n # stdout/stderr tail before it is persisted to the event log / spans / UI — a leaked secret in a\n # print()/traceback must not land in the durable log. Off by default; recommend on for untrusted.\n redact_output: bool = False\n # B5 reward-hacking detector: a host-side monitor that flags suspicious wins (grader/answer-key\n # access, runtime writes to frozen files, suspiciously-perfect metrics) as a `reward_hack_suspected`\n # audit event in the Trust panel. Off by default. Whether a flag CHANGES selection is governed by\n # `trust_gate` below (default: audit-only, never changes selection).\n reward_hack_detect: bool = False\n # Trust enforcement (T2): what a reward-hack / data-leakage flag actually DOES to selection.\n # \"audit\" (default) = surface it in the Trust panel, never change selection (today's behavior);\n # \"gate\" = a flagged node can no longer be selected as BEST **and isn't bred/confirmed from**\n # (breed_excluded — see replay._apply_trust_gate / models.breedable_nodes), but stays\n # FEASIBLE so it still counts for diversity/audit — closes the \"a hacked/leaky node can\n # win OR seed its lineage\" hole;\n # \"block\" = additionally mark it fully INFEASIBLE (removed from feasible_nodes entirely).\n # Deliberately gates only high-precision CHEATING/LEAKAGE signals. Broad `critic:*` findings stay\n # advisory; `critic:hardcoded_metric` is the one narrow high-precision critic exception\n # and gates because it requires a literal metric with no computed assignment anywhere.\n trust_gate: str = \"audit\"\n # I3 data-centric: static code-leakage scan of each evaluated solution (fit-before-split,\n # fit-on-test) surfaced into the Trust panel alongside reward-hack flags. Off by default.\n code_leakage_detect: bool = False\n # 4.4 sandbox instrumentation (needs reward_hack_detect): after each eval, flag a RUNTIME write\n # to a protected/frozen file (an answer key or grader tampered mid-run) — behavioral evidence a\n # static code scan misses. ON by default; only fires when the reward-hack detector is on.\n workdir_audit: bool = True\n # D8 decoupled research Verifier: check every Deep-Research memo's CLAIMS against their cited\n # evidence (node ids / urls) before recording — synthesis is the documented weak link (Kosmos\n # 57.9% accurate). Deterministic layer always; an LLM rubric pass when the DeepResearcher has a\n # client. The verdict cannot change this run's node/champion, but finalize uses aligned supported\n # verdicts as the evidence gate for persisted D8 cross-run claims; unavailable/misaligned stays\n # unverified. ON by default (no-op with no memo/claims).\n research_verify: bool = True\n # C4 independent critic: an execution-free critic of each solution (stub / hardcoded-metric /\n # params-ignored; on host-graded tasks the metric checks become a submission-output check)\n # surfaced in the Trust panel. Broad findings are advisory; `critic:hardcoded_metric` can gate under\n # trust_gate=gate/block. Off by default.\n critic_check: bool = False\n # A7 Strategist (NEW, user-requested): optional LLM/rule meta-controller that picks the search\n # policy/allocator + operator mix + fidelity (+ Developer backend) per situation. Config-first:\n # every choice the Strategist makes is also a direct knob. Defaults to \"agent\" so the agent adapts\n # the search policy/operators/fidelity per situation; set \"off\" for static config-driven search.\n # \"agent\" = a tool-using Strategist that READS the run/data/sibling-runs/KB/memory (B1-guarded)\n # before deciding, instead of the single-shot \"llm\" call over aggregate stats.\n strategist_backend: str = \"agent\" # \"off\" | \"rule\" | \"llm\" | \"agent\" — default AGENTIC: the\n # Strategist READS the run/data/sibling-runs/KB/memory with tools before deciding, not a single-shot\n # call over aggregate stats. \"llm\" = the old non-agentic single-shot; \"rule\"/\"off\" = no LLM.\n strategist_every: int = Field(default=3, ge=1) # consult cadence (created nodes)\n # PART V (F1): concept CLASSIFIER re-tag + consolidation cadence, DECOUPLED from strategist_every. The\n # LLM concept map is heavier and slower-moving than a strategy consult, so it refreshes on its own\n # (sparser) interval. Researcher-authored idea.concepts still fold immediately at node_created — this\n # only paces the classifier-evidence + consolidation refresh (and the concept-coverage pivot snapshot).\n concept_retag_every: int = Field(default=30, ge=1)\n # === Confirmation & holdout ===========================================================\n # Multi-seed confirmation (I12, ADR-15): confirm the top-k under N seeds before\n # finishing. 0 disables (default). Only meaningful when eval has variance.\n confirm_top_k: int = 0\n confirm_seeds: int = 0\n # D1 seed-holdout discipline: the FIRST seed the confirm phase uses. Search evals run with the\n # implicit seed 0 (LOOPLAB_EVAL_SEED unset -> \"0\"), so a base of 1 keeps every confirm split\n # DISJOINT from anything the search optimized against — the confirm metric is then a\n # generalization signal, not a re-measurement (AIRA: selecting on a signal the search saw\n # overfits by 9-13 pp). Set 0 to restore the legacy overlapping seeds 0..N-1.\n confirm_seed_base: int = Field(default=1, ge=0)\n # D1 holdout-gated promotion (B6, Arbor-style): for host-graded tasks, reserve this fraction of\n # the held-out labels as a FINAL holdout partition the search never sees — every search/confirm\n # eval is scored on the remaining rows only, and at finish the val-top-k are re-scored on the\n # holdout partition (free: the predictions already exist). 0.0 = off (legacy full-label scoring).\n holdout_fraction: float = Field(default=0.25, ge=0.0, le=0.9)\n # Champion selection prefers the HOLDOUT metric among the nodes that have one (the val-top-k).\n # Recorded in run_started so replay applies the same rule; old logs fold to False (legacy pick).\n # The per-node val-holdout `generalization_gap` folds into the Trust panel either way.\n holdout_select: bool = True\n # How many val-leaders get a holdout evaluation at finish (host-graded tasks).\n holdout_top_k: int = Field(default=3, ge=1)\n # R1-c / Part IV — calibrated §12-verifier tie-break in best-selection. When a fresh node's robust\n # metric EXACTLY TIES an already-eligible node, the engine runs the §12 verifier (grounded on each\n # node's realized result, `selection_criteria`) and records a soundness score per node. The tie-break\n # then applies across the WHOLE selection path: the mean pick breaks a robust_metric tie by the score,\n # and — when `holdout_select` is on — the holdout-slot ranking prefers the sounder node so it isn't\n # denied a holdout eval, and the final holdout override breaks a holdout_metric tie by it too. Strictly\n # a TIE-BREAK — it can NEVER move a node ahead of a strictly-better metric (§21.7 advisory-never-\n # overrides). Best-effort: verification is LAZY (only fires on an actual tie) and bounded per cadence,\n # so a large/late tie stays on deterministic metric+id order unless one atomic treatment scores every\n # member. Recorded in run_started so\n # replay applies the same rule; old logs fold to False (legacy pick). Opt-in (default off); needs a\n # reachable LLM client. Enable only after validating calibration offline with `verifier.calibrate`.\n # See trust/verifier.py + core/fitness.py.\n select_verifier: bool = False\n # R1-d (§21.19): widen the verifier tie-break from EXACT-metric to a conservative STATISTICAL tie.\n # The band is capped by both the metric leader's SE and the pooled SE-of-difference, so challenger\n # variance cannot widen it and a significant difference is never a tie (§21.7). Off -> exact only.\n verifier_ci_tie: bool = False\n # Verifier sample count for `select_verifier` (the §12 repeated-sampling expectation). 3 tames the\n # single-shot variance; 1 = single-shot (cheaper, noisier).\n select_verifier_samples: int = Field(default=3, ge=1, le=32)\n # Budget (I13): hard wall-clock ceiling; the run aborts cleanly when exceeded.\n max_seconds: float | None = None\n # Eval-compute budget (#2): hard ceiling on cumulative time spent INSIDE evals (training\n # runs), separate from wall-clock. Survives resume (summed from the event log). The real\n # guard against a silent multi-hour sweep when an eval is a minutes-hours training run.\n max_eval_seconds: float | None = None\n # Cross-run memory (I19, ADR-10): if set, the best result of each run is stored as\n # a case here, and the cases become retrievable knowledge for future runs.\n memory_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"memory\"))\n # HITL (I21, ADR-11): pause for human approval of the final best before finishing.\n require_approval: bool = False\n # Diversity archive (I22): niche bucket width in parameter space.\n archive_resolution: float = 1.0\n # Coverage read-model (narrowing signal): at the strategist cadence, compute a breadth summary\n # (themes / param-niches / theme entropy / dominant-theme fraction) from the folded run, record\n # it as a `coverage_snapshot` audit event, and surface it into the Strategist's decision context.\n # Deterministic, cheap, and purely additive context — no search-behavior change on its own. On by\n # default (like the rest of the always-on situational context). See search/coverage.py.\n coverage_context: bool = True\n # PART IV Phase 2a live steering (§21.11/§21.13). When on, the `concept_retag_every` cadence (NOT\n # `strategist_every` — the producer gates on `_should_consult_concepts`, which uses the seed boundary\n # + `concept_retag_every`, default 30) records a\n # concept-graph coverage + uncovered-region snapshot (deterministic heuristic tagger over the\n # task-type skeleton) and, on an `explore` stance, the Researcher's novelty hint names the exact\n # uncovered regions (\"0 coverage in {negatives/external-mining, distillation} — go there\") instead\n # of the vague \"broaden\". ON by default in the product Settings (ce4a379), EngineOptions off: it\n # only enriches an already-mode-gated explore hint, never forces exploration, and no-ops for a task\n # with no curated concept skeleton. The snapshot is auditable, but its prompt cue can change future\n # candidate generation; it does not directly rewrite best-metric selection. See search/concept_graph.py.\n # DESIGN NOTE (2026-07-17 critique): now ON unconditionally, but a toy / non-ML task yields EMPTY concept\n # tags while still paying the per-node LLM concept cost (~10 calls/node with the full Part IV/V bundle).\n # Consider AUTO-QUIESCING: if the heuristic tagger returns empty for the first N nodes (no concept signal),\n # skip the LLM concept work for the run — keeps it on for ML tasks, free on toy/degenerate ones.\n concept_pivot: bool = True\n # PART IV Phase 2b — D3 graded novelty into the LIVE novelty gate (§21.4/§21.13). When on and the\n # task has a curated concept skeleton, the novelty gate first grades a proposal over the concept\n # graph: a level-4 \"same direction, DIFFERENT implementation\" or a level-5 \"re-opens a\n # wrongly-abandoned FAILED direction\" proposal is ALLOWED through unchanged — the flat LLM/semantic\n # dedup gate can't tell \"this DCL tweak\" from \"the whole DCL branch\" and would wrongly reject a\n # legitimate variant or never re-open a sound-but-killed direction (the node_63 archetype). Levels\n # 1/2/3 (identical / near-dup / prior-run) still fall through to the existing gate. Deterministic\n # (heuristic tagger, no LLM), audit event `novelty_graded`, replay-safe. ON by default in the product\n # Settings (ce4a379), EngineOptions off; no-ops for a task with no skeleton. See search/graded_novelty.py.\n graded_novelty: bool = True\n # PART IV Phase 2b — D7 capability-expansion forced-jump DIRECTIVE (§21.8/§21.13, issue #7). When on\n # and the concept-graph cadence detects action-space LOCK-IN (the search has stayed inside one D5\n # branch for a long consecutive streak) on an `explore` stance, the Researcher's novelty hint\n # ESCALATES from \"broaden\" to \"expand the ACTION SPACE — build the missing infra (external mining,\n # a new data pipeline, a different eval), do NOT swap another {locked} lever.\" The same\n # gate also stamps the proposal's authoritative operator as `expand`; that node competes normally\n # under SearchFitness and its separate operator yield is measured. It does not force a winner.\n # Reads the 2a concept-coverage snapshot, so it needs `concept_pivot` to record one. OPT-IN\n # (default off). See engine/proposal_cues.py + search/lock_in.py.\n capability_expansion: bool = False\n # PART IV cross-run Step 0 (§21.20.12/§21.20.13). Universal task-fingerprint tokenization: drop the\n # ASCII-only `[a-z0-9]` allowlist on goal keywords so a non-Latin goal (Russian, CJK, …) is NOT\n # silently dropped from its cross-run fingerprint (today such a goal keys only on kind/dir/metric and\n # never reaches SIMILAR-task priors/lessons/cases). Uses `[^\\W_]+`/`.casefold()` — same splitting as\n # before, any script. ON by default in the product Settings (ce4a379); the bare-library EngineOptions\n # default stays off, so a run pinned to it won't silently re-key a portfolio mid-flight. See engine/memory.py.\n fingerprint_universal: bool = True\n # PART IV cross-run Step 2 (§21.20). Fill graded-novelty's `prior_concepts` from the cross-run\n # ConceptCapsuleStore: at run end write a per-run concept capsule (the shipped `node_concepts` tags +\n # best outcome, keyed by task_fingerprint) to `memory_dir`; when a later SIMILAR run proposes an idea\n # whose concept was tried before, SURFACE the prior outcome as a `cross_run_prior` audit event (never\n # reject — D3 level 3). Audit-only, off the selection path; ON by default in the product Settings\n # (ce4a379), EngineOptions off. Needs a `memory_dir` to share capsules.\n # See engine/memory.py (ConceptCapsuleStore) + engine/novelty.py.\n # PREREQUISITES (this flag is NOT standalone): the WRITE needs per-run concept tags\n # (`node_concepts`, produced by `concept_pivot`/F1 tagging) — no tags => no capsule; the SURFACE path\n # runs inside the graded-novelty precheck, so it also needs `graded_novelty` on. With only this flag +\n # `memory_dir` it persists/surfaces nothing. Enable `concept_pivot` + `graded_novelty` alongside it.\n # A non-Latin (Russian/CJK) portfolio should ALSO set `fingerprint_universal`, else the capsule's goal\n # tokens are dropped and its fingerprint over-matches on kind/dir/metric alone (the novelty direction\n # gate narrows this, but goal-word similarity is still lost).\n cross_run_concepts: bool = True\n # PART V (B): run-base + node-DELTA concept authoring. When on, once the first evaluated node has\n # authored concepts the engine seeds RunState.run_base_concepts from them (a one-shot EV_RUN_CONCEPTS,\n # idempotent because it only fires while the base is empty), and the Researcher's proposal hint tells it\n # to author only `concepts_added`/`concepts_removed` vs the run base + its parent — minimal per-node\n # annotations that inherit down the DAG (a swap propagates). Off -> every node authors its full concept\n # set as before. Product-on; the bare EngineOptions mirror defaults off (test_options_divergence).\n concept_run_base: bool = True\n # PART IV cross-run Step 5 advisory (§21.20.5). Fold the bounded cross-run CONTEXT PACK — evidence-\n # grounded claims with BOTH support and counter-evidence (Step 4) + a portfolio-coverage line (Step 3) —\n # into the Researcher's proposal prompt, exactly like the E4 cross-run prior note. Advisory ONLY: it is\n # prompt-grounding: it can change the Researcher's later candidates, but does not directly rewrite\n # best-metric selection (§21.7). Reads `memory_dir` (lessons.jsonl + concept_capsules.jsonl); \"\" when\n # empty. ON by explicit experimental product choice; no frozen A/B validation is claimed. The\n # bare-library EngineOptions default stays off (engine/options.py, frozen in\n # test_options_divergence). See engine/proposal_cues.py.\n cross_run_advisory: bool = True\n # PART IV cross-run §21.20.13 (full CR of the lean fuzzy claim merge). Switch the claim read-model to the\n # SCOPE+POLARITY-safe STRUCTURED claim key (engine/claim_key.py): claims from different tasks never\n # merge, opposite polarity (\"X helps\" vs \"X never helps\") is surfaced as a CONTRADICTION instead of being\n # collapsed, paraphrase/inflection variants group by exact structured key (no transitive over-merge), and\n # operator governance is scope-precise (a decision in task A cannot reach a same-worded claim in task B).\n # Affects the `cross_run_advisory` context pack only; ON by default in the product Settings (ce4a379);\n # the bare-library EngineOptions default stays off (engine/options.py). See engine/claims.py.\n cross_run_structured_claims: bool = True\n # PART IV cross-run §22.4 (AGENTIC taxonomy steward). At finalize, when an LLM client is available, let\n # the concept steward (engine/concept_steward.py) review the freshly-updated portfolio concept graph and\n # PROPOSE a curation — merge duplicate slugs / split conflated ones / purge noise. Proposals are LOGGED to\n # `concept_curation_log.jsonl` for operator ratification (via the serve/CLI governance surface); the LLM\n # never mutates fold state. The calls run synchronously during finalize, so they add latency and may add\n # paid inference; a caught steward failure does not prevent terminal completion or trigger a retry.\n # ON by explicit experimental product choice when `memory_dir` + an LLM backend are present; the\n # bare-library EngineOptions default stays off (engine/options.py). See engine/lessons.py.\n cross_run_curation: bool = True\n # Deprecated compatibility flag. Steward output is untrusted and finalize is proposal-only regardless\n # of this value; retained so old snapshots still validate and logs can disclose that auto was requested.\n cross_run_curation_auto: bool = False\n # Role backend (ADR-7/14): \"toy\" (offline optimizer) | \"llm\" (live model).\n # CODEX AGENT: non-typed sources (`--set`, file, env) accept any string; a typo then falls through to\n # toy roles instead of failing an intended LLM run. Validate this as Literal[\"toy\", \"llm\"] across\n # all construction paths.\n backend: str = \"toy\"\n # Developer backend (ADR-7): \"default\" (templated/LLM from the task) or an external\n # CLI coding agent: \"opencode\" | \"aider\" | \"goose\" | \"continue\".\n developer_backend: str = \"default\"\n # C2 best-of-N: generate N candidate implementations per node and keep the best by an\n # execution-free reward (static validity + metric-print). 1 = off. In-house LLM developer only\n # (not external agents — ADR-7 cost rule). The top SWE-bench reliability lever for weak models.\n best_of_n: int = 1\n # D10 list-wise best-of-N selection: when the top static-scorers TIE, break the tie with a\n # comparative LLM selection over the candidates presented together (+~3 pts vs pointwise on\n # GAIA, arXiv:2506.12928). The static score stays the primary filter — the LLM is a weak\n # comparative prior, never the sole oracle. ON by default (no-op when best_of_n==1).\n best_of_n_listwise: bool = True\n # === Foresight / predict-before-execute ===============================================\n # FOREAGENT predict-before-execute (arXiv:2601.05930): use the LLM as an IMPLICIT WORLD MODEL to\n # predict — WITHOUT executing — which candidate / hypothesis will score best, primed with a\n # Verified Data Analysis Report (data profile / brief) + the accumulated experiment memory. Master\n # switch, ON by default; a no-op wherever there is nothing to rank (best_of_n==1 and\n # foresight_panel==1) or no LLM client. See `looplab/search/foresight.py`.\n foresight: bool = True\n # Hypothesis foresight breadth: generate K candidate ideas per proposal and keep the one PREDICTED\n # best pre-execution by the world model — it ranks the STRUCTURAL / text ideas the numeric k-NN\n # surrogate (researcher_panel) is blind to, primed with the data profile + memory (the synergy).\n # >1 enables it (LLM backend only; needs `foresight` on); 2 = on by default at modest cost, 1 = off.\n foresight_panel: int = 2\n # AGENTIC foresight: run the ranking (hypothesis-board prioritization + K-idea pick) as a TOOL-USING\n # loop that can pull actual experiment results / data facts before deciding, instead of a one-shot\n # prediction from a pre-baked report. ON by default (needs foresight + a client; falls back to the\n # one-shot predictor on any hiccup). A few extra LLM calls per proposal; set False for the cheaper\n # single-call ranker.\n foresight_agentic: bool = True\n # §1 foresight confidence gate: the minimum predicted confidence at which a predict-before-execute\n # pick is ACTED ON. Below it the ranker abstains — the K-idea panel falls back to the first\n # proposal, best-of-N falls through to the D10 tie-break — instead of committing a low-confidence\n # choice. 0.0 (default) = OFF (act on every pick, the historical behavior); raise toward ~0.5 to\n # make the world model defer when it isn't sure. Pairs with the foresight track record (§1) the\n # predictor is now primed with. Range 0.0-1.0.\n foresight_min_confidence: float = 0.0\n # PART IV Phase 2c — replace the world model's SELF-REPORTED confidence (measured Pearson≈0 with the\n # realized outcome, §21.12) with a CALIBRATED §12-verifier score. When on and a client is available,\n # after the K-idea ranker picks the predicted-best candidate the §12 verifier scores it (grounded +\n # repeated + criteria-decomposed: `foresight_criteria` — likely to improve the objective, and\n # sound/feasible) and THAT calibrated score becomes the confidence the `foresight_min_confidence` gate\n # and the telemetry use. Degrades to the self-reported confidence without a client or on any verifier\n # error (the telemetry records which source was used). ON by default in the product Settings (ce4a379),\n # EngineOptions off; a few extra LLM calls per acted-on proposal. See looplab/trust/verifier.py + search/foresight.py.\n # REVISIT (cost/quality, not yet resolved): a live classification run measured ~10 LLM calls & ~3.8 min/node\n # on deepseek-v4-flash with the full Part IV/V default bundle; the calibrated verifier is part of that per-node\n # tax. Kept ON deliberately — it raises decision quality (self-reported foresight confidence has ≈0 Pearson\n # with realized outcome), and lowering quality to save calls is the wrong trade. TODO: run a frozen quality/cost\n # A/B (verifier-on vs -off) to quantify the recall@objective gain per extra call before considering a default flip.\n foresight_verify: bool = True\n # Verifier sample count for `foresight_verify` (the §12 repeated-sampling expectation on a no-logprob\n # backend). 3 tames the single-shot variance §21.12 measured; 1 = single-shot (cheaper, noisier).\n # RESUME-critical: this value is persisted in config.snapshot.json, so a hard `le` schema bound would\n # make an old run started with a larger LOOPLAB_FORESIGHT_VERIFY_SAMPLES un-resumable (Settings(**snapshot)\n # would raise on `looplab resume` / finalize-recovery / serve replay). CLAMP into [1, MAX] via a validator\n # instead of rejecting — the runtime cap is preserved without breaking the snapshots/env-compat rule.\n foresight_verify_samples: int = 3\n\n @field_validator(\"foresight_verify_samples\")\n @classmethod\n def _clamp_foresight_verify_samples(cls, v: int) -> int:\n # Coerce a persisted/env value into [1, MAX] rather than reject (see field note above).\n try:\n v = int(v)\n except (TypeError, ValueError):\n return 3\n return max(1, min(v, MAX_FORESIGHT_VERIFY_SAMPLES))\n\n # T7 LLM response cache: serve an identical DETERMINISTIC (temperature 0) request from an\n # in-process content-addressed cache instead of re-hitting the model — cuts cost on\n # retry/panel/verify flows. NEVER caches sampling calls (temp>0: best-of-N / panel / novelty\n # re-propose must vary). Off by default (most role calls run at temperature>0 anyway).\n llm_cache: bool = False\n agent_cmd: str | None = None # override the agent's launcher (path / wrapper)\n # External-agent validation (ADR-7): wrap a CLI-agent Developer with a validator that\n # audits each output (no-op/syntax/crash/timeout), retries with feedback, and falls\n # back to the in-house LLM Developer. Off -> use the raw agent output unchecked.\n validate_agent: bool = True\n agent_max_retries: int = 1 # re-prompts of the agent on an invalid result\n # Patch-gated multi-file agent (ADR-7 Rule 3): run the CLI agent in a git worktree and\n # accept only edits whose paths match `agent_surface` (reject-not-strip out-of-surface\n # touches). Lets the agent create helper modules, not just solution.py. Degrades to\n # whole-file readback if git is unavailable.\n agent_patch_gate: bool = True\n agent_surface: list[str] = [\"*.py\"] # edit-surface allow-list (globs)\n # Trust policy for an agent-authored eval/metric adapter (RepoTask onboarding, Phase 3):\n # \"ratify_freeze\" waits for human approval, \"autonomous\" accepts the proposed spec without that\n # gate, and \"ratify_freeze_drift\" also enforces the configured independent drift cross-check.\n eval_trust_mode: str = \"ratify_freeze\"\n # RepoTask node seeding policy (run-wide fallback; a task/editable `seed_mode` overrides):\n # \"auto\" (default) copies git-tracked files when the editable is a git repo (so a tree bloated\n # with untracked artifacts isn't deep-copied per node), else a full copy; \"tracked\" forces\n # code-only; \"all\" forces a full recursive copy (legacy). Genesis authors per-task from the\n # user's words; this is the global default when unspecified.\n seed_mode: str = \"auto\"\n llm_model: str = \"qwen3:8b\"\n # === LLM / transport ==================================================================\n llm_base_url: str = \"http://localhost:11434/v1\" # Ollama OpenAI-compatible endpoint\n llm_temperature: float = 0.6\n # H3 per-role model presets (5090 recipe): optionally run the Researcher and Developer on\n # DIFFERENT models/endpoints (e.g. Developer=Qwen3-Coder-30B for code, a fast model for breadth).\n # Blank = use the shared llm_model/llm_base_url. Generalizes the role-backend swap to per-role.\n researcher_model: str | None = None\n developer_model: str | None = None\n researcher_base_url: str | None = None\n developer_base_url: str | None = None\n # Per-role sampling temperature (§4.1): the Researcher wants breadth (higher temp = more diverse\n # ideas), the Developer wants determinism (lower temp = fewer syntax slips), the Strategist a\n # steady hand. Each overrides the shared `llm_temperature` for that role only; None = use the\n # shared value (byte-identical to before). Flat + nullable, mirroring the per-role model fields.\n researcher_temperature: float | None = None\n developer_temperature: float | None = None\n strategist_temperature: float | None = None\n # Unified self-driving agent: one LLM identity that plays Researcher + Developer (+ Strategist)\n # across pipeline stages, choosing its own model/toolset per stage. ON by default — the agent\n # drives the loop (incl. crash triage: repair/abandon/reject_idea) and, via\n # `agent_drives_actions`, picks the next macro action within a pure legal-action gate. Set both\n # to False for the legacy byte-identical split-role behavior. (No-op unless backend=\"llm\".)\n unified_agent: bool = True\n agent_drives_actions: bool = True\n # Layer 3 Card queue ownership. False preserves the existing policy/unified-pilot action path.\n # True is pinned in run_started because changing the selector on resume would mix two search\n # treatments in one run. When both this and agent_drives_actions are true, Card selection wins.\n card_driven_selection: bool = False\n # Layer 5 bounded speculative Card buffer. Zero is a hard OFF switch and preserves the\n # historical alternating build/eval spine; positive values are pinned by run_started so a\n # resume cannot silently mix search treatments after a snapshot/default edit.\n speculation_depth: int = Field(default=0, ge=0, le=64)\n # Local evidence receipt produced by ``looplab speculation-gate``. This is deliberately absent\n # from the curated Settings UI: positive-depth execution is a calibrated rollout decision, not a\n # casual tuning knob. The Engine validates the receipt against the current scorer,\n # implementation, GPU inventory and raw paired-run evidence before it enables speculation.\n speculation_gate_receipt: str | None = Field(default=None, max_length=4096)\n # Per-stage model/endpoint overrides for the unified agent. Recognized keys (see\n # tasks.build_unified_agent): `propose` (researcher), `implement`/`repair` (developer),\n # `strategy`, `pilot`. Unlisted keys are ignored. Empty = the per-role researcher_*/developer_*\n # models, then the shared llm_model. Explicit map wins. No-op unless `unified_agent` is true.\n agent_stage_models: dict = {}\n agent_stage_base_urls: dict = {}\n llm_parser: str = \"tool_call\"\n # Reasoning/thinking toggle sent IN the request (providers differ — see `llm.reasoning_body`):\n # Qwen3 on vLLM/SGLang -> chat_template_kwargs.enable_thinking (bool)\n # OpenAI/Ollama-v1/DeepSeek -> reasoning_effort (low|medium|high|none)\n # `llm_reasoning`: \"\" = send nothing (server default — current behavior); off = actively disable;\n # on = enable at default depth; low|medium|high = enable at that effort.\n # Defaults to \"high\" so the agent reasons before proposing/repairing/triaging (better code, fewer\n # crashes); set \"\" to send nothing (server default) or \"off\" to disable. The model's chain-of-thought\n # is CAPTURED either way (reasoning_content field or inline <think>). NOTE: to get a SEPARATE\n # reasoning_content field from SGLang/vLLM, the server also needs `--reasoning-parser qwen3`.\n llm_reasoning: str = \"high\"\n llm_reasoning_style: str = \"auto\" # auto | qwen | effort | none — how to shape the request param\n llm_reasoning_extra: dict = {} # raw fields merged into the body (escape hatch, e.g. Anthropic thinking)\n # H1: drive structured calls with the endpoint's constrained decoding (vLLM/SGLang guided_json +\n # response_format json_schema) so weak models can't emit invalid JSON. Off for Ollama (default).\n llm_guided_json: bool = False\n # Stream every LLM request (SSE) and reassemble it, so the request `timeout` is an INTER-TOKEN\n # idle timeout rather than a whole-request deadline: a long-but-alive generation is never cut off\n # (tokens keep resetting the timer), while a genuinely STALLED endpoint (no token for `timeout` s)\n # is detected and retried on a fresh connection — the fix for silent multi-minute hangs. Set False\n # to use one blocking read (old per-op timeout semantics) if an endpoint streams badly.\n llm_stream: bool = True\n # LLM IDLE timeout (seconds). Stream mode: the INTER-TOKEN stall limit — a steady generation is\n # never cut off, only a silent endpoint. Non-stream: bounds the whole read. Raise for endpoints\n # with a long prefill on huge prompts; lower to fail fast on a flaky shared endpoint.\n llm_timeout: float = Field(default=180.0, gt=0)\n # First-byte (response-headers) window for STREAM attempts, seconds: an SSE response sends its\n # headers on admission, so no-headers ≈ a black-holed request — fail over to a fresh connection\n # fast instead of waiting the full idle timeout. Clamped to llm_timeout. Non-stream attempts are\n # NOT bounded by this (their headers arrive only after the whole generation). The default mirrors\n # `DEFAULT_HEADER_TIMEOUT_S` in looplab/core/llm.py is the single source (imported above).\n llm_header_timeout: float = Field(default=DEFAULT_HEADER_TIMEOUT_S, gt=0)\n # Honour HTTP(S)_PROXY / NO_PROXY / SSL_CERT_FILE env vars for LLM calls. Default False: the\n # transport connects DIRECTLY (many internal/OLAP endpoints are reachable only by bypassing an\n # ambient proxy — a picked-up proxy yields \"connection refused\"). Set true when the endpoint is\n # reachable ONLY via a corporate proxy or needs a custom CA bundle from the environment.\n llm_trust_env: bool = False\n # H4/C2: compact the growing agentic tool-call history once it exceeds this many chars (auto_summary\n # LLM-summarizes the stale middle; else middle-truncate). Sized to the model's real context window so\n # files read earlier STAY in context instead of being compacted away and re-read: ~1,000,000 chars ≈\n # 250k tokens (≈4 chars/token), matching the configured deepseek-v4-flash (≥200k tokens confirmed).\n # The old 120k-char (~30k-token) default was ~8× too aggressive and drove a read-thrash loop. 0 = off.\n context_budget_chars: int = 1_000_000\n # M5: the Researcher's always-on experiments digest budget (chars). 0 = AUTO — scales with the\n # run size (60 chars/node, bounded [1200, 6000]) instead of one flat cap for an 8-node toy run\n # and a 200-node benchmark run alike. Set a positive value to pin it.\n digest_char_cap: int = Field(default=0, ge=0)\n # D11 compression model slot (open_deep_research's four-slot pattern): a dedicated CHEAP model\n # for agent-history summarization (C2 auto-summary), so compression doesn't pay the main\n # model's price. Blank = use each loop's own model (legacy). `compressor_base_url` blank =\n # the shared llm_base_url.\n compressor_model: str | None = None\n compressor_base_url: str | None = None\n # === Agentic tool-loop ================================================================\n # Agentic tool-loop limits — apply to EVERY tool-using agent (the LLM Researcher, the unified\n # agent's pilot + crash-triage, Deep-Research, the run-chat Boss, the genesis repo-scout, and the\n # cross-run report synthesizer). The loop lets the model call read-only tools across turns before\n # emitting its structured result. Both default to UNLIMITED so the agent takes as many turns / as\n # long as it needs — it is never cut off mid-reasoning; set a positive value here (or per-run, or\n # in the UI settings) to bound latency/cost. These used to be hardcoded per call site (e.g. the\n # boss at max_turns=3 / 45s), which silently dropped a slow reasoning model to a no-op reply.\n agent_max_turns: int = 0 # max tool turns before the emit is forced (0 = unlimited)\n agent_time_budget_s: float = 0.0 # wall-clock ceiling across the loop's turns (0 = no cap)\n # G · Soft convergence SAFETY NET (not a research cap). Two thresholds on the agent's tool-turn count:\n # at `agent_emit_after` the loop NUDGES the agent once (\"you've investigated enough — emit now\"); at\n # `agent_emit_force` it FORCES the emit from what was gathered. Set GENEROUSLY — a focused researcher\n # may legitimately read many files/experiments before it can propose a grounded idea; these only catch\n # a genuine runaway that keeps issuing DIFFERENT calls forever (which stuck-detection, keyed on\n # repeats, misses). NB: the old 499-read runaway (node 63) was 79% REDUNDANT re-reads caused by a\n # broken read_file — fixing read_file (pagination) is the real cure; this is just insurance. 0 = off.\n agent_emit_after: int = 300\n agent_emit_force: int = 500\n # B1 · No-progress / stuck detection — the safety net that makes \"unlimited turns\" safe. The\n # turn/time ceilings above are only BACKSTOPS; this is what actually stops a runaway loop, on the\n # cheapest signal: when the model repeats the SAME tool call (or ping-pongs between two, or keeps\n # hitting the SAME error) with no progress, the loop forces the final emit and finishes. ON by\n # default; reading DIFFERENT files or running ONE long command never trips it. (OpenHands-style.)\n agent_stuck_detection: bool = True\n agent_stuck_repeat: int = 4 # identical calls in a row that count as \"stuck\" (>=2)\n agent_stuck_alternate: int = 4 # ping-pong cycles between two calls that count as \"stuck\" (>=2)\n # C1 · Self-plan (TodoWrite-style): expose an `update_plan` tool so a long-running agent keeps its\n # OWN working TODO and re-surfaces it every `agent_plan_reinject_every` turns — keeps the goal in\n # view across a long loop so the agent \"writes its own context\" (Claude Code style). ON by default.\n agent_self_plan: bool = True\n agent_plan_reinject_every: int = 5\n # C2 · Auto-summary (ON by default): when the tool-loop history grows long, LLM-summarize the\n # stale middle instead of only middle-truncating it. The trigger is `context_budget_chars` if set,\n # else a built-in high-water mark (context_budget.DEFAULT_SUMMARY_CHARS ≈ 120k chars) so short\n # loops are untouched. Falls back to truncation on any summarizer error; one extra model call per\n # over-budget turn.\n agent_auto_summary: bool = True\n # C4 · Repo-developer plan decomposition. A big feature attempted in ONE developer session made a\n # non-converging reasoning model loop indefinitely — it kept writing + exploring without ever\n # emitting `done` (10k+ LLM calls / hours; agentic stuck-detection can't catch VARIED exploration,\n # only literal repeats). Fix: the developer first proposes an ordered plan of ATOMIC steps; a\n # multi-step plan is executed step-by-step, each in a FRESH bounded session with only that step's\n # scope + the files accumulated so far, syntax-validated per write. Every session stays\n # small-context and convergent. ON by default for repo tasks (a 1-step plan == the old single pass).\n developer_plan_decompose: bool = True\n developer_plan_min_steps: int = 2 # a proposed plan with >= this many steps runs step-by-step\n developer_plan_max_steps: int = 8 # cap on plan length (a runaway planner can't spawn 100 steps)\n # Hard per-session backstop for the repo developer — the ONE write-heavy agent that can run away\n # even with stuck-detection on (varied writes/reads never trip the repeat/ping-pong signal). Bounds\n # the plan phase, EACH step, and the single-session fallback. Unlike the global agent_* limits\n # (0 = unlimited, meant for read-only agents), the developer always gets a FINITE ceiling so a\n # model that never emits `done` fails cleanly with the code it wrote so far, instead of looping.\n developer_session_max_turns: int = 500\n developer_session_time_budget_s: float = 1200.0 # 20 min wall-clock per developer session\n # Phase-handoff summaries. Each LLM phase in a node build (Researcher·propose → Developer·stages →\n # plan → implement) ends with ONE extra LLM call that distills its transcript — the repo structure\n # it mapped, files/data confirmed, decisions made — into a brief injected into the NEXT phase (even\n # across the role boundary). The next phase TRUSTS that instead of re-reading the same files, which\n # cuts the biggest source of tool-call thrash (stages/plan/implement each re-exploring the repo).\n # One summary call per phase in exchange for many fewer read calls downstream. ON by default.\n phase_handoff_summary: bool = True\n # Observability (ADR-17): capture each LLM call's full prompt + completion into the active\n # span (spans.jsonl) so the UI can show exactly what the model read and wrote per node.\n # Diagnostics only — `replay.fold` never reads spans. Default on for local single-user; set\n # LOOPLAB_TRACE_LLM_IO=0 to suppress (e.g. to keep spans.jsonl small or avoid storing prompts).\n trace_llm_io: bool = True\n # Run-introspection tools (ON by default): make the LLM Researcher (and DeepResearcher) a\n # tool-using agent that can read its OWN experiments (list/read/find-analogous/themes/code) and\n # the task data (schema/profile/asset) mid-loop, instead of seeing only best+parent. Advisory —\n # never changes best-selection. Off = the legacy single-shot Researcher (richer digest still added).\n researcher_tools: bool = True\n # Cross-run introspection: give the agentic Researcher / pilot read-only tools to look at\n # SIBLING runs (same task_id, same run-root) — list them, read an experiment / its code, and\n # find analogous configs across runs — so a run can build on what neighbouring runs already\n # learned instead of rediscovering it. Advisory; never changes best-selection. Needs the run's\n # own dir wired through (no-op for unit-built roles). Off = the legacy single-run view only.\n cross_run_tools: bool = True\n # Read-only access to EVERY run on this machine ACROSS ALL TASKS (not just same-task siblings):\n # the Developer/Researcher get list_all_runs + read_run_code + read_run_experiment so they can read\n # the code + result of ANY past experiment anywhere and reuse an approach. Broader than\n # `cross_run_tools` (same-task only); the agent decides when a foreign run is relevant. Advisory;\n # never changes best-selection. Needs the run's own dir wired through (no-op for unit-built roles).\n all_runs_tools: bool = True\n # PART V §22 — read-only CROSS-RUN KNOWLEDGE tool for the reasoning roles (Researcher, Strategist,\n # deep-research, and a Developer-scoped variant). Adds `cross_run_prior_attempts` / `cross_run_claims`\n # / `cross_run_atlas` over the §21.20 read-models (concept overview + claim assessments + atlas), read\n # from `memory_dir` (lessons.jsonl + concept_capsules.jsonl). Read-only is a storage-authority\n # boundary, not a behavioral no-op: retrieved content can steer later proposals, and a tool-using\n # reasoning loop can consume additional model calls. The tools never mutate cross-run truth (facts\n # are engine-written, verdicts operator-ratified, §22.4) or directly select a winner. ON by explicit\n # experimental product choice, EngineOptions off; stores only exist once Part-IV features have run.\n cross_run_read_tools: bool = True\n # Agentic retrieval (ADR-16): if set, the LLM Researcher gets grep/kb_search/read\n # tools over this directory of markdown notes and chooses when to use them.\n knowledge_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"knowledge\"))\n # T4 real embeddings: model id for an OpenAI-compatible `/embeddings` endpoint used for\n # kb_search / case retrieval. Blank (default) = the dependency-free lexical `hash_embed` (today's\n # behavior). Set e.g. \"nomic-embed-text\" (Ollama) or \"text-embedding-3-small\" for SEMANTIC\n # retrieval; a misconfigured/offline endpoint degrades back to hash_embed at call time (never\n # crashes a run). `embed_base_url` blank = reuse `llm_base_url`; the shared `llm_api_key` is used.\n embed_model: str | None = None\n embed_base_url: str | None = None\n # Harmonic memory (Memora, ICML'26 — idea import). When ON, cross-run cases and knowledge notes are\n # indexed by a short ABSTRACTION + cue ANCHORS instead of their raw text, near-duplicate memories are\n # CONSOLIDATED into one entry under a matching abstraction, and `kb_search`/case retrieval EXPAND\n # through the top hits' anchors to surface related-but-not-similar memories. ON by default. Set\n # `memora=false` to restore the pre-Memora raw-text index. Never a source of truth — abstractions\n # live only in the derived, rebuildable retrieval index (never the event log or canonical cases.jsonl).\n memora: bool = True\n # Write abstractions with the wired chat model (richer than the deterministic lexical fallback). ON\n # by default; results are CACHED by content hash (see `memora_cache`) so a re-built index doesn't\n # re-call the model on unchanged notes/cases, and any endpoint failure degrades to lexical at call\n # time — so an offline box just gets lexical abstractions, never a crash. Set false to force lexical.\n memora_llm: bool = True\n # === Cross-run memory / Memora ========================================================\n memora_anchors: int = 6 # max cue anchors kept per memory\n # Cosine similarity at/above which a new case is CONSOLIDATED into an existing one (same evolving\n # topic) rather than stored as a separate entry. Only used when `memora` is on.\n memora_consolidate_threshold: float = 0.86\n # Where the LLM-abstraction cache lives (JSON). Blank (default) derives it from `memory_dir`\n # (`<memory_dir>/memora_cache.json`) or else `knowledge_dir` (`<knowledge_dir>/.memora_cache.json`);\n # with neither set the cache is in-memory only (per process). No-op unless `memora_llm` is on.\n memora_cache: str | None = None\n # E3 literature-grounded ideation (network-OPTIONAL): give the agentic Researcher an arXiv search\n # tool to ground ideas in real techniques. Off by default (egress is unreliable on some boxes);\n # fails gracefully if the network is blocked.\n literature_search: bool = False\n # Deep-Research stage (network-OPTIONAL): give the DeepResearcher a general web search/fetch\n # tool (DuckDuckGo) on top of arXiv to read across results + the web before steering the next\n # batch. Off by default (egress is unreliable on some boxes); fails gracefully when blocked.\n web_search: bool = False\n # Cadence for the Deep-Research stage: run it automatically every N created nodes. Zero disables\n # only the cadence; manual `deep_research` and Strategist `request_research` triggers still fire.\n # Default 3: deep research analyzes the accumulating results and steers the next batch (its\n # directions become hints + open hypotheses); paired with `concurrent_research` it overlaps the\n # think with the GPU-bound eval, so the cadence costs LLM tokens but no wall-clock on the search.\n deep_research_every: int = 3\n # Overlap a DUE deep-research \"think\" with the GPU-bound eval instead of running it in its own\n # serial step (the agent is otherwise idle while a node trains). research() computes from a state\n # snapshot; the joined background task records only allowlisted, order-tolerant\n # research/hint/hypothesis events as soon as the memo finishes. ON by default: the LLM is typically\n # remote (no GPU contention with eval), so overlapping hides latency behind long training.\n concurrent_research: bool = True\n # Concurrent-research REPEAT — don't idle a multi-DAY eval. The overlapped \"think\" fires ONCE per\n # window by default; ON here, it RE-RUNS on an adaptive time cadence for the WHOLE eval window\n # (a two-day training must not leave the reasoning agents idle after one memo). Self-paced: it\n # only records a memo whose CONTENT is new (identical re-runs are skipped, so the log/board don't\n # bloat) and backs off geometrically as the analysis converges, capped so it always re-checks.\n # The appended records never rewrite the current metric champion, but their hints/open hypotheses\n # deliberately steer later proposals and replay reconstructs that advice. The library default is\n # one-shot (== today, byte-identical); this product default turns the repetition on.\n concurrent_research_repeat: bool = True\n # Base cadence (seconds) between repeated concurrent-research passes. Research is EXPENSIVE\n # (multi-turn LLM + web/arXiv), so this is a FLOOR, not a ceiling: the effective cadence is\n # max(this, ~5% of the per-experiment time budget) — a two-day eval is re-researched about hourly,\n # a short eval not at all (the first tick outlasts it). Only matters when the repeat is on.\n concurrent_research_interval_s: float = Field(default=1800.0, gt=0)\n # Per-eval-window backstop on repeated-research LLM calls (0 = cadence-only, no cap). The adaptive\n # cadence + convergence backoff are the primary budget control; this bounds a pathological\n # never-converging window. Past it the loop stops calling the LLM (the health monitor still runs).\n concurrent_research_max_calls: int = Field(default=40, ge=0)\n # Concurrent hypothesis-board CONSOLIDATION during the eval window. Repeated research keeps ADDING\n # near-duplicate directions to the open board (each memo registers up to 5 as open hypotheses), so a\n # long eval's board bloats before the between-nodes merge runs. This dedups/merges it on the SAME\n # background loop so the board stays clean for the next proposal. In Card-driven mode the same merge\n # changes ownership/readiness, so it is deferred to the joined main-task cadence instead. Needs the\n # reflect client + `track_hypotheses`.\n # Only active while `concurrent_research` runs the overlap loop. Library default off (== today).\n concurrent_consolidate: bool = True\n # Cadence for the agent-authored run report: regenerate the conclusion-first narrative every N\n # created nodes (0 = off; it still regenerates on a manual `report_refresh` from the UI). The\n # deterministic report always renders from the node set regardless of this knob.\n report_every: int = 3\n # Agent Skills (I18, ADR-9): dir of SKILL.md the Researcher can list/load as tools.\n skills_dir: str | None = None\n # Prompt store (I18, ADR-8): dir of editable, hot-reloaded role prompt .md files.\n prompt_dir: str | None = None\n # API key as a reference, never serialized as a value (ADR-11). Local servers\n # ignore it; default to a placeholder.\n llm_api_key: SecretStr | None = None\n\n @model_validator(mode=\"before\")\n @classmethod\n def _apply_profile(cls, data):\n \"\"\"Expand `profile` into a bundle of setting defaults BEFORE validation.\n\n pydantic-settings hands this validator the fully-merged input from ALL explicit sources\n (init kwargs + LOOPLAB_* env + .env), with fields the user left unset simply ABSENT (they\n fall to field defaults afterward). So a profile value is applied only when its key is not\n already present — i.e. any explicit file/CLI/env setting wins over the profile, and the\n profile wins over the bare field default. This makes `thorough` a true convenience preset,\n never an override of what the user asked for.\"\"\"\n if not isinstance(data, dict):\n return data\n prof = data.get(\"profile\", \"default\")\n # env may deliver the key as \"profile\" (env_prefix stripped) — normalize to a str\n prof = str(prof).strip().lower() if prof is not None else \"default\"\n overrides = PROFILES.get(prof)\n if overrides is None:\n raise ValueError(f\"unknown profile {prof!r}; known: {sorted(PROFILES)}\")\n for key, val in overrides.items():\n if key not in data: # explicit source (file/CLI/env) always wins\n data[key] = val\n return data\n\n @model_validator(mode=\"after\")\n def _check_trust_gate(self):\n if self.trust_gate not in (\"audit\", \"gate\", \"block\"):\n raise ValueError(\n f\"trust_gate must be audit|gate|block, got {self.trust_gate!r}\")\n if self.merge_mode not in (\"auto\", \"mean\", \"ensemble\"):\n raise ValueError(\n f\"merge_mode must be auto|mean|ensemble, got {self.merge_mode!r}\")\n # novelty_mode drives the dedup/novelty gate; an out-of-set value (e.g. a mis-cased\n # LOOPLAB_NOVELTY_MODE=LLM, or \"on\") used to fall through _apply_novelty_gate silently as a\n # NO-OP — turning the gate off with no diagnostic. Fail loudly at config time like trust_gate/\n # merge_mode do. (Other enum-ish string fields — seed_mode/eval_trust_mode/strategist_backend —\n # share the pattern; left unvalidated here only because their full value sets are less settled.)\n if self.novelty_mode not in (\"off\", \"algo\", \"llm\"):\n raise ValueError(\n f\"novelty_mode must be off|algo|llm, got {self.novelty_mode!r}\")\n # The remaining enum-ish string fields (arch-review §5 P3): a typo used to be accepted at\n # construction and only fail-safe/later-loud downstream (e.g. a mis-cased strategist_backend\n # silently ran the default). Fail loudly here like the fields above.\n if self.strategist_backend not in (\"off\", \"rule\", \"llm\", \"agent\"):\n raise ValueError(\n f\"strategist_backend must be off|rule|llm|agent, got {self.strategist_backend!r}\")\n if self.eval_trust_mode not in (\"ratify_freeze\", \"autonomous\", \"ratify_freeze_drift\"):\n raise ValueError(\n \"eval_trust_mode must be ratify_freeze|autonomous|ratify_freeze_drift, \"\n f\"got {self.eval_trust_mode!r}\")\n if self.seed_mode not in (\"auto\", \"tracked\", \"all\"):\n raise ValueError(f\"seed_mode must be auto|tracked|all, got {self.seed_mode!r}\")\n return self\n\n def masked_snapshot(self) -> dict:\n # Snapshots are a JSON contract (they are written verbatim to config.snapshot.json), not a\n # Python-object dump. In particular tuple-valued schema fields such as\n # ``inline_repair_reasons`` must become JSON arrays before callers hash the runtime envelope;\n # otherwise the strict speculation-scope canonicalizer sees a different/non-JSON preimage\n # from the one that ``json.dumps`` eventually persists.\n d = self.model_dump(mode=\"json\")\n if self.llm_api_key is not None:\n d[\"llm_api_key\"] = \"***\"\n else:\n d[\"llm_api_key\"] = None\n return d\n\n\n# Pre-versioned snapshots are full Settings dumps, so absence is a reliable per-field version marker.\n# Keep their historical effective behavior when newer product defaults become active: re-entry must not\n# silently add paid calls, interventions, concurrency or a different selection policy to an old run.\nLEGACY_CONFIG_SNAPSHOT_DEFAULTS: dict[str, object] = {\n \"parallel_build\": 1,\n \"eval_parallel\": None,\n \"llm_parallel\": None,\n \"train_monitor\": False,\n \"train_monitor_interval_s\": 600.0,\n \"train_monitor_kill\": False,\n \"train_monitor_kill_confidence\": 0.8,\n \"asha_live\": False,\n \"asha_live_kill\": False,\n \"asha_live_quantile\": 0.5,\n \"asha_live_min_siblings\": 3,\n # Before this operator ceiling existed, the sandbox's 24-hour process limit was authoritative.\n \"max_eval_timeout\": 24 * 3600.0,\n \"watchdog_reflection\": False,\n \"card_driven_selection\": False,\n \"speculation_depth\": 0,\n \"speculation_gate_receipt\": None,\n \"concurrent_research_repeat\": False,\n \"concurrent_research_interval_s\": 1800.0,\n # Zero means unlimited; 40 was the first bounded default, and is latent while repeat is disabled.\n \"concurrent_research_max_calls\": 40,\n \"concurrent_consolidate\": False,\n}\n\n\ndef migrate_config_snapshot(data: dict) -> dict:\n \"\"\"Return an effective copy of one legacy/current snapshot without rewriting evidence bytes.\"\"\"\n if not isinstance(data, dict):\n raise TypeError(\"config snapshot must be a JSON object\")\n migrated = dict(data)\n for key, value in LEGACY_CONFIG_SNAPSHOT_DEFAULTS.items():\n migrated.setdefault(key, value)\n return migrated\n\n\ndef settings_from_snapshot(data: dict) -> Settings:\n # CODEX AGENT: snapshots have no schema/runtime version and Settings ignores unknown fields.\n # An older binary can silently drop newer behavior-affecting controls and resume the same event\n # history with different paid/concurrency/selection semantics. Version snapshots and fail closed\n # on an unsupported downgrade before migration/default filling.\n \"\"\"Build re-entry Settings from a masked snapshot under its historical missing-field contract.\"\"\"\n migrated = migrate_config_snapshot(data)\n migrated.pop(\"llm_api_key\", None)\n return Settings(**migrated)\n"}, "1aeead3b96bc409f307f6b75634e0e14aa71ffcd": {"t": 1785768763.3836527, "n": 98825, "h": "fac3a4c86a18574e", "c": "\"\"\"Engine config: the schema (what settings exist). (I0, ADR-11)\n\nOne pydantic-settings class declaring every engine knob, its default, and its docs. The loader —\nhow a run file / CLI / env becomes a `Settings` — is `looplab.core.appconfig`, which also owns\nthe config-precedence order. A resolved, secret-masked snapshot is written next to each run for\nreproducibility. (No real secrets in P0, but the masking discipline is in place.)\n\"\"\"\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nfrom pydantic import Field, SecretStr, field_validator, model_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n# Single source of the LLM first-byte (response-headers) default — see core/llm.py.\nfrom looplab.core.llm import DEFAULT_HEADER_TIMEOUT_S\n\n# Default home for cross-run memory + the knowledge base (shared across every run). Both are ON by\n# default now (a user asked for it) so a fresh install accumulates + consults knowledge with no setup;\n# set the env var to \"\" to disable, or to a path to relocate. `~/.looplab/` keeps them out of any one\n# project + shared across projects.\n_LL_HOME = Path.home() / \".looplab\"\n\n# Autonomous roles that may be granted per-setting write access (see Settings.agent_control).\n# strategist = A7 meta-controller (run-wide); boss = run-chat operator-proxy (run-wide);\n# researcher = per-experiment proposer (per-node sizing, e.g. eval timeout for a heavy model).\nAGENT_ROLES = (\"strategist\", \"boss\", \"researcher\")\n\n# A foresight score is computed for every acted-on proposal. Keep this lower than the generic\n# verifier's emergency ceiling so a typo cannot multiply paid synchronous calls across a run.\nMAX_FORESIGHT_VERIFY_SAMPLES = 8\n\n\n# Layer-2 parallelism renamed two public settings without removing the durable legacy fields. Keep\n# the equivalence in one place: config-source precedence, governance migration, and live strategy\n# application must all agree which names describe the same authority/value.\nPARALLELISM_ALIASES: dict[str, str] = {\n \"max_parallel\": \"eval_parallel\",\n \"parallel_build\": \"llm_parallel\",\n}\n\n\ndef parallelism_aliases(setting: str) -> tuple[str, ...]:\n \"\"\"Return every spelling in ``setting``'s alias family, canonical name first.\"\"\"\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if setting in (legacy, canonical):\n return canonical, legacy\n return (setting,)\n\n\ndef canonicalize_parallelism_source(\n values: dict, *, promote_build_to_llm_parallel: bool = False,\n) -> dict:\n \"\"\"Promote legacy parallel keys *within one config-precedence source*.\n\n Applying this only after file/CLI/env were flattened would let a lower-priority canonical env\n value shadow a higher-priority legacy file value (or a file canonical value shadow a CLI legacy\n override), so callers with sub-layers normalize each layer independently.\n\n ``max_parallel``/``eval_parallel`` are true aliases (pure eval width, no extra semantics) and are\n always promoted. ``parallel_build``/``llm_parallel`` are NOT: a positive ``llm_parallel`` also flips\n on the finite shared LLM broker (``orchestrator._startup_llm_total``), while legacy ``parallel_build``\n means \"legacy build width, unbounded overlap, no shared total\". So promoting a legacy-only\n ``parallel_build`` layer (e.g. just ``LOOPLAB_PARALLEL_BUILD=1`` in env) would silently serialize\n every provider call — contradicting llm_broker.py's docstring, orchestrator's \"legacy-only preserves\n unbounded overlap\" note, the broker-neutral live-control path, and the configuration.md ``llm_parallel``\n row (\"falls back ... without enabling a shared total\"). Config/startup loads therefore keep broker\n opt-in tied to an EXPLICITLY-spelled canonical ``llm_parallel``; the orchestrator's own width fallback\n (``llm_parallel if not None else parallel_build``) still carries the legacy build width. Only the\n Strategist live-control path (``canonicalize_strategy_parallelism``) opts into\n ``promote_build_to_llm_parallel``, where ``parallel_build`` is a deliberate full alias of the\n canonical knob (see strategy.py ``_apply_strategy``).\n \"\"\"\n if not isinstance(values, dict):\n return values\n out = dict(values)\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if canonical == \"llm_parallel\" and not promote_build_to_llm_parallel:\n continue\n # None is the durable legacy mode for llm_parallel, not a missing value. A normal snapshot\n # contains llm_parallel:null plus parallel_build:1; promoting null to 1 would unexpectedly enable\n # the global one-call broker. Promote aliases only when the canonical key is absent, and pin\n # snapshot round-trip parity. Canonicalize inside each precedence layer, never across the\n # flattened result, so the alias migration cannot invert CLI > file > env precedence.\n if canonical not in out and out.get(legacy) is not None:\n out[canonical] = out[legacy]\n return out\n\n# Settings whose semantics are committed by ``run_started`` and restored from the folded event log\n# on every re-entry. Changing one only in ``config.snapshot.json`` would mix incompatible evidence\n# (a different holdout split) or make the live producer disagree with replay's selection policy.\n# The HTTP config editor and the engine both consume this one contract; keep deliberately mutable\n# controls such as ``trust_gate`` out (that field has its own durable change event).\nRUN_START_PINNED_FIELDS = frozenset({\n \"card_driven_selection\",\n \"speculation_depth\",\n \"holdout_fraction\",\n \"holdout_select\",\n \"select_verifier\",\n \"select_verifier_samples\",\n \"verifier_ci_tie\",\n})\n\n\ndef run_start_pinned_settings(state) -> dict:\n \"\"\"Return the effective Settings names committed by a folded run-start record.\n\n Pre-pin legacy logs have no recorded holdout fraction, so their snapshot remains authoritative\n for both holdout knobs. Verifier fields, however, have always been re-pinned from the legacy-safe\n fold defaults whenever a valid ``run_started`` identity exists.\n \"\"\"\n if not getattr(state, \"run_id\", \"\"):\n return {}\n values = {\n # Layer 3 changes which queue owns macro-action selection. Once a run starts, replay and\n # resume must keep the same owner even if the snapshot or ambient default changes later.\n \"card_driven_selection\": bool(getattr(state, \"card_driven_selection\", False)),\n # Layer 5 overlaps Card production with evaluation only when this pinned treatment is\n # positive. Old logs omit it and therefore retain the exact serial build/eval spine.\n \"speculation_depth\": int(getattr(state, \"speculation_depth\", 0)),\n \"select_verifier\": bool(getattr(state, \"select_verifier_tiebreak\", False)),\n \"select_verifier_samples\": int(getattr(state, \"select_verifier_samples\", 3)),\n \"verifier_ci_tie\": bool(getattr(state, \"verifier_ci_tie\", False)),\n }\n holdout_fraction = getattr(state, \"holdout_fraction\", None)\n if holdout_fraction is not None:\n values.update({\n \"holdout_fraction\": holdout_fraction,\n \"holdout_select\": bool(getattr(state, \"holdout_select\", False)),\n })\n if not values.keys() <= RUN_START_PINNED_FIELDS:\n raise RuntimeError(\"folded run-start pinned settings contract drifted\")\n return values\n\n# --- Run profiles (config-first presets) -------------------------------------------------------\n# A `profile` is a NAMED BUNDLE of setting defaults — nothing more. It only fills fields the user\n# did NOT set explicitly (via file / --set / LOOPLAB_* env / init-kwargs), so any explicit knob\n# always wins (see `_apply_profile`). Most engine intelligence lives behind individual flags. The\n# PART IV/V cross-run + concept-graph machinery (concept_pivot, graded_novelty, cross_run_concepts /\n# _advisory / _structured_claims / _curation / _read_tools, fingerprint_universal, foresight_verify)\n# now ships ON in the product `Settings` default. That is an explicit experimental product choice,\n# not a claim that a frozen A/B or workload-wide cost/quality gate has cleared: the bundle can steer\n# prompts and candidate generation, change graded-novelty admission / pre-execution choices, and add\n# paid LLM work when its client and memory prerequisites are present. The bare-library `EngineOptions`\n# default stays lean (see tests/test_options_divergence.py — product side is the aggressive one) so a\n# toy `Engine(...)` in a test doesn't fire cross-run LLM work unasked. The trust/confirm/ablate QUALITY\n# bundle still ships OFF and is turned on by `thorough`. Every value here is reachable by hand — the\n# profile is a convenience, never a hidden mode.\n# keep this rationale behavioral and evidence-based; do not relabel read-only storage\n# access or proposal-only governance as a no-op for agent behavior, cost, or proposal admission.\n#\n# `thorough` deliberately touches only QUALITY/TRUST machinery, not spend: it does NOT raise\n# max_nodes / eval_parallel (those are cost knobs the user owns). It enables multi-seed confirmation\n# (demote seed-lucky leaders), the reward-hack / leakage / critic monitors AND flips them from\n# advisory to *gating* (`trust_gate=\"gate\"` — a\n# flagged win can no longer be selected as best), ablation-driven refinement, and the prompt cues\n# (complexity / budget / failure-reflection / watchdog-reflection). Agentic novelty and the experimental\n# Part IV/V bundle are already product defaults, not profile changes. Effect is large; marginal cost is a\n# few extra evals.\nPROFILES: dict[str, dict] = {\n \"default\": {}, # explicit no-op alias for \"ship-as-is\" (== omitting profile)\n \"fast\": {}, # today's lean defaults, named for symmetry with `thorough`\n \"thorough\": {\n \"confirm_top_k\": 3,\n \"confirm_seeds\": 3,\n # novelty_gate intentionally NOT enabled here: duplicate-detection is the agentic Researcher's\n # job (it reads past experiments + find_analogous_across_runs and DECIDES), not an algorithmic\n # param-distance / embedding auto-reject that mis-fired (it nudged a good fresh idea onto a\n # twice-dead lineage — live node 61). Search stays as tools; the LLM judges novelty.\n \"operator_bandit\": True, # P4: adaptive operator mix over folded yields (GreedyTree)\n \"reward_hack_detect\": True,\n \"code_leakage_detect\": True,\n \"critic_check\": True,\n \"trust_gate\": \"gate\", # detectors stop gating a flagged node from WINNING (not just audit)\n \"ablate_every\": 3,\n \"complexity_cue\": True,\n \"budget_aware\": True,\n \"failure_reflection\": True,\n \"watchdog_reflection\": True, # feed recent live-watchdog (train-monitor/ASHA) flags to proposals\n \"reflection_priors\": True, # no-op unless memory_dir is set (cross-run priors)\n },\n}\n\n\n# The shipped agent-governance matrix (who may change which setting at runtime). Defined as a module\n# constant so the Engine's direct-construction default matches this shipped default exactly (the\n# EngineOptions \"Engine() == shipped defaults\" invariant) instead of diverging to an all-locked map.\nDEFAULT_AGENT_CONTROL: dict[str, list[str]] = {\n \"timeout\": [\"researcher\", \"strategist\"],\n # Layer-2 canonical names are the presented/default governance surface. `_agent_may` falls back\n # to an old snapshot's legacy grant only when its canonical counterpart is absent, so removing\n # duplicate defaults preserves resume authority while an explicit canonical revocation still wins.\n \"eval_parallel\": [\"strategist\"],\n \"llm_parallel\": [\"strategist\"],\n # Strategy-only pseudo-setting: the closed per-lane allocation inside the canonical LLM total.\n # It is not a launch Settings field; operators pin it through `set_strategy` and the Strategist\n # may reallocate it only while this grant is present.\n \"llm_lane_limits\": [\"strategist\"],\n \"max_nodes\": [\"strategist\", \"boss\"],\n \"max_eval_seconds\": [\"strategist\", \"boss\"],\n \"policy\": [\"strategist\"],\n # The policy NAME and its PARAMS are gated independently in `_apply_strategy`; grant both by default\n # so the Strategist that may switch to (say) MCTS may also apply the `c`/`eta` it decided — else its\n # tuned params are silently dropped and the recorded `active_strategy` diverges from the live engine.\n \"policy_params\": [\"strategist\"],\n \"n_seeds\": [\"strategist\"],\n \"ablate_every\": [\"strategist\"],\n \"merge_mode\": [\"strategist\"],\n \"complexity_cue\": [\"strategist\"],\n \"ablate_code_blocks\": [\"strategist\"],\n \"prefer_sweep\": [\"strategist\"],\n \"novelty_stance\": [\"strategist\"],\n \"developer\": [\"strategist\"],\n \"fidelity\": [\"strategist\", \"researcher\"],\n}\n\n\ndef default_agent_control() -> dict[str, list[str]]:\n \"\"\"A FRESH deep-ish copy of the shipped governance matrix (each role list copied so a caller can\n mutate its own map without touching the module constant). The SINGLE constructor of the default,\n called by both `Settings.agent_control`'s default_factory AND the Engine's direct-construction\n default — so \"Engine() == shipped defaults\" can't drift if the copy depth ever needs to change\n (e.g. nested values requiring copy.deepcopy), instead of two verbatim copy expressions.\"\"\"\n return {k: list(v) for k, v in DEFAULT_AGENT_CONTROL.items()}\n\n\nclass Settings(BaseSettings):\n \"\"\"The engine settings schema (every knob a run accepts).\n\n Timeout family — six distinct knobs, each owned by a different subsystem:\n - `timeout`: per-eval wall-clock budget for ONE experiment's evaluation (engine/eval).\n - `max_eval_timeout`: hard ceiling for a governed per-node Researcher timeout override.\n - `llm_timeout`: LLM request idle timeout — inter-token stall limit in stream mode\n (core.llm OpenAICompatibleClient).\n - `llm_header_timeout`: LLM first-byte (response-headers) window for stream attempts (core.llm).\n - `agent_time_budget_s`: wall-clock ceiling across a tool-using agent loop's turns (agents).\n - `dep_install_timeout`: per-package budget for auto-installing a missing dep (env self-prep).\n\n Config-source precedence is owned by `looplab.core.appconfig` (the loader) — see its module\n docstring for the one canonical order.\n \"\"\"\n # Config precedence: see appconfig.py — the loader owns the full order. The `.env` is read\n # so `looplab run`/`looplab ui` pick up LOOPLAB_LLM_BASE_URL etc. without exporting them by hand\n # (keys MUST carry the LOOPLAB_ prefix, same as the env vars). utf-8-sig tolerates a BOM from\n # Windows editors. The test suite disables this (tests/conftest.py) so a dev's real .env in the\n # repo root can't leak into assertions.\n model_config = SettingsConfigDict(\n env_prefix=\"LOOPLAB_\",\n env_file=\".env\",\n env_file_encoding=\"utf-8-sig\",\n extra=\"ignore\",\n allow_inf_nan=False,\n )\n\n @classmethod\n def settings_customise_sources(\n cls, settings_cls, init_settings, env_settings, dotenv_settings,\n file_secret_settings):\n \"\"\"Preserve source precedence across canonical/legacy parallelism names.\n\n Pydantic ranks init > env > dotenv correctly, but it ranks field names rather than semantic\n alias families. Promoting a legacy value inside each source before the merge makes an old\n explicit snapshot/file value beat a lower-priority canonical env value. ``appconfig`` also\n promotes its file/typed/--set sub-layers before combining them into the init source.\n \"\"\"\n def _with_parallel_aliases(source):\n return lambda: canonicalize_parallelism_source(source())\n\n return (\n _with_parallel_aliases(init_settings),\n _with_parallel_aliases(env_settings),\n _with_parallel_aliases(dotenv_settings),\n _with_parallel_aliases(file_secret_settings),\n )\n\n # Run profile (config-first preset, see PROFILES above). \"default\"/\"fast\" = today's lean\n # defaults; \"thorough\" turns the built quality/trust machinery ON in one word. A profile only\n # fills fields the user did NOT set — every knob it touches stays individually overridable.\n profile: str = \"default\"\n\n # Bounds (review C/⚪): a non-positive node/seed budget or timeout is never valid. Upper bounds are\n # equally load-bearing — the UI /start path writes these straight into the engine's env, and an\n # unbounded `eval_parallel` (or legacy `max_parallel`; e.g. 100000 from a crafted preflight) would\n # fan out that many sandboxes → resource exhaustion. Reject at config time, not mid-run. Ceilings\n # are generous; tests top out near 50.\n # CODEX AGENT: Pydantic coerces JSON booleans into these numeric fields (`true` -> 1/1.0), so a UI\n # type error can silently collapse run/eval budgets instead of returning 422. Apply shared\n # before-validation that rejects bool at every numeric settings boundary.\n n_seeds: int = Field(default=3, ge=1, le=1024)\n max_nodes: int = Field(default=8, ge=1, le=1_000_000)\n # legacy evaluation-width input. `eval_parallel` below is canonical and wins when set;\n # keep this field for old env/config/snapshots and direct Engine call sites. A width > 1 admits\n # concurrent evals, whose resource reservations may be CPU-only or span one/more GPUs — there is no\n # one-node/one-GPU promise. `max_parallel=0` is launch-time AUTO (detected GPU count, at least 1).\n max_parallel: int = Field(default=1, ge=0, le=1024)\n # legacy build-width input. `llm_parallel` below is canonical and also controls the\n # shared provider-call total when positive. A width > 1 uses fresh pooled researcher/developer pairs;\n # it says nothing about the later eval's CPU/GPU reservation. Startup `0` derives build fan-out from\n # resolved eval width; a live Strategist/operator zero settles to 1. Without role_factory it clamps to 1.\n parallel_build: int = Field(default=1, ge=0, le=64)\n # Hypothesis-card Kanban re-architecture (docs/23, Layer 2). Two INDEPENDENT concurrency axes,\n # decoupled from each other so cost (LLM work) can be tuned separately from throughput (GPU evals).\n # These are the CANONICAL names going forward; `max_parallel`/`parallel_build` above are kept as thin\n # back-compat aliases (env vars / snapshots / ~100 Engine(...) call sites depend on the exact names,\n # so they cannot be renamed — CLAUDE.md). Resolution (orchestrator.__init__): a NEW name, when set,\n # WINS over its legacy alias; None => fall back to the legacy field => byte-identical to today.\n # `eval_parallel`: concurrent EVALS (the GPU/experiment consumer). None -> max_parallel; 0 = AUTO\n # (one experiment per detected GPU). This is the in-Run eval-concurrency ceiling; the resource\n # scheduler bin-packs declared footprints separately. Sibling local GPU-owning Runs serialize\n # through the conservative host-pool lease; external execution needs its own admission boundary.\n # `llm_parallel`: total concurrent LLM provider calls + BUILD fan-out. None -> legacy\n # parallel_build semantics without a shared total; 0 = startup AUTO (= resolved eval_parallel\n # for build fan-out, historical research overlap unbounded). A positive canonical value enables\n # the named-lane broker the Strategist can reallocate at cadence.\n eval_parallel: int | None = Field(default=None, ge=0, le=1024)\n llm_parallel: int | None = Field(default=None, ge=0, le=64)\n # Training-log monitor (I-series watchdog family): a per-eval background observer that tails the live\n # training log while a (often multi-hour) declared stage runs in a worker thread. ON in the product\n # surface (Settings). Its alert event is fold-ignored and cannot directly change lifecycle/champion;\n # with watchdog_reflection on, that alert is nevertheless advice in a later Researcher\n # prompt and can change future proposals. It only fires on the command-eval path with an LLM client;\n # bare-library EngineOptions stays OFF. See test_options_divergence.\n # `_interval_s` is the BASE tick cadence (the effective one adapts to the per-experiment budget).\n train_monitor: bool = True\n train_monitor_interval_s: float = Field(default=600.0, gt=0)\n # Phase 3 — INTERVENTION (opt-in, separate from observation): let the monitor tree-kill a training the\n # LLM judges 'broken' (diverged / silent CPU fallback / not learning) EARLY, instead of burning the\n # whole budget. Off by default — the observer only WATCHES unless this is on. A kill only fires on a\n # 'broken' verdict (a plateau is 'watch', never killed) with confidence >= the threshold; the node then\n # fails normally (reason='monitor_broken'), so replay reconstructs it from that one terminal event.\n train_monitor_kill: bool = False\n train_monitor_kill_confidence: float = Field(default=0.8, ge=0.0, le=1.0)\n # ASHA live-curve watchdog (sibling of the training monitor): reads the latest INTERMEDIATE value of\n # the objective metric off the live log (reusing the eval's OWN metric reader). Finished-endpoint rank\n # remains advisory. An opt-in KILL additionally requires an operator-declared metric.resource_key and\n # enough sibling observations at exactly that resource; absent comparable evidence cannot stop a run.\n # Records a fold-ignored `asha_rank` diagnostic + trace span; the library default is OFF (off == today).\n asha_live: bool = True\n # Opt-in INTERVENTION (separate from the advisory signal): let the watchdog tree-kill a node whose\n # intermediate metric stays below the SAME-RESOURCE bar past a short grace window. Off by default —\n # it only surfaces endpoint rank unless this is on. A kill fails normally (asha_underperforming).\n asha_live_kill: bool = False\n # The bar sits at `asha_live_quantile` along a WORST->BEST ordering of the finished siblings' finals:\n # 0.5 = the median (a node worse than the median peer flags); a SMALLER value is more conservative —\n # it lowers the bar toward the WORST finished peer, so only a node worse than nearly all peers flags\n # (0.0 = worse than the worst). `min_siblings` is the floor of finished peers required before it ranks\n # at all, so it never acts on too little evidence.\n asha_live_quantile: float = Field(default=0.5, ge=0.0, le=1.0)\n asha_live_min_siblings: int = Field(default=3, ge=1)\n timeout: float = Field(default=30.0, gt=0)\n # Hard ceiling for the Researcher's governed per-node `Idea.eval_timeout`. One hour preserves the\n # existing heavy/neural-net authoring example while staying well below the sandbox's defensive\n # 24-hour subprocess ceiling. This is operator-owned run config: agents may request less or more,\n # but the accepted action is always clamped here after the `agent_control.timeout` permission gate.\n max_eval_timeout: float = Field(default=3600.0, gt=0, le=24 * 3600.0)\n # Intra-node sweep: a sweep node runs a whole grid in one process, so it gets this multiple of\n # `timeout` as its wall-clock budget (solution.py path; RepoTasks use their per-profile timeout).\n sweep_timeout_mult: float = Field(default=8.0, ge=1)\n # Eval STALL watchdog CAP (seconds): a stage that is completely SILENT on stdout/stderr for this\n # long — while still alive and BELOW its wall-clock deadline — is tree-killed early with a STALLED\n # marker (a hung dataloader/deadlock dies in minutes instead of burning a multi-hour timeout). The\n # per-stage window is `min(this, the stage's own timeout)`, so a short scorer's deadline fires first.\n # `0` DISABLES the watchdog entirely (only the hard deadline applies) — for a legitimately quiet\n # non-Python stage (block-buffered stdout, a script that logs only to its own file). Threaded into\n # the eval and surfaced to the Developer so its code can emit periodic progress to stay alive.\n eval_stall_timeout_s: float = Field(default=1800.0, ge=0)\n # Sandbox tier (ADR-13): \"trusted_local\" (subprocess, no Docker) for the CLI;\n # \"untrusted\" (Docker --network none, shared-kernel runtime) for hosted/multi-tenant UI;\n # \"hostile\" (untrusted + a true-isolation OCI runtime, gVisor `runsc` by default / Kata) for\n # running untrusted code. See runtime/sandbox.py::make_sandbox for the tier selection.\n trust_mode: str = \"trusted_local\"\n # Image for the untrusted command-eval tier (RepoTask, Phase 4): the framework's deps\n # should be baked in (the container runs --network none, so a pip setup can't fetch).\n docker_image: str = \"python:3.12-slim\"\n # Resource caps for the untrusted/hostile Docker tier (passed to `docker run --memory/--cpus`).\n # The default 4g bound protects other tenants from an OOM, but a model-training MLE-bench eval\n # routinely needs more — raise it (e.g. \"16g\") for such runs. \"\" disables the cap (unbounded).\n # Ignored by the trusted_local (subprocess) tier.\n sandbox_memory: str = \"4g\"\n sandbox_cpus: str = \"\"\n # Best-effort host-OOM guard for the TRUSTED-LOCAL (subprocess) tier. When set (e.g. \"8g\"), each\n # eval child gets an RLIMIT_AS address-space cap so a runaway trainer hits MemoryError instead of\n # OOM-killing the whole host (and the engine). POSIX only; a no-op on Windows. Default \"\" = OFF:\n # RLIMIT_AS bounds VIRTUAL memory, and CUDA/torch reserve tens of GB of virtual without using it,\n # so a default cap would spuriously kill GPU evals — leave it off for those and use the Docker\n # tier's real --memory cgroup bound (sandbox_memory) instead. Ideal for CPU/numpy/sklearn runs.\n sandbox_memory_local: str = \"\"\n # Best-effort disk-fill guard for the TRUSTED-LOCAL tier (P1-5): an RLIMIT_FSIZE cap on the size of\n # any single file an eval child writes (e.g. \"2g\"), so a runaway that writes an unbounded file gets\n # SIGXFSZ instead of filling the host disk. POSIX only. Default \"\" = OFF (large model checkpoints\n # need big files); set it for tasks that write only small artifacts.\n sandbox_fsize_local: str = \"\"\n # Search policy (ADR-2): \"greedy\" | \"evolutionary\" | \"mcts\" | \"asha\".\n policy: str = \"greedy\"\n # Ablation-driven refinement (I7): every N improves, ablate the best to find the\n # highest-impact parameter and refine it. 0 = off. (GreedyTree only.)\n ablate_every: int = 0\n # A0a (MLE-STAR): when ablating, treat each generated pipeline *code block* (not just a\n # numeric param) as an ablation unit — neutralize a block, measure the metric delta, refine\n # the highest-impact block. Off => the classic numeric-param ablation. Config-first knob.\n ablate_code_blocks: bool = False\n # A0b: real merge/ensembling. \"mean\" = legacy mean-param merge; \"ensemble\" = the Developer\n # writes a code-recombination ensemble over the two parents' solutions (verified: agent-proposed\n # ensembling 37.9%->43.9%). \"auto\" (default) = ensemble whenever the Developer actually\n # generates code (LLM/agent backends; declared via `is_code_generating`), mean for the\n # templated/toy developers where a code ensemble is meaningless — the evidence says code\n # recombination is the strongest single merge (removing it costs ~9 pp), so it is the default\n # wherever it can work. Falls back to mean when the Developer can't ensemble.\n merge_mode: str = \"auto\"\n # A0d (AIRA): inject a dynamic complexity hint into the draft/improve prompt keyed on the\n # node's child count (few children -> keep minimal; many -> escalate to ensembling/HPO).\n complexity_cue: bool = False\n # I1 (CAAFE-style) LLM feature-engineering: instruct the proposer/developer to add\n # semantically-meaningful engineered features (ratios, interactions, aggregations) as code. The\n # existing cross-validation eval + best-selection is the CV GATE — a feature set that doesn't\n # improve CV is never selected (feature engineering is non-universal, so this gate is mandatory).\n # Tabular tasks (those exposing column data). Off by default.\n feature_engineering: bool = False\n # A5: surface the REMAINING eval-compute budget into the proposal prompt so the Researcher can\n # reason over how much compute is left (explore broad while flush, exploit/cheapen when low).\n # No-op unless a max_eval_seconds budget is set.\n budget_aware: bool = False\n # A4 (LATS-style): feed a summary of the most recent FAILED branches (operator + error reason)\n # back into the proposal prompt so the proposer reflects on and avoids repeating them. ON by\n # default: it is SELECTIVE by construction (injects only when recent failures exist — the\n # evidenced form of reflection; reflecting at every step shows no gains, arXiv:2506.12928).\n failure_reflection: bool = True\n # Feed the LIVE-WATCHDOG observations (train-monitor health verdicts + ASHA intermediate-rank flags)\n # of recent experiments back into the proposal prompt, so the Researcher avoids re-proposing a config\n # whose TRAINING was already observed to be weak — even when the watchdog kills are off (default) and\n # the node ran to completion (those diagnostics are fold-ignored, so failure_reflection never sees\n # them). ON by default and SELECTIVE like failure_reflection (injects only when a recent flag exists).\n watchdog_reflection: bool = True\n # C3 deep test-driven repair: hand the Developer the failure taxonomy + a structured \"reproduce\n # then fix\" directive on debug, not just the raw stderr tail. ON by default (product surface); the\n # conservative library default in EngineOptions stays off, per that module's contract.\n deep_repair: bool = True\n # === Inline crash repair ==============================================================\n # Hybrid in-node crash repair: when an LLM-generated node CRASHES at runtime (mechanical errors\n # — bad import, removed kwarg, typo), the agent triages it and may repair the code IN PLACE within\n # the same eval (cheap; does NOT consume max_nodes and does NOT add a node to the search tree).\n # Semantic failures still flow to a new idea/debug node via the policy. ON by default; set False\n # to restore the prior behavior (every crash waits for a budgeted inter-node debug node).\n inline_repair: bool = True\n # Max in-place repair attempts per node before it fails normally. Default 0 = UNLIMITED: the same\n # agent keeps fixing + re-evaluating IN THE SAME node until it produces a clean metric — no new\n # node just to retry. Runaway is prevented not by a count but by the anti-stuck guard\n # (`inline_repair_stuck_repeat`): when the SAME error signature repeats with no progress, or the\n # agent's crash-triage says \"abandon\", the node fails. Set a positive N to cap the retries instead.\n inline_repair_attempts: int = Field(default=0, ge=0)\n # Anti-stuck: abandon in-node repair when the SAME error recurs this many times in a row (no\n # progress). Keeps \"unlimited\" repair from looping forever on an unfixable error.\n inline_repair_stuck_repeat: int = Field(default=4, ge=2)\n # Which failure reasons (_failure_reason: crash|timeout|oom|setup|no_metric|drift) are eligible for\n # inline repair. Default: mechanical crashes, timeouts AND OOM-kills — a timeout/OOM means the code\n # was too slow / too memory-hungry for the budget (or the pod's cgroup limit), not that the idea is\n # wrong, so the agent repairs it by reducing compute/memory (fewer estimators/epochs/folds/seeds, a\n # lighter model, a smaller batch, subsampling) instead of letting the node die unrecovered. Env\n # override expects a JSON array.\n inline_repair_reasons: tuple[str, ...] = (\"crash\", \"timeout\", \"oom\")\n # Cost ceiling for in-node repair of a MULTI-STAGE eval: the max number of FULL pipeline re-runs\n # (i.e. re-trains from the first stage) the inline-repair loop may do before abandoning to the\n # inter-node debug operator. A late-stage failure (a broken `score`/eval script that didn't touch\n # `train`) REUSES the completed train checkpoint and re-runs only from the failed stage — that is\n # CHEAP and NOT counted here; only a repair that changes an EARLIER stage's code (train.py/loss.py/…)\n # forces a full re-train, which each costs the whole training time. Without this bound, a repair that\n # keeps changing training code could burn many full trains (the anti-stuck guard is error-signature\n # based, not cost based). 0 = unlimited (legacy behavior). Single-command evals ignore it.\n inline_repair_retrain_cap: int = Field(default=2, ge=0)\n # Environment self-prep: when a solution crashes purely because a KNOWN library isn't installed\n # (ModuleNotFoundError), the engine pip-installs it into the eval interpreter and re-runs — so a\n # torch/XGBoost/CatBoost idea (e.g. a GRU model) actually runs on a fresh box instead of being\n # thrown away as `idea_rejected`. Trusted_local tier only (the untrusted/hostile Docker tiers run\n # --network none). A typo'd or local helper module is NOT installed (that's a code bug to repair).\n auto_install_deps: bool = True\n # Per-package wall-clock budget for an auto-install (seconds). Generous: large wheels like torch\n # can take minutes to download/build.\n dep_install_timeout: float = Field(default=900.0, gt=0)\n # --- Agent governance: who may change a setting at runtime (per-setting allow-list of roles) ---\n # Roles: \"strategist\" (A7 meta-controller; run-wide), \"boss\" (run-chat operator-proxy; run-wide),\n # \"researcher\" (per-experiment proposer; PER-NODE — e.g. set a longer eval timeout for a neural-net\n # idea). A setting ABSENT from this map is LOCKED — no agent may change it, only a human via the\n # snapshot/UI. The sole conditional exception is Strategy-only `card_scoring`: Card mode grants\n # it implicitly without changing the default flag-off snapshot, while an explicit [] revokes it.\n # The gate is ENFORCED at every AGENT seam via `_agent_may` — the strategist's whole\n # control surface (policy/operators/novelty_stance/prefer_sweep/developer/fidelity/timeout/\n # eval_parallel/llm_parallel) is checked in `_apply_strategy`, so removing a role from a knob locks it,\n # not just greys out the UI pill. The boss/max_* grants document boss-relevant knobs, but a\n # `budget_extend` is a HUMAN control intent (the boss action-builder can only emit `add_nodes`), so\n # it is applied as-is — the human always wins via the UI/snapshot. Defaults are conservative: resource/\n # search-shape knobs the agents already reason about; provider infra (llm_model/base_url/api_key,\n # docker_image) stays locked. The UI shows S/B/R pills per setting. `timeout` granted to the\n # researcher IS the \"auto\" per-node mode — the researcher sizes each experiment's wall-clock\n # (Idea.eval_timeout); the config `timeout` is the fallback for nodes it doesn't size. Env override\n # expects a JSON object. (`fidelity`/`novelty_stance`/`prefer_sweep`/`developer` are governance keys\n # for the strategist's per-node dials — not 1:1 Settings fields, but gated the same way.) The default\n # lives in the module-level DEFAULT_AGENT_CONTROL so the Engine's direct-construction default matches.\n agent_control: dict[str, list[str]] = Field(default_factory=default_agent_control)\n # C1 (Agentless localization): for repo tasks, rank the source files most relevant to the most\n # recent failure (traceback + identifiers) and surface them in the proposal/repair prompt so the\n # Developer edits the right place. Off by default; repo tasks only.\n localize_faults: bool = False\n # A2 surrogate-guided proposal (BO-lite): fit a k-NN surrogate over (params->metric) and propose\n # by acquisition instead of random/hill-climb. Numeric-bounds tasks only; bootstraps via the\n # base Researcher. Off by default. `surrogate_explore` = UCB-style exploration weight.\n surrogate_proposer: bool = False\n surrogate_explore: float = 0.1\n # E2 researcher panel: generate K candidate ideas per proposal and keep the one ranked best by a\n # cheap empirical surrogate over the (params->metric) history (NOT an LLM-judge). 1 = off.\n researcher_panel: int = 1\n # A1 ASHA / successive-halving: reduction factor (keep top 1/eta per rung) and rung budget.\n asha_eta: int = Field(default=3, ge=2)\n # Rung-0 width for ASHA/BOHB (the wide base of cheap drafts). 0 = use n_seeds (default,\n # preserving prior behavior); set >0 to seed a wider/narrower base independent of n_seeds.\n asha_rung_nodes: int = Field(default=0, ge=0)\n # A6 proxy/predictive scoring: cheaply rank a candidate's potential from early-stage signals\n # and skip a full eval for the doomed bottom fraction. 0.0 = off (no candidate skipped).\n proxy_scoring: bool = False\n proxy_kill_fraction: float = Field(default=0.0, ge=0.0, le=0.9)\n # === Novelty / dedup ==================================================================\n # E1 novelty/dedup gate: reject near-duplicate proposals (normalized param-space distance below\n # `novelty_epsilon`) by deterministically nudging them off the duplicate — stops the search\n # wasting evals re-trying the same idea. Audit event `novelty_rejected`. Off by default.\n # Novelty/dedup MODE selector: how a fresh proposal is checked against already-tried experiments.\n # \"off\" — no explicit gate; the agentic Researcher's own read-the-history judgment is trusted.\n # \"algo\" — the deterministic gate: numeric param-distance (`novelty_epsilon`) + optional embedding\n # similarity (`novelty_semantic`). Cheap, no LLM call, but can't explain itself.\n # \"llm\" — an LLM adjudicates (reads the real experiments via tools) whether the idea is a\n # near-duplicate and, if so, asks the Researcher once more for something different. One\n # extra LLM call per proposal — highest quality, follows the \"let the LLM decide\" line.\n novelty_mode: str = \"llm\"\n # Legacy sub-toggles, honored by the \"algo\" mode (and back-compat: novelty_gate=True forces \"algo\"):\n novelty_gate: bool = False\n novelty_epsilon: float = 0.05\n # T5 semantic novelty (active whenever the deterministic gate runs — novelty_mode=algo,\n # novelty_gate=true which is the legacy alias for algo, OR the Strategist novelty stance is\n # \"explore\"; a no-op only under novelty_mode=llm/off with a non-explore stance): reject a proposal\n # whose idea TEXT embeds within\n # `novelty_semantic_threshold` cosine of an existing node's. OFF BY DEFAULT: novelty is the agentic\n # Researcher's call, made by READING the actual prior experiments (read_experiment /\n # find_analogous_across_runs / list_experiments), NOT an embedding-similarity auto-reject that can't\n # explain itself and mis-fires on paraphrases. Semantic/param search stays available as TOOLS in the\n # Researcher's context; the LLM decides whether an idea is a true duplicate.\n novelty_semantic: bool = False\n novelty_semantic_threshold: float = Field(default=0.92, ge=0.5, le=1.0)\n # T10: debug-lineage depth bound for every policy — how many error-feedback repairs a failing\n # lineage gets before it is abandoned. The old default (1) abandoned lineages after ONE repair;\n # multi-turn/deeper debugging is a verified lever (AIRA2 ReAct debug +5.5 percentile points).\n debug_depth: int = Field(default=2, ge=1)\n # P4 operator bandit (GreedyTree): replace the fixed merge/ablate cadences with a\n # deterministic UCB over per-operator yield (Δmetric per eval-second, folded from the run).\n # Off by default (the cadences are well-tested and the bandit has no direct published\n # ablation); `thorough` turns it on.\n operator_bandit: bool = False\n # P1 hypothesis ledger: ask the Researcher to state the one-line hypothesis each experiment tests,\n # register deep-research directions as hypotheses, and track them to a verdict on the board. ON by\n # default. The board is shown to the Researcher and can be foresight-ordered, so it can\n # steer later proposals even though it never directly re-ranks already-evaluated nodes. Set False\n # to drop the prompt nudge + registration.\n track_hypotheses: bool = True\n # E4/M2/M3 reflection-memory -> priors (gradient-free cross-run meta-learning): at run end distill\n # the winner into `<memory_dir>/meta_notes.jsonl` AND structured lessons (incl. NEGATIVE results:\n # tested/abandoned hypotheses + failure themes) into `lessons.jsonl` with a task fingerprint; at run\n # start, inject exact-task notes + fingerprint-matched lessons from SIMILAR tasks into the proposal\n # prompt. ON by default — but a NO-OP until `memory_dir` is set (that's where cross-run memory lives).\n reflection_priors: bool = True\n # M6 comparative lessons (MARS \"comparative reflective memory\", doc 13 §7 item 2): distill\n # credit-assigned lessons from PAIRS of solutions — which SPECIFIC change made a child beat\n # (or regress from) its parent, and what fixed a failure — on top of the one-shot run-end\n # reflection. At most one extra LLM call per distillation (all pairs batched); offline a\n # deterministic param-diff credit stands in. Gated on reflection_priors + memory_dir like the\n # rest of cross-run memory — and since memory_dir has a default (~/.looplab/memory), this is\n # ACTIVE out of the box; clear memory_dir to disable cross-run memory wholesale.\n comparative_lessons: bool = True\n # M6 mid-run cadences (doc 13 §7 item 5 — the AgentRxiv live-share pattern): every N created\n # nodes, WRITE comparative lessons to the shared cross-run store during the run (lessons_every\n # — one batched LLM call per firing) and RE-READ the store so lessons distilled by CONCURRENT\n # runs reach this run's proposals (lessons_refresh_every; file re-read, no LLM call, skipped\n # when the store is unchanged). ON by default (every 4 nodes) and, like comparative_lessons\n # above, live out of the box because memory_dir defaults on; 0 = off — run-end write /\n # run-start read only, the pre-M6 behavior.\n lessons_every: int = Field(default=4, ge=0)\n lessons_refresh_every: int = Field(default=4, ge=0)\n # B3 output redaction: mask credentials (known key shapes + high-entropy tokens) in the\n # stdout/stderr tail before it is persisted to the event log / spans / UI — a leaked secret in a\n # print()/traceback must not land in the durable log. Off by default; recommend on for untrusted.\n redact_output: bool = False\n # B5 reward-hacking detector: a host-side monitor that flags suspicious wins (grader/answer-key\n # access, runtime writes to frozen files, suspiciously-perfect metrics) as a `reward_hack_suspected`\n # audit event in the Trust panel. Off by default. Whether a flag CHANGES selection is governed by\n # `trust_gate` below (default: audit-only, never changes selection).\n reward_hack_detect: bool = False\n # Trust enforcement (T2): what a reward-hack / data-leakage flag actually DOES to selection.\n # \"audit\" (default) = surface it in the Trust panel, never change selection (today's behavior);\n # \"gate\" = a flagged node can no longer be selected as BEST **and isn't bred/confirmed from**\n # (breed_excluded — see replay._apply_trust_gate / models.breedable_nodes), but stays\n # FEASIBLE so it still counts for diversity/audit — closes the \"a hacked/leaky node can\n # win OR seed its lineage\" hole;\n # \"block\" = additionally mark it fully INFEASIBLE (removed from feasible_nodes entirely).\n # Deliberately gates only high-precision CHEATING/LEAKAGE signals. Broad `critic:*` findings stay\n # advisory; `critic:hardcoded_metric` is the one narrow high-precision critic exception\n # and gates because it requires a literal metric with no computed assignment anywhere.\n trust_gate: str = \"audit\"\n # I3 data-centric: static code-leakage scan of each evaluated solution (fit-before-split,\n # fit-on-test) surfaced into the Trust panel alongside reward-hack flags. Off by default.\n code_leakage_detect: bool = False\n # 4.4 sandbox instrumentation (needs reward_hack_detect): after each eval, flag a RUNTIME write\n # to a protected/frozen file (an answer key or grader tampered mid-run) — behavioral evidence a\n # static code scan misses. ON by default; only fires when the reward-hack detector is on.\n workdir_audit: bool = True\n # D8 decoupled research Verifier: check every Deep-Research memo's CLAIMS against their cited\n # evidence (node ids / urls) before recording — synthesis is the documented weak link (Kosmos\n # 57.9% accurate). Deterministic layer always; an LLM rubric pass when the DeepResearcher has a\n # client. The verdict cannot change this run's node/champion, but finalize uses aligned supported\n # verdicts as the evidence gate for persisted D8 cross-run claims; unavailable/misaligned stays\n # unverified. ON by default (no-op with no memo/claims).\n research_verify: bool = True\n # C4 independent critic: an execution-free critic of each solution (stub / hardcoded-metric /\n # params-ignored; on host-graded tasks the metric checks become a submission-output check)\n # surfaced in the Trust panel. Broad findings are advisory; `critic:hardcoded_metric` can gate under\n # trust_gate=gate/block. Off by default.\n critic_check: bool = False\n # A7 Strategist (NEW, user-requested): optional LLM/rule meta-controller that picks the search\n # policy/allocator + operator mix + fidelity (+ Developer backend) per situation. Config-first:\n # every choice the Strategist makes is also a direct knob. Defaults to \"agent\" so the agent adapts\n # the search policy/operators/fidelity per situation; set \"off\" for static config-driven search.\n # \"agent\" = a tool-using Strategist that READS the run/data/sibling-runs/KB/memory (B1-guarded)\n # before deciding, instead of the single-shot \"llm\" call over aggregate stats.\n strategist_backend: str = \"agent\" # \"off\" | \"rule\" | \"llm\" | \"agent\" — default AGENTIC: the\n # Strategist READS the run/data/sibling-runs/KB/memory with tools before deciding, not a single-shot\n # call over aggregate stats. \"llm\" = the old non-agentic single-shot; \"rule\"/\"off\" = no LLM.\n strategist_every: int = Field(default=3, ge=1) # consult cadence (created nodes)\n # PART V (F1): concept CLASSIFIER re-tag + consolidation cadence, DECOUPLED from strategist_every. The\n # LLM concept map is heavier and slower-moving than a strategy consult, so it refreshes on its own\n # (sparser) interval. Researcher-authored idea.concepts still fold immediately at node_created — this\n # only paces the classifier-evidence + consolidation refresh (and the concept-coverage pivot snapshot).\n concept_retag_every: int = Field(default=30, ge=1)\n # === Confirmation & holdout ===========================================================\n # Multi-seed confirmation (I12, ADR-15): confirm the top-k under N seeds before\n # finishing. 0 disables (default). Only meaningful when eval has variance.\n confirm_top_k: int = 0\n confirm_seeds: int = 0\n # D1 seed-holdout discipline: the FIRST seed the confirm phase uses. Search evals run with the\n # implicit seed 0 (LOOPLAB_EVAL_SEED unset -> \"0\"), so a base of 1 keeps every confirm split\n # DISJOINT from anything the search optimized against — the confirm metric is then a\n # generalization signal, not a re-measurement (AIRA: selecting on a signal the search saw\n # overfits by 9-13 pp). Set 0 to restore the legacy overlapping seeds 0..N-1.\n confirm_seed_base: int = Field(default=1, ge=0)\n # D1 holdout-gated promotion (B6, Arbor-style): for host-graded tasks, reserve this fraction of\n # the held-out labels as a FINAL holdout partition the search never sees — every search/confirm\n # eval is scored on the remaining rows only, and at finish the val-top-k are re-scored on the\n # holdout partition (free: the predictions already exist). 0.0 = off (legacy full-label scoring).\n holdout_fraction: float = Field(default=0.25, ge=0.0, le=0.9)\n # Champion selection prefers the HOLDOUT metric among the nodes that have one (the val-top-k).\n # Recorded in run_started so replay applies the same rule; old logs fold to False (legacy pick).\n # The per-node val-holdout `generalization_gap` folds into the Trust panel either way.\n holdout_select: bool = True\n # How many val-leaders get a holdout evaluation at finish (host-graded tasks).\n holdout_top_k: int = Field(default=3, ge=1)\n # R1-c / Part IV — calibrated §12-verifier tie-break in best-selection. When a fresh node's robust\n # metric EXACTLY TIES an already-eligible node, the engine runs the §12 verifier (grounded on each\n # node's realized result, `selection_criteria`) and records a soundness score per node. The tie-break\n # then applies across the WHOLE selection path: the mean pick breaks a robust_metric tie by the score,\n # and — when `holdout_select` is on — the holdout-slot ranking prefers the sounder node so it isn't\n # denied a holdout eval, and the final holdout override breaks a holdout_metric tie by it too. Strictly\n # a TIE-BREAK — it can NEVER move a node ahead of a strictly-better metric (§21.7 advisory-never-\n # overrides). Best-effort: verification is LAZY (only fires on an actual tie) and bounded per cadence,\n # so a large/late tie stays on deterministic metric+id order unless one atomic treatment scores every\n # member. Recorded in run_started so\n # replay applies the same rule; old logs fold to False (legacy pick). Opt-in (default off); needs a\n # reachable LLM client. Enable only after validating calibration offline with `verifier.calibrate`.\n # See trust/verifier.py + core/fitness.py.\n select_verifier: bool = False\n # R1-d (§21.19): widen the verifier tie-break from EXACT-metric to a conservative STATISTICAL tie.\n # The band is capped by both the metric leader's SE and the pooled SE-of-difference, so challenger\n # variance cannot widen it and a significant difference is never a tie (§21.7). Off -> exact only.\n verifier_ci_tie: bool = False\n # Verifier sample count for `select_verifier` (the §12 repeated-sampling expectation). 3 tames the\n # single-shot variance; 1 = single-shot (cheaper, noisier).\n select_verifier_samples: int = Field(default=3, ge=1, le=32)\n # Budget (I13): hard wall-clock ceiling; the run aborts cleanly when exceeded.\n max_seconds: float | None = None\n # Eval-compute budget (#2): hard ceiling on cumulative time spent INSIDE evals (training\n # runs), separate from wall-clock. Survives resume (summed from the event log). The real\n # guard against a silent multi-hour sweep when an eval is a minutes-hours training run.\n max_eval_seconds: float | None = None\n # Cross-run memory (I19, ADR-10): if set, the best result of each run is stored as\n # a case here, and the cases become retrievable knowledge for future runs.\n memory_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"memory\"))\n # HITL (I21, ADR-11): pause for human approval of the final best before finishing.\n require_approval: bool = False\n # Diversity archive (I22): niche bucket width in parameter space.\n archive_resolution: float = 1.0\n # Coverage read-model (narrowing signal): at the strategist cadence, compute a breadth summary\n # (themes / param-niches / theme entropy / dominant-theme fraction) from the folded run, record\n # it as a `coverage_snapshot` audit event, and surface it into the Strategist's decision context.\n # Deterministic, cheap, and purely additive context — no search-behavior change on its own. On by\n # default (like the rest of the always-on situational context). See search/coverage.py.\n coverage_context: bool = True\n # PART IV Phase 2a live steering (§21.11/§21.13). When on, the `concept_retag_every` cadence (NOT\n # `strategist_every` — the producer gates on `_should_consult_concepts`, which uses the seed boundary\n # + `concept_retag_every`, default 30) records a\n # concept-graph coverage + uncovered-region snapshot (deterministic heuristic tagger over the\n # task-type skeleton) and, on an `explore` stance, the Researcher's novelty hint names the exact\n # uncovered regions (\"0 coverage in {negatives/external-mining, distillation} — go there\") instead\n # of the vague \"broaden\". ON by default in the product Settings (ce4a379), EngineOptions off: it\n # only enriches an already-mode-gated explore hint, never forces exploration, and no-ops for a task\n # with no curated concept skeleton. The snapshot is auditable, but its prompt cue can change future\n # candidate generation; it does not directly rewrite best-metric selection. See search/concept_graph.py.\n # DESIGN NOTE (2026-07-17 critique): now ON unconditionally, but a toy / non-ML task yields EMPTY concept\n # tags while still paying the per-node LLM concept cost (~10 calls/node with the full Part IV/V bundle).\n # Consider AUTO-QUIESCING: if the heuristic tagger returns empty for the first N nodes (no concept signal),\n # skip the LLM concept work for the run — keeps it on for ML tasks, free on toy/degenerate ones.\n concept_pivot: bool = True\n # PART IV Phase 2b — D3 graded novelty into the LIVE novelty gate (§21.4/§21.13). When on and the\n # task has a curated concept skeleton, the novelty gate first grades a proposal over the concept\n # graph: a level-4 \"same direction, DIFFERENT implementation\" or a level-5 \"re-opens a\n # wrongly-abandoned FAILED direction\" proposal is ALLOWED through unchanged — the flat LLM/semantic\n # dedup gate can't tell \"this DCL tweak\" from \"the whole DCL branch\" and would wrongly reject a\n # legitimate variant or never re-open a sound-but-killed direction (the node_63 archetype). Levels\n # 1/2/3 (identical / near-dup / prior-run) still fall through to the existing gate. Deterministic\n # (heuristic tagger, no LLM), audit event `novelty_graded`, replay-safe. ON by default in the product\n # Settings (ce4a379), EngineOptions off; no-ops for a task with no skeleton. See search/graded_novelty.py.\n graded_novelty: bool = True\n # PART IV Phase 2b — D7 capability-expansion forced-jump DIRECTIVE (§21.8/§21.13, issue #7). When on\n # and the concept-graph cadence detects action-space LOCK-IN (the search has stayed inside one D5\n # branch for a long consecutive streak) on an `explore` stance, the Researcher's novelty hint\n # ESCALATES from \"broaden\" to \"expand the ACTION SPACE — build the missing infra (external mining,\n # a new data pipeline, a different eval), do NOT swap another {locked} lever.\" The same\n # gate also stamps the proposal's authoritative operator as `expand`; that node competes normally\n # under SearchFitness and its separate operator yield is measured. It does not force a winner.\n # Reads the 2a concept-coverage snapshot, so it needs `concept_pivot` to record one. OPT-IN\n # (default off). See engine/proposal_cues.py + search/lock_in.py.\n capability_expansion: bool = False\n # PART IV cross-run Step 0 (§21.20.12/§21.20.13). Universal task-fingerprint tokenization: drop the\n # ASCII-only `[a-z0-9]` allowlist on goal keywords so a non-Latin goal (Russian, CJK, …) is NOT\n # silently dropped from its cross-run fingerprint (today such a goal keys only on kind/dir/metric and\n # never reaches SIMILAR-task priors/lessons/cases). Uses `[^\\W_]+`/`.casefold()` — same splitting as\n # before, any script. ON by default in the product Settings (ce4a379); the bare-library EngineOptions\n # default stays off, so a run pinned to it won't silently re-key a portfolio mid-flight. See engine/memory.py.\n fingerprint_universal: bool = True\n # PART IV cross-run Step 2 (§21.20). Fill graded-novelty's `prior_concepts` from the cross-run\n # ConceptCapsuleStore: at run end write a per-run concept capsule (the shipped `node_concepts` tags +\n # best outcome, keyed by task_fingerprint) to `memory_dir`; when a later SIMILAR run proposes an idea\n # whose concept was tried before, SURFACE the prior outcome as a `cross_run_prior` audit event (never\n # reject — D3 level 3). Audit-only, off the selection path; ON by default in the product Settings\n # (ce4a379), EngineOptions off. Needs a `memory_dir` to share capsules.\n # See engine/memory.py (ConceptCapsuleStore) + engine/novelty.py.\n # PREREQUISITES (this flag is NOT standalone): the WRITE needs per-run concept tags\n # (`node_concepts`, produced by `concept_pivot`/F1 tagging) — no tags => no capsule; the SURFACE path\n # runs inside the graded-novelty precheck, so it also needs `graded_novelty` on. With only this flag +\n # `memory_dir` it persists/surfaces nothing. Enable `concept_pivot` + `graded_novelty` alongside it.\n # A non-Latin (Russian/CJK) portfolio should ALSO set `fingerprint_universal`, else the capsule's goal\n # tokens are dropped and its fingerprint over-matches on kind/dir/metric alone (the novelty direction\n # gate narrows this, but goal-word similarity is still lost).\n cross_run_concepts: bool = True\n # PART V (B): run-base + node-DELTA concept authoring. When on, once the first evaluated node has\n # authored concepts the engine seeds RunState.run_base_concepts from them (a one-shot EV_RUN_CONCEPTS,\n # idempotent because it only fires while the base is empty), and the Researcher's proposal hint tells it\n # to author only `concepts_added`/`concepts_removed` vs the run base + its parent — minimal per-node\n # annotations that inherit down the DAG (a swap propagates). Off -> every node authors its full concept\n # set as before. Product-on; the bare EngineOptions mirror defaults off (test_options_divergence).\n concept_run_base: bool = True\n # PART IV cross-run Step 5 advisory (§21.20.5). Fold the bounded cross-run CONTEXT PACK — evidence-\n # grounded claims with BOTH support and counter-evidence (Step 4) + a portfolio-coverage line (Step 3) —\n # into the Researcher's proposal prompt, exactly like the E4 cross-run prior note. Advisory ONLY: it is\n # prompt-grounding: it can change the Researcher's later candidates, but does not directly rewrite\n # best-metric selection (§21.7). Reads `memory_dir` (lessons.jsonl + concept_capsules.jsonl); \"\" when\n # empty. ON by explicit experimental product choice; no frozen A/B validation is claimed. The\n # bare-library EngineOptions default stays off (engine/options.py, frozen in\n # test_options_divergence). See engine/proposal_cues.py.\n cross_run_advisory: bool = True\n # PART IV cross-run §21.20.13 (full CR of the lean fuzzy claim merge). Switch the claim read-model to the\n # SCOPE+POLARITY-safe STRUCTURED claim key (engine/claim_key.py): claims from different tasks never\n # merge, opposite polarity (\"X helps\" vs \"X never helps\") is surfaced as a CONTRADICTION instead of being\n # collapsed, paraphrase/inflection variants group by exact structured key (no transitive over-merge), and\n # operator governance is scope-precise (a decision in task A cannot reach a same-worded claim in task B).\n # Affects the `cross_run_advisory` context pack only; ON by default in the product Settings (ce4a379);\n # the bare-library EngineOptions default stays off (engine/options.py). See engine/claims.py.\n cross_run_structured_claims: bool = True\n # PART IV cross-run §22.4 (AGENTIC taxonomy steward). At finalize, when an LLM client is available, let\n # the concept steward (engine/concept_steward.py) review the freshly-updated portfolio concept graph and\n # PROPOSE a curation — merge duplicate slugs / split conflated ones / purge noise. Proposals are LOGGED to\n # `concept_curation_log.jsonl` for operator ratification (via the serve/CLI governance surface); the LLM\n # never mutates fold state. The calls run synchronously during finalize, so they add latency and may add\n # paid inference; a caught steward failure does not prevent terminal completion or trigger a retry.\n # ON by explicit experimental product choice when `memory_dir` + an LLM backend are present; the\n # bare-library EngineOptions default stays off (engine/options.py). See engine/lessons.py.\n cross_run_curation: bool = True\n # Deprecated compatibility flag. Steward output is untrusted and finalize is proposal-only regardless\n # of this value; retained so old snapshots still validate and logs can disclose that auto was requested.\n cross_run_curation_auto: bool = False\n # Role backend (ADR-7/14): \"toy\" (offline optimizer) | \"llm\" (live model).\n # CODEX AGENT: non-typed sources (`--set`, file, env) accept any string; a typo then falls through to\n # toy roles instead of failing an intended LLM run. Validate this as Literal[\"toy\", \"llm\"] across\n # all construction paths.\n backend: str = \"toy\"\n # Developer backend (ADR-7): \"default\" (templated/LLM from the task) or an external\n # CLI coding agent: \"opencode\" | \"aider\" | \"goose\" | \"continue\".\n developer_backend: str = \"default\"\n # C2 best-of-N: generate N candidate implementations per node and keep the best by an\n # execution-free reward (static validity + metric-print). 1 = off. In-house LLM developer only\n # (not external agents — ADR-7 cost rule). The top SWE-bench reliability lever for weak models.\n best_of_n: int = 1\n # D10 list-wise best-of-N selection: when the top static-scorers TIE, break the tie with a\n # comparative LLM selection over the candidates presented together (+~3 pts vs pointwise on\n # GAIA, arXiv:2506.12928). The static score stays the primary filter — the LLM is a weak\n # comparative prior, never the sole oracle. ON by default (no-op when best_of_n==1).\n best_of_n_listwise: bool = True\n # === Foresight / predict-before-execute ===============================================\n # FOREAGENT predict-before-execute (arXiv:2601.05930): use the LLM as an IMPLICIT WORLD MODEL to\n # predict — WITHOUT executing — which candidate / hypothesis will score best, primed with a\n # Verified Data Analysis Report (data profile / brief) + the accumulated experiment memory. Master\n # switch, ON by default; a no-op wherever there is nothing to rank (best_of_n==1 and\n # foresight_panel==1) or no LLM client. See `looplab/search/foresight.py`.\n foresight: bool = True\n # Hypothesis foresight breadth: generate K candidate ideas per proposal and keep the one PREDICTED\n # best pre-execution by the world model — it ranks the STRUCTURAL / text ideas the numeric k-NN\n # surrogate (researcher_panel) is blind to, primed with the data profile + memory (the synergy).\n # >1 enables it (LLM backend only; needs `foresight` on); 2 = on by default at modest cost, 1 = off.\n foresight_panel: int = 2\n # AGENTIC foresight: run the ranking (hypothesis-board prioritization + K-idea pick) as a TOOL-USING\n # loop that can pull actual experiment results / data facts before deciding, instead of a one-shot\n # prediction from a pre-baked report. ON by default (needs foresight + a client; falls back to the\n # one-shot predictor on any hiccup). A few extra LLM calls per proposal; set False for the cheaper\n # single-call ranker.\n foresight_agentic: bool = True\n # §1 foresight confidence gate: the minimum predicted confidence at which a predict-before-execute\n # pick is ACTED ON. Below it the ranker abstains — the K-idea panel falls back to the first\n # proposal, best-of-N falls through to the D10 tie-break — instead of committing a low-confidence\n # choice. 0.0 (default) = OFF (act on every pick, the historical behavior); raise toward ~0.5 to\n # make the world model defer when it isn't sure. Pairs with the foresight track record (§1) the\n # predictor is now primed with. Range 0.0-1.0.\n foresight_min_confidence: float = 0.0\n # PART IV Phase 2c — replace the world model's SELF-REPORTED confidence (measured Pearson≈0 with the\n # realized outcome, §21.12) with a CALIBRATED §12-verifier score. When on and a client is available,\n # after the K-idea ranker picks the predicted-best candidate the §12 verifier scores it (grounded +\n # repeated + criteria-decomposed: `foresight_criteria` — likely to improve the objective, and\n # sound/feasible) and THAT calibrated score becomes the confidence the `foresight_min_confidence` gate\n # and the telemetry use. Degrades to the self-reported confidence without a client or on any verifier\n # error (the telemetry records which source was used). ON by default in the product Settings (ce4a379),\n # EngineOptions off; a few extra LLM calls per acted-on proposal. See looplab/trust/verifier.py + search/foresight.py.\n # REVISIT (cost/quality, not yet resolved): a live classification run measured ~10 LLM calls & ~3.8 min/node\n # on deepseek-v4-flash with the full Part IV/V default bundle; the calibrated verifier is part of that per-node\n # tax. Kept ON deliberately — it raises decision quality (self-reported foresight confidence has ≈0 Pearson\n # with realized outcome), and lowering quality to save calls is the wrong trade. TODO: run a frozen quality/cost\n # A/B (verifier-on vs -off) to quantify the recall@objective gain per extra call before considering a default flip.\n foresight_verify: bool = True\n # Verifier sample count for `foresight_verify` (the §12 repeated-sampling expectation on a no-logprob\n # backend). 3 tames the single-shot variance §21.12 measured; 1 = single-shot (cheaper, noisier).\n # RESUME-critical: this value is persisted in config.snapshot.json, so a hard `le` schema bound would\n # make an old run started with a larger LOOPLAB_FORESIGHT_VERIFY_SAMPLES un-resumable (Settings(**snapshot)\n # would raise on `looplab resume` / finalize-recovery / serve replay). CLAMP into [1, MAX] via a validator\n # instead of rejecting — the runtime cap is preserved without breaking the snapshots/env-compat rule.\n foresight_verify_samples: int = 3\n\n @field_validator(\"foresight_verify_samples\")\n @classmethod\n def _clamp_foresight_verify_samples(cls, v: int) -> int:\n # Coerce a persisted/env value into [1, MAX] rather than reject (see field note above).\n try:\n v = int(v)\n except (TypeError, ValueError):\n return 3\n return max(1, min(v, MAX_FORESIGHT_VERIFY_SAMPLES))\n\n # T7 LLM response cache: serve an identical DETERMINISTIC (temperature 0) request from an\n # in-process content-addressed cache instead of re-hitting the model — cuts cost on\n # retry/panel/verify flows. NEVER caches sampling calls (temp>0: best-of-N / panel / novelty\n # re-propose must vary). Off by default (most role calls run at temperature>0 anyway).\n llm_cache: bool = False\n agent_cmd: str | None = None # override the agent's launcher (path / wrapper)\n # External-agent validation (ADR-7): wrap a CLI-agent Developer with a validator that\n # audits each output (no-op/syntax/crash/timeout), retries with feedback, and falls\n # back to the in-house LLM Developer. Off -> use the raw agent output unchecked.\n validate_agent: bool = True\n agent_max_retries: int = 1 # re-prompts of the agent on an invalid result\n # Patch-gated multi-file agent (ADR-7 Rule 3): run the CLI agent in a git worktree and\n # accept only edits whose paths match `agent_surface` (reject-not-strip out-of-surface\n # touches). Lets the agent create helper modules, not just solution.py. Degrades to\n # whole-file readback if git is unavailable.\n agent_patch_gate: bool = True\n agent_surface: list[str] = [\"*.py\"] # edit-surface allow-list (globs)\n # Trust policy for an agent-authored eval/metric adapter (RepoTask onboarding, Phase 3):\n # \"ratify_freeze\" waits for human approval, \"autonomous\" accepts the proposed spec without that\n # gate, and \"ratify_freeze_drift\" also enforces the configured independent drift cross-check.\n eval_trust_mode: str = \"ratify_freeze\"\n # RepoTask node seeding policy (run-wide fallback; a task/editable `seed_mode` overrides):\n # \"auto\" (default) copies git-tracked files when the editable is a git repo (so a tree bloated\n # with untracked artifacts isn't deep-copied per node), else a full copy; \"tracked\" forces\n # code-only; \"all\" forces a full recursive copy (legacy). Genesis authors per-task from the\n # user's words; this is the global default when unspecified.\n seed_mode: str = \"auto\"\n llm_model: str = \"qwen3:8b\"\n # === LLM / transport ==================================================================\n llm_base_url: str = \"http://localhost:11434/v1\" # Ollama OpenAI-compatible endpoint\n llm_temperature: float = 0.6\n # H3 per-role model presets (5090 recipe): optionally run the Researcher and Developer on\n # DIFFERENT models/endpoints (e.g. Developer=Qwen3-Coder-30B for code, a fast model for breadth).\n # Blank = use the shared llm_model/llm_base_url. Generalizes the role-backend swap to per-role.\n researcher_model: str | None = None\n developer_model: str | None = None\n researcher_base_url: str | None = None\n developer_base_url: str | None = None\n # Per-role sampling temperature (§4.1): the Researcher wants breadth (higher temp = more diverse\n # ideas), the Developer wants determinism (lower temp = fewer syntax slips), the Strategist a\n # steady hand. Each overrides the shared `llm_temperature` for that role only; None = use the\n # shared value (byte-identical to before). Flat + nullable, mirroring the per-role model fields.\n researcher_temperature: float | None = None\n developer_temperature: float | None = None\n strategist_temperature: float | None = None\n # Unified self-driving agent: one LLM identity that plays Researcher + Developer (+ Strategist)\n # across pipeline stages, choosing its own model/toolset per stage. ON by default — the agent\n # drives the loop (incl. crash triage: repair/abandon/reject_idea) and, via\n # `agent_drives_actions`, picks the next macro action within a pure legal-action gate. Set both\n # to False for the legacy byte-identical split-role behavior. (No-op unless backend=\"llm\".)\n unified_agent: bool = True\n agent_drives_actions: bool = True\n # Layer 3 Card queue ownership. False preserves the existing policy/unified-pilot action path.\n # True is pinned in run_started because changing the selector on resume would mix two search\n # treatments in one run. When both this and agent_drives_actions are true, Card selection wins.\n card_driven_selection: bool = False\n # Layer 5 bounded speculative Card buffer. Zero is a hard OFF switch and preserves the\n # historical alternating build/eval spine; positive values are pinned by run_started so a\n # resume cannot silently mix search treatments after a snapshot/default edit.\n speculation_depth: int = Field(default=0, ge=0, le=64)\n # Local evidence receipt produced by ``looplab speculation-gate``. This is deliberately absent\n # from the curated Settings UI: positive-depth execution is a calibrated rollout decision, not a\n # casual tuning knob. The Engine validates the receipt against the current scorer,\n # implementation, GPU inventory and raw paired-run evidence before it enables speculation.\n speculation_gate_receipt: str | None = Field(default=None, max_length=4096)\n # Per-stage model/endpoint overrides for the unified agent. Recognized keys (see\n # tasks.build_unified_agent): `propose` (researcher), `implement`/`repair` (developer),\n # `strategy`, `pilot`. Unlisted keys are ignored. Empty = the per-role researcher_*/developer_*\n # models, then the shared llm_model. Explicit map wins. No-op unless `unified_agent` is true.\n agent_stage_models: dict = {}\n agent_stage_base_urls: dict = {}\n llm_parser: str = \"tool_call\"\n # Reasoning/thinking toggle sent IN the request (providers differ — see `llm.reasoning_body`):\n # Qwen3 on vLLM/SGLang -> chat_template_kwargs.enable_thinking (bool)\n # OpenAI/Ollama-v1/DeepSeek -> reasoning_effort (low|medium|high|none)\n # `llm_reasoning`: \"\" = send nothing (server default — current behavior); off = actively disable;\n # on = enable at default depth; low|medium|high = enable at that effort.\n # Defaults to \"high\" so the agent reasons before proposing/repairing/triaging (better code, fewer\n # crashes); set \"\" to send nothing (server default) or \"off\" to disable. The model's chain-of-thought\n # is CAPTURED either way (reasoning_content field or inline <think>). NOTE: to get a SEPARATE\n # reasoning_content field from SGLang/vLLM, the server also needs `--reasoning-parser qwen3`.\n llm_reasoning: str = \"high\"\n llm_reasoning_style: str = \"auto\" # auto | qwen | effort | none — how to shape the request param\n llm_reasoning_extra: dict = {} # raw fields merged into the body (escape hatch, e.g. Anthropic thinking)\n # H1: drive structured calls with the endpoint's constrained decoding (vLLM/SGLang guided_json +\n # response_format json_schema) so weak models can't emit invalid JSON. Off for Ollama (default).\n llm_guided_json: bool = False\n # Stream every LLM request (SSE) and reassemble it, so the request `timeout` is an INTER-TOKEN\n # idle timeout rather than a whole-request deadline: a long-but-alive generation is never cut off\n # (tokens keep resetting the timer), while a genuinely STALLED endpoint (no token for `timeout` s)\n # is detected and retried on a fresh connection — the fix for silent multi-minute hangs. Set False\n # to use one blocking read (old per-op timeout semantics) if an endpoint streams badly.\n llm_stream: bool = True\n # LLM IDLE timeout (seconds). Stream mode: the INTER-TOKEN stall limit — a steady generation is\n # never cut off, only a silent endpoint. Non-stream: bounds the whole read. Raise for endpoints\n # with a long prefill on huge prompts; lower to fail fast on a flaky shared endpoint.\n llm_timeout: float = Field(default=180.0, gt=0)\n # First-byte (response-headers) window for STREAM attempts, seconds: an SSE response sends its\n # headers on admission, so no-headers ≈ a black-holed request — fail over to a fresh connection\n # fast instead of waiting the full idle timeout. Clamped to llm_timeout. Non-stream attempts are\n # NOT bounded by this (their headers arrive only after the whole generation). The default mirrors\n # `DEFAULT_HEADER_TIMEOUT_S` in looplab/core/llm.py is the single source (imported above).\n llm_header_timeout: float = Field(default=DEFAULT_HEADER_TIMEOUT_S, gt=0)\n # Honour HTTP(S)_PROXY / NO_PROXY / SSL_CERT_FILE env vars for LLM calls. Default False: the\n # transport connects DIRECTLY (many internal/OLAP endpoints are reachable only by bypassing an\n # ambient proxy — a picked-up proxy yields \"connection refused\"). Set true when the endpoint is\n # reachable ONLY via a corporate proxy or needs a custom CA bundle from the environment.\n llm_trust_env: bool = False\n # H4/C2: compact the growing agentic tool-call history once it exceeds this many chars (auto_summary\n # LLM-summarizes the stale middle; else middle-truncate). Sized to the model's real context window so\n # files read earlier STAY in context instead of being compacted away and re-read: ~1,000,000 chars ≈\n # 250k tokens (≈4 chars/token), matching the configured deepseek-v4-flash (≥200k tokens confirmed).\n # The old 120k-char (~30k-token) default was ~8× too aggressive and drove a read-thrash loop. 0 = off.\n context_budget_chars: int = 1_000_000\n # M5: the Researcher's always-on experiments digest budget (chars). 0 = AUTO — scales with the\n # run size (60 chars/node, bounded [1200, 6000]) instead of one flat cap for an 8-node toy run\n # and a 200-node benchmark run alike. Set a positive value to pin it.\n digest_char_cap: int = Field(default=0, ge=0)\n # D11 compression model slot (open_deep_research's four-slot pattern): a dedicated CHEAP model\n # for agent-history summarization (C2 auto-summary), so compression doesn't pay the main\n # model's price. Blank = use each loop's own model (legacy). `compressor_base_url` blank =\n # the shared llm_base_url.\n compressor_model: str | None = None\n compressor_base_url: str | None = None\n # === Agentic tool-loop ================================================================\n # Agentic tool-loop limits — apply to EVERY tool-using agent (the LLM Researcher, the unified\n # agent's pilot + crash-triage, Deep-Research, the run-chat Boss, the genesis repo-scout, and the\n # cross-run report synthesizer). The loop lets the model call read-only tools across turns before\n # emitting its structured result. Both default to UNLIMITED so the agent takes as many turns / as\n # long as it needs — it is never cut off mid-reasoning; set a positive value here (or per-run, or\n # in the UI settings) to bound latency/cost. These used to be hardcoded per call site (e.g. the\n # boss at max_turns=3 / 45s), which silently dropped a slow reasoning model to a no-op reply.\n agent_max_turns: int = 0 # max tool turns before the emit is forced (0 = unlimited)\n agent_time_budget_s: float = 0.0 # wall-clock ceiling across the loop's turns (0 = no cap)\n # G · Soft convergence SAFETY NET (not a research cap). Two thresholds on the agent's tool-turn count:\n # at `agent_emit_after` the loop NUDGES the agent once (\"you've investigated enough — emit now\"); at\n # `agent_emit_force` it FORCES the emit from what was gathered. Set GENEROUSLY — a focused researcher\n # may legitimately read many files/experiments before it can propose a grounded idea; these only catch\n # a genuine runaway that keeps issuing DIFFERENT calls forever (which stuck-detection, keyed on\n # repeats, misses). NB: the old 499-read runaway (node 63) was 79% REDUNDANT re-reads caused by a\n # broken read_file — fixing read_file (pagination) is the real cure; this is just insurance. 0 = off.\n agent_emit_after: int = 300\n agent_emit_force: int = 500\n # B1 · No-progress / stuck detection — the safety net that makes \"unlimited turns\" safe. The\n # turn/time ceilings above are only BACKSTOPS; this is what actually stops a runaway loop, on the\n # cheapest signal: when the model repeats the SAME tool call (or ping-pongs between two, or keeps\n # hitting the SAME error) with no progress, the loop forces the final emit and finishes. ON by\n # default; reading DIFFERENT files or running ONE long command never trips it. (OpenHands-style.)\n agent_stuck_detection: bool = True\n agent_stuck_repeat: int = 4 # identical calls in a row that count as \"stuck\" (>=2)\n agent_stuck_alternate: int = 4 # ping-pong cycles between two calls that count as \"stuck\" (>=2)\n # C1 · Self-plan (TodoWrite-style): expose an `update_plan` tool so a long-running agent keeps its\n # OWN working TODO and re-surfaces it every `agent_plan_reinject_every` turns — keeps the goal in\n # view across a long loop so the agent \"writes its own context\" (Claude Code style). ON by default.\n agent_self_plan: bool = True\n agent_plan_reinject_every: int = 5\n # C2 · Auto-summary (ON by default): when the tool-loop history grows long, LLM-summarize the\n # stale middle instead of only middle-truncating it. The trigger is `context_budget_chars` if set,\n # else a built-in high-water mark (context_budget.DEFAULT_SUMMARY_CHARS ≈ 120k chars) so short\n # loops are untouched. Falls back to truncation on any summarizer error; one extra model call per\n # over-budget turn.\n agent_auto_summary: bool = True\n # C4 · Repo-developer plan decomposition. A big feature attempted in ONE developer session made a\n # non-converging reasoning model loop indefinitely — it kept writing + exploring without ever\n # emitting `done` (10k+ LLM calls / hours; agentic stuck-detection can't catch VARIED exploration,\n # only literal repeats). Fix: the developer first proposes an ordered plan of ATOMIC steps; a\n # multi-step plan is executed step-by-step, each in a FRESH bounded session with only that step's\n # scope + the files accumulated so far, syntax-validated per write. Every session stays\n # small-context and convergent. ON by default for repo tasks (a 1-step plan == the old single pass).\n developer_plan_decompose: bool = True\n developer_plan_min_steps: int = 2 # a proposed plan with >= this many steps runs step-by-step\n developer_plan_max_steps: int = 8 # cap on plan length (a runaway planner can't spawn 100 steps)\n # Hard per-session backstop for the repo developer — the ONE write-heavy agent that can run away\n # even with stuck-detection on (varied writes/reads never trip the repeat/ping-pong signal). Bounds\n # the plan phase, EACH step, and the single-session fallback. Unlike the global agent_* limits\n # (0 = unlimited, meant for read-only agents), the developer always gets a FINITE ceiling so a\n # model that never emits `done` fails cleanly with the code it wrote so far, instead of looping.\n developer_session_max_turns: int = 500\n developer_session_time_budget_s: float = 1200.0 # 20 min wall-clock per developer session\n # Phase-handoff summaries. Each LLM phase in a node build (Researcher·propose → Developer·stages →\n # plan → implement) ends with ONE extra LLM call that distills its transcript — the repo structure\n # it mapped, files/data confirmed, decisions made — into a brief injected into the NEXT phase (even\n # across the role boundary). The next phase TRUSTS that instead of re-reading the same files, which\n # cuts the biggest source of tool-call thrash (stages/plan/implement each re-exploring the repo).\n # One summary call per phase in exchange for many fewer read calls downstream. ON by default.\n phase_handoff_summary: bool = True\n # Observability (ADR-17): capture each LLM call's full prompt + completion into the active\n # span (spans.jsonl) so the UI can show exactly what the model read and wrote per node.\n # Diagnostics only — `replay.fold` never reads spans. Default on for local single-user; set\n # LOOPLAB_TRACE_LLM_IO=0 to suppress (e.g. to keep spans.jsonl small or avoid storing prompts).\n trace_llm_io: bool = True\n # Run-introspection tools (ON by default): make the LLM Researcher (and DeepResearcher) a\n # tool-using agent that can read its OWN experiments (list/read/find-analogous/themes/code) and\n # the task data (schema/profile/asset) mid-loop, instead of seeing only best+parent. Advisory —\n # never changes best-selection. Off = the legacy single-shot Researcher (richer digest still added).\n researcher_tools: bool = True\n # Cross-run introspection: give the agentic Researcher / pilot read-only tools to look at\n # SIBLING runs (same task_id, same run-root) — list them, read an experiment / its code, and\n # find analogous configs across runs — so a run can build on what neighbouring runs already\n # learned instead of rediscovering it. Advisory; never changes best-selection. Needs the run's\n # own dir wired through (no-op for unit-built roles). Off = the legacy single-run view only.\n cross_run_tools: bool = True\n # Read-only access to EVERY run on this machine ACROSS ALL TASKS (not just same-task siblings):\n # the Developer/Researcher get list_all_runs + read_run_code + read_run_experiment so they can read\n # the code + result of ANY past experiment anywhere and reuse an approach. Broader than\n # `cross_run_tools` (same-task only); the agent decides when a foreign run is relevant. Advisory;\n # never changes best-selection. Needs the run's own dir wired through (no-op for unit-built roles).\n all_runs_tools: bool = True\n # PART V §22 — read-only CROSS-RUN KNOWLEDGE tool for the reasoning roles (Researcher, Strategist,\n # deep-research, and a Developer-scoped variant). Adds `cross_run_prior_attempts` / `cross_run_claims`\n # / `cross_run_atlas` over the §21.20 read-models (concept overview + claim assessments + atlas), read\n # from `memory_dir` (lessons.jsonl + concept_capsules.jsonl). Read-only is a storage-authority\n # boundary, not a behavioral no-op: retrieved content can steer later proposals, and a tool-using\n # reasoning loop can consume additional model calls. The tools never mutate cross-run truth (facts\n # are engine-written, verdicts operator-ratified, §22.4) or directly select a winner. ON by explicit\n # experimental product choice, EngineOptions off; stores only exist once Part-IV features have run.\n cross_run_read_tools: bool = True\n # Agentic retrieval (ADR-16): if set, the LLM Researcher gets grep/kb_search/read\n # tools over this directory of markdown notes and chooses when to use them.\n knowledge_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"knowledge\"))\n # T4 real embeddings: model id for an OpenAI-compatible `/embeddings` endpoint used for\n # kb_search / case retrieval. Blank (default) = the dependency-free lexical `hash_embed` (today's\n # behavior). Set e.g. \"nomic-embed-text\" (Ollama) or \"text-embedding-3-small\" for SEMANTIC\n # retrieval; a misconfigured/offline endpoint degrades back to hash_embed at call time (never\n # crashes a run). `embed_base_url` blank = reuse `llm_base_url`; the shared `llm_api_key` is used.\n embed_model: str | None = None\n embed_base_url: str | None = None\n # Harmonic memory (Memora, ICML'26 — idea import). When ON, cross-run cases and knowledge notes are\n # indexed by a short ABSTRACTION + cue ANCHORS instead of their raw text, near-duplicate memories are\n # CONSOLIDATED into one entry under a matching abstraction, and `kb_search`/case retrieval EXPAND\n # through the top hits' anchors to surface related-but-not-similar memories. ON by default. Set\n # `memora=false` to restore the pre-Memora raw-text index. Never a source of truth — abstractions\n # live only in the derived, rebuildable retrieval index (never the event log or canonical cases.jsonl).\n memora: bool = True\n # Write abstractions with the wired chat model (richer than the deterministic lexical fallback). ON\n # by default; results are CACHED by content hash (see `memora_cache`) so a re-built index doesn't\n # re-call the model on unchanged notes/cases, and any endpoint failure degrades to lexical at call\n # time — so an offline box just gets lexical abstractions, never a crash. Set false to force lexical.\n memora_llm: bool = True\n # === Cross-run memory / Memora ========================================================\n memora_anchors: int = 6 # max cue anchors kept per memory\n # Cosine similarity at/above which a new case is CONSOLIDATED into an existing one (same evolving\n # topic) rather than stored as a separate entry. Only used when `memora` is on.\n memora_consolidate_threshold: float = 0.86\n # Where the LLM-abstraction cache lives (JSON). Blank (default) derives it from `memory_dir`\n # (`<memory_dir>/memora_cache.json`) or else `knowledge_dir` (`<knowledge_dir>/.memora_cache.json`);\n # with neither set the cache is in-memory only (per process). No-op unless `memora_llm` is on.\n memora_cache: str | None = None\n # E3 literature-grounded ideation (network-OPTIONAL): give the agentic Researcher an arXiv search\n # tool to ground ideas in real techniques. Off by default (egress is unreliable on some boxes);\n # fails gracefully if the network is blocked.\n literature_search: bool = False\n # Deep-Research stage (network-OPTIONAL): give the DeepResearcher a general web search/fetch\n # tool (DuckDuckGo) on top of arXiv to read across results + the web before steering the next\n # batch. Off by default (egress is unreliable on some boxes); fails gracefully when blocked.\n web_search: bool = False\n # Cadence for the Deep-Research stage: run it automatically every N created nodes. Zero disables\n # only the cadence; manual `deep_research` and Strategist `request_research` triggers still fire.\n # Default 3: deep research analyzes the accumulating results and steers the next batch (its\n # directions become hints + open hypotheses); paired with `concurrent_research` it overlaps the\n # think with the GPU-bound eval, so the cadence costs LLM tokens but no wall-clock on the search.\n deep_research_every: int = 3\n # Overlap a DUE deep-research \"think\" with the GPU-bound eval instead of running it in its own\n # serial step (the agent is otherwise idle while a node trains). research() computes from a state\n # snapshot; the joined background task records only allowlisted, order-tolerant\n # research/hint/hypothesis events as soon as the memo finishes. ON by default: the LLM is typically\n # remote (no GPU contention with eval), so overlapping hides latency behind long training.\n concurrent_research: bool = True\n # Concurrent-research REPEAT — don't idle a multi-DAY eval. The overlapped \"think\" fires ONCE per\n # window by default; ON here, it RE-RUNS on an adaptive time cadence for the WHOLE eval window\n # (a two-day training must not leave the reasoning agents idle after one memo). Self-paced: it\n # only records a memo whose CONTENT is new (identical re-runs are skipped, so the log/board don't\n # bloat) and backs off geometrically as the analysis converges, capped so it always re-checks.\n # The appended records never rewrite the current metric champion, but their hints/open hypotheses\n # deliberately steer later proposals and replay reconstructs that advice. The library default is\n # one-shot (== today, byte-identical); this product default turns the repetition on.\n concurrent_research_repeat: bool = True\n # Base cadence (seconds) between repeated concurrent-research passes. Research is EXPENSIVE\n # (multi-turn LLM + web/arXiv), so this is a FLOOR, not a ceiling: the effective cadence is\n # max(this, ~5% of the per-experiment time budget) — a two-day eval is re-researched about hourly,\n # a short eval not at all (the first tick outlasts it). Only matters when the repeat is on.\n concurrent_research_interval_s: float = Field(default=1800.0, gt=0)\n # Per-eval-window backstop on repeated-research LLM calls (0 = cadence-only, no cap). The adaptive\n # cadence + convergence backoff are the primary budget control; this bounds a pathological\n # never-converging window. Past it the loop stops calling the LLM (the health monitor still runs).\n concurrent_research_max_calls: int = Field(default=40, ge=0)\n # Concurrent hypothesis-board CONSOLIDATION during the eval window. Repeated research keeps ADDING\n # near-duplicate directions to the open board (each memo registers up to 5 as open hypotheses), so a\n # long eval's board bloats before the between-nodes merge runs. This dedups/merges it on the SAME\n # background loop so the board stays clean for the next proposal. In Card-driven mode the same merge\n # changes ownership/readiness, so it is deferred to the joined main-task cadence instead. Needs the\n # reflect client + `track_hypotheses`.\n # Only active while `concurrent_research` runs the overlap loop. Library default off (== today).\n concurrent_consolidate: bool = True\n # Cadence for the agent-authored run report: regenerate the conclusion-first narrative every N\n # created nodes (0 = off; it still regenerates on a manual `report_refresh` from the UI). The\n # deterministic report always renders from the node set regardless of this knob.\n report_every: int = 3\n # Agent Skills (I18, ADR-9): dir of SKILL.md the Researcher can list/load as tools.\n skills_dir: str | None = None\n # Prompt store (I18, ADR-8): dir of editable, hot-reloaded role prompt .md files.\n prompt_dir: str | None = None\n # API key as a reference, never serialized as a value (ADR-11). Local servers\n # ignore it; default to a placeholder.\n llm_api_key: SecretStr | None = None\n\n @model_validator(mode=\"before\")\n @classmethod\n def _apply_profile(cls, data):\n \"\"\"Expand `profile` into a bundle of setting defaults BEFORE validation.\n\n pydantic-settings hands this validator the fully-merged input from ALL explicit sources\n (init kwargs + LOOPLAB_* env + .env), with fields the user left unset simply ABSENT (they\n fall to field defaults afterward). So a profile value is applied only when its key is not\n already present — i.e. any explicit file/CLI/env setting wins over the profile, and the\n profile wins over the bare field default. This makes `thorough` a true convenience preset,\n never an override of what the user asked for.\"\"\"\n if not isinstance(data, dict):\n return data\n prof = data.get(\"profile\", \"default\")\n # env may deliver the key as \"profile\" (env_prefix stripped) — normalize to a str\n prof = str(prof).strip().lower() if prof is not None else \"default\"\n overrides = PROFILES.get(prof)\n if overrides is None:\n raise ValueError(f\"unknown profile {prof!r}; known: {sorted(PROFILES)}\")\n for key, val in overrides.items():\n if key not in data: # explicit source (file/CLI/env) always wins\n data[key] = val\n return data\n\n @model_validator(mode=\"after\")\n def _check_trust_gate(self):\n if self.trust_gate not in (\"audit\", \"gate\", \"block\"):\n raise ValueError(\n f\"trust_gate must be audit|gate|block, got {self.trust_gate!r}\")\n if self.merge_mode not in (\"auto\", \"mean\", \"ensemble\"):\n raise ValueError(\n f\"merge_mode must be auto|mean|ensemble, got {self.merge_mode!r}\")\n # novelty_mode drives the dedup/novelty gate; an out-of-set value (e.g. a mis-cased\n # LOOPLAB_NOVELTY_MODE=LLM, or \"on\") used to fall through _apply_novelty_gate silently as a\n # NO-OP — turning the gate off with no diagnostic. Fail loudly at config time like trust_gate/\n # merge_mode do. (Other enum-ish string fields — seed_mode/eval_trust_mode/strategist_backend —\n # share the pattern; left unvalidated here only because their full value sets are less settled.)\n if self.novelty_mode not in (\"off\", \"algo\", \"llm\"):\n raise ValueError(\n f\"novelty_mode must be off|algo|llm, got {self.novelty_mode!r}\")\n # The remaining enum-ish string fields (arch-review §5 P3): a typo used to be accepted at\n # construction and only fail-safe/later-loud downstream (e.g. a mis-cased strategist_backend\n # silently ran the default). Fail loudly here like the fields above.\n if self.strategist_backend not in (\"off\", \"rule\", \"llm\", \"agent\"):\n raise ValueError(\n f\"strategist_backend must be off|rule|llm|agent, got {self.strategist_backend!r}\")\n if self.eval_trust_mode not in (\"ratify_freeze\", \"autonomous\", \"ratify_freeze_drift\"):\n raise ValueError(\n \"eval_trust_mode must be ratify_freeze|autonomous|ratify_freeze_drift, \"\n f\"got {self.eval_trust_mode!r}\")\n if self.seed_mode not in (\"auto\", \"tracked\", \"all\"):\n raise ValueError(f\"seed_mode must be auto|tracked|all, got {self.seed_mode!r}\")\n return self\n\n def masked_snapshot(self) -> dict:\n # Snapshots are a JSON contract (they are written verbatim to config.snapshot.json), not a\n # Python-object dump. In particular tuple-valued schema fields such as\n # ``inline_repair_reasons`` must become JSON arrays before callers hash the runtime envelope;\n # otherwise the strict speculation-scope canonicalizer sees a different/non-JSON preimage\n # from the one that ``json.dumps`` eventually persists.\n d = self.model_dump(mode=\"json\")\n if self.llm_api_key is not None:\n d[\"llm_api_key\"] = \"***\"\n else:\n d[\"llm_api_key\"] = None\n return d\n\n\n# Pre-versioned snapshots are full Settings dumps, so absence is a reliable per-field version marker.\n# Keep their historical effective behavior when newer product defaults become active: re-entry must not\n# silently add paid calls, interventions, concurrency or a different selection policy to an old run.\nLEGACY_CONFIG_SNAPSHOT_DEFAULTS: dict[str, object] = {\n \"parallel_build\": 1,\n \"eval_parallel\": None,\n \"llm_parallel\": None,\n \"train_monitor\": False,\n \"train_monitor_interval_s\": 600.0,\n \"train_monitor_kill\": False,\n \"train_monitor_kill_confidence\": 0.8,\n \"asha_live\": False,\n \"asha_live_kill\": False,\n \"asha_live_quantile\": 0.5,\n \"asha_live_min_siblings\": 3,\n # Before this operator ceiling existed, the sandbox's 24-hour process limit was authoritative.\n \"max_eval_timeout\": 24 * 3600.0,\n \"watchdog_reflection\": False,\n \"card_driven_selection\": False,\n \"speculation_depth\": 0,\n \"speculation_gate_receipt\": None,\n \"concurrent_research_repeat\": False,\n \"concurrent_research_interval_s\": 1800.0,\n # Zero means unlimited; 40 was the first bounded default, and is latent while repeat is disabled.\n \"concurrent_research_max_calls\": 40,\n \"concurrent_consolidate\": False,\n}\n\n\ndef migrate_config_snapshot(data: dict) -> dict:\n \"\"\"Return an effective copy of one legacy/current snapshot without rewriting evidence bytes.\"\"\"\n if not isinstance(data, dict):\n raise TypeError(\"config snapshot must be a JSON object\")\n migrated = dict(data)\n for key, value in LEGACY_CONFIG_SNAPSHOT_DEFAULTS.items():\n migrated.setdefault(key, value)\n return migrated\n\n\ndef settings_from_snapshot(data: dict) -> Settings:\n # CODEX AGENT: snapshots have no schema/runtime version and Settings ignores unknown fields.\n # An older binary can silently drop newer behavior-affecting controls and resume the same event\n # history with different paid/concurrency/selection semantics. Version snapshots and fail closed\n # on an unsupported downgrade before migration/default filling.\n \"\"\"Build re-entry Settings from a masked snapshot under its historical missing-field contract.\"\"\"\n migrated = migrate_config_snapshot(data)\n migrated.pop(\"llm_api_key\", None)\n return Settings(**migrated)\n"}, "3c9aa698aab0d36234c29bd0971b3472040bac4f": {"t": 1785768765.6249444, "n": 98459, "h": "cffb2a9e2f72a41e", "c": "\"\"\"Engine config: the schema (what settings exist). (I0, ADR-11)\n\nOne pydantic-settings class declaring every engine knob, its default, and its docs. The loader —\nhow a run file / CLI / env becomes a `Settings` — is `looplab.core.appconfig`, which also owns\nthe config-precedence order. A resolved, secret-masked snapshot is written next to each run for\nreproducibility. (No real secrets in P0, but the masking discipline is in place.)\n\"\"\"\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nfrom pydantic import Field, SecretStr, field_validator, model_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n# Single source of the LLM first-byte (response-headers) default — see core/llm.py.\nfrom looplab.core.llm import DEFAULT_HEADER_TIMEOUT_S\n\n# Default home for cross-run memory + the knowledge base (shared across every run). Both are ON by\n# default now (a user asked for it) so a fresh install accumulates + consults knowledge with no setup;\n# set the env var to \"\" to disable, or to a path to relocate. `~/.looplab/` keeps them out of any one\n# project + shared across projects.\n_LL_HOME = Path.home() / \".looplab\"\n\n# Autonomous roles that may be granted per-setting write access (see Settings.agent_control).\n# strategist = A7 meta-controller (run-wide); boss = run-chat operator-proxy (run-wide);\n# researcher = per-experiment proposer (per-node sizing, e.g. eval timeout for a heavy model).\nAGENT_ROLES = (\"strategist\", \"boss\", \"researcher\")\n\n# A foresight score is computed for every acted-on proposal. Keep this lower than the generic\n# verifier's emergency ceiling so a typo cannot multiply paid synchronous calls across a run.\nMAX_FORESIGHT_VERIFY_SAMPLES = 8\n\n\n# Layer-2 parallelism renamed two public settings without removing the durable legacy fields. Keep\n# the equivalence in one place: config-source precedence, governance migration, and live strategy\n# application must all agree which names describe the same authority/value.\nPARALLELISM_ALIASES: dict[str, str] = {\n \"max_parallel\": \"eval_parallel\",\n \"parallel_build\": \"llm_parallel\",\n}\n\n\ndef parallelism_aliases(setting: str) -> tuple[str, ...]:\n \"\"\"Return every spelling in ``setting``'s alias family, canonical name first.\"\"\"\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if setting in (legacy, canonical):\n return canonical, legacy\n return (setting,)\n\n\ndef canonicalize_parallelism_source(\n values: dict, *, promote_build_to_llm_parallel: bool = False,\n) -> dict:\n \"\"\"Promote legacy parallel keys *within one config-precedence source*.\n\n Applying this only after file/CLI/env were flattened would let a lower-priority canonical env\n value shadow a higher-priority legacy file value (or a file canonical value shadow a CLI legacy\n override), so callers with sub-layers normalize each layer independently.\n\n ``max_parallel``/``eval_parallel`` are true aliases (pure eval width, no extra semantics) and are\n always promoted. ``parallel_build``/``llm_parallel`` are NOT: a positive ``llm_parallel`` also flips\n on the finite shared LLM broker (``orchestrator._startup_llm_total``), while legacy ``parallel_build``\n means \"legacy build width, unbounded overlap, no shared total\". So promoting a legacy-only\n ``parallel_build`` layer (e.g. just ``LOOPLAB_PARALLEL_BUILD=1`` in env) would silently serialize\n every provider call — contradicting llm_broker.py's docstring, orchestrator's \"legacy-only preserves\n unbounded overlap\" note, the broker-neutral live-control path, and the configuration.md ``llm_parallel``\n row (\"falls back ... without enabling a shared total\"). Config/startup loads therefore keep broker\n opt-in tied to an EXPLICITLY-spelled canonical ``llm_parallel``; the orchestrator's own width fallback\n (``llm_parallel if not None else parallel_build``) still carries the legacy build width. Only the\n Strategist live-control path (``canonicalize_strategy_parallelism``) opts into\n ``promote_build_to_llm_parallel``, where ``parallel_build`` is a deliberate full alias of the\n canonical knob (see strategy.py ``_apply_strategy``).\n \"\"\"\n if not isinstance(values, dict):\n return values\n out = dict(values)\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if canonical == \"llm_parallel\" and not promote_build_to_llm_parallel:\n continue\n # None is the durable legacy mode for llm_parallel, not a missing value. A normal snapshot\n # contains llm_parallel:null plus parallel_build:1; promoting null to 1 would unexpectedly enable\n # the global one-call broker. Promote aliases only when the canonical key is absent, and pin\n # snapshot round-trip parity. Canonicalize inside each precedence layer, never across the\n # flattened result, so the alias migration cannot invert CLI > file > env precedence.\n if canonical not in out and out.get(legacy) is not None:\n out[canonical] = out[legacy]\n return out\n\n# Settings whose semantics are committed by ``run_started`` and restored from the folded event log\n# on every re-entry. Changing one only in ``config.snapshot.json`` would mix incompatible evidence\n# (a different holdout split) or make the live producer disagree with replay's selection policy.\n# The HTTP config editor and the engine both consume this one contract; keep deliberately mutable\n# controls such as ``trust_gate`` out (that field has its own durable change event).\nRUN_START_PINNED_FIELDS = frozenset({\n \"card_driven_selection\",\n \"speculation_depth\",\n \"holdout_fraction\",\n \"holdout_select\",\n \"select_verifier\",\n \"select_verifier_samples\",\n \"verifier_ci_tie\",\n})\n\n\ndef run_start_pinned_settings(state) -> dict:\n \"\"\"Return the effective Settings names committed by a folded run-start record.\n\n Pre-pin legacy logs have no recorded holdout fraction, so their snapshot remains authoritative\n for both holdout knobs. Verifier fields, however, have always been re-pinned from the legacy-safe\n fold defaults whenever a valid ``run_started`` identity exists.\n \"\"\"\n if not getattr(state, \"run_id\", \"\"):\n return {}\n values = {\n # Layer 3 changes which queue owns macro-action selection. Once a run starts, replay and\n # resume must keep the same owner even if the snapshot or ambient default changes later.\n \"card_driven_selection\": bool(getattr(state, \"card_driven_selection\", False)),\n # Layer 5 overlaps Card production with evaluation only when this pinned treatment is\n # positive. Old logs omit it and therefore retain the exact serial build/eval spine.\n \"speculation_depth\": int(getattr(state, \"speculation_depth\", 0)),\n \"select_verifier\": bool(getattr(state, \"select_verifier_tiebreak\", False)),\n \"select_verifier_samples\": int(getattr(state, \"select_verifier_samples\", 3)),\n \"verifier_ci_tie\": bool(getattr(state, \"verifier_ci_tie\", False)),\n }\n holdout_fraction = getattr(state, \"holdout_fraction\", None)\n if holdout_fraction is not None:\n values.update({\n \"holdout_fraction\": holdout_fraction,\n \"holdout_select\": bool(getattr(state, \"holdout_select\", False)),\n })\n if not values.keys() <= RUN_START_PINNED_FIELDS:\n raise RuntimeError(\"folded run-start pinned settings contract drifted\")\n return values\n\n# --- Run profiles (config-first presets) -------------------------------------------------------\n# A `profile` is a NAMED BUNDLE of setting defaults — nothing more. It only fills fields the user\n# did NOT set explicitly (via file / --set / LOOPLAB_* env / init-kwargs), so any explicit knob\n# always wins (see `_apply_profile`). Most engine intelligence lives behind individual flags. The\n# PART IV/V cross-run + concept-graph machinery (concept_pivot, graded_novelty, cross_run_concepts /\n# _advisory / _structured_claims / _curation / _read_tools, fingerprint_universal, foresight_verify)\n# now ships ON in the product `Settings` default. That is an explicit experimental product choice,\n# not a claim that a frozen A/B or workload-wide cost/quality gate has cleared: the bundle can steer\n# prompts and candidate generation, change graded-novelty admission / pre-execution choices, and add\n# paid LLM work when its client and memory prerequisites are present. The bare-library `EngineOptions`\n# default stays lean (see tests/test_options_divergence.py — product side is the aggressive one) so a\n# toy `Engine(...)` in a test doesn't fire cross-run LLM work unasked. The trust/confirm/ablate QUALITY\n# bundle still ships OFF and is turned on by `thorough`. Every value here is reachable by hand — the\n# profile is a convenience, never a hidden mode.\n# keep this rationale behavioral and evidence-based; do not relabel read-only storage\n# access or proposal-only governance as a no-op for agent behavior, cost, or proposal admission.\n#\n# `thorough` deliberately touches only QUALITY/TRUST machinery, not spend: it does NOT raise\n# max_nodes / eval_parallel (those are cost knobs the user owns). It enables multi-seed confirmation\n# (demote seed-lucky leaders), the reward-hack / leakage / critic monitors AND flips them from\n# advisory to *gating* (`trust_gate=\"gate\"` — a\n# flagged win can no longer be selected as best), ablation-driven refinement, and the prompt cues\n# (complexity / budget / failure-reflection / watchdog-reflection). Agentic novelty and the experimental\n# Part IV/V bundle are already product defaults, not profile changes. Effect is large; marginal cost is a\n# few extra evals.\nPROFILES: dict[str, dict] = {\n \"default\": {}, # explicit no-op alias for \"ship-as-is\" (== omitting profile)\n \"fast\": {}, # today's lean defaults, named for symmetry with `thorough`\n \"thorough\": {\n \"confirm_top_k\": 3,\n \"confirm_seeds\": 3,\n # novelty_gate intentionally NOT enabled here: duplicate-detection is the agentic Researcher's\n # job (it reads past experiments + find_analogous_across_runs and DECIDES), not an algorithmic\n # param-distance / embedding auto-reject that mis-fired (it nudged a good fresh idea onto a\n # twice-dead lineage — live node 61). Search stays as tools; the LLM judges novelty.\n \"operator_bandit\": True, # P4: adaptive operator mix over folded yields (GreedyTree)\n \"reward_hack_detect\": True,\n \"code_leakage_detect\": True,\n \"critic_check\": True,\n \"trust_gate\": \"gate\", # detectors stop gating a flagged node from WINNING (not just audit)\n \"ablate_every\": 3,\n \"complexity_cue\": True,\n \"budget_aware\": True,\n \"failure_reflection\": True,\n \"watchdog_reflection\": True, # feed recent live-watchdog (train-monitor/ASHA) flags to proposals\n \"reflection_priors\": True, # no-op unless memory_dir is set (cross-run priors)\n },\n}\n\n\n# The shipped agent-governance matrix (who may change which setting at runtime). Defined as a module\n# constant so the Engine's direct-construction default matches this shipped default exactly (the\n# EngineOptions \"Engine() == shipped defaults\" invariant) instead of diverging to an all-locked map.\nDEFAULT_AGENT_CONTROL: dict[str, list[str]] = {\n \"timeout\": [\"researcher\", \"strategist\"],\n # Layer-2 canonical names are the presented/default governance surface. `_agent_may` falls back\n # to an old snapshot's legacy grant only when its canonical counterpart is absent, so removing\n # duplicate defaults preserves resume authority while an explicit canonical revocation still wins.\n \"eval_parallel\": [\"strategist\"],\n \"llm_parallel\": [\"strategist\"],\n # Strategy-only pseudo-setting: the closed per-lane allocation inside the canonical LLM total.\n # It is not a launch Settings field; operators pin it through `set_strategy` and the Strategist\n # may reallocate it only while this grant is present.\n \"llm_lane_limits\": [\"strategist\"],\n \"max_nodes\": [\"strategist\", \"boss\"],\n \"max_eval_seconds\": [\"strategist\", \"boss\"],\n \"policy\": [\"strategist\"],\n # The policy NAME and its PARAMS are gated independently in `_apply_strategy`; grant both by default\n # so the Strategist that may switch to (say) MCTS may also apply the `c`/`eta` it decided — else its\n # tuned params are silently dropped and the recorded `active_strategy` diverges from the live engine.\n \"policy_params\": [\"strategist\"],\n \"n_seeds\": [\"strategist\"],\n \"ablate_every\": [\"strategist\"],\n \"merge_mode\": [\"strategist\"],\n \"complexity_cue\": [\"strategist\"],\n \"ablate_code_blocks\": [\"strategist\"],\n \"prefer_sweep\": [\"strategist\"],\n \"novelty_stance\": [\"strategist\"],\n \"developer\": [\"strategist\"],\n \"fidelity\": [\"strategist\", \"researcher\"],\n}\n\n\ndef default_agent_control() -> dict[str, list[str]]:\n \"\"\"A FRESH deep-ish copy of the shipped governance matrix (each role list copied so a caller can\n mutate its own map without touching the module constant). The SINGLE constructor of the default,\n called by both `Settings.agent_control`'s default_factory AND the Engine's direct-construction\n default — so \"Engine() == shipped defaults\" can't drift if the copy depth ever needs to change\n (e.g. nested values requiring copy.deepcopy), instead of two verbatim copy expressions.\"\"\"\n return {k: list(v) for k, v in DEFAULT_AGENT_CONTROL.items()}\n\n\nclass Settings(BaseSettings):\n \"\"\"The engine settings schema (every knob a run accepts).\n\n Timeout family — six distinct knobs, each owned by a different subsystem:\n - `timeout`: per-eval wall-clock budget for ONE experiment's evaluation (engine/eval).\n - `max_eval_timeout`: hard ceiling for a governed per-node Researcher timeout override.\n - `llm_timeout`: LLM request idle timeout — inter-token stall limit in stream mode\n (core.llm OpenAICompatibleClient).\n - `llm_header_timeout`: LLM first-byte (response-headers) window for stream attempts (core.llm).\n - `agent_time_budget_s`: wall-clock ceiling across a tool-using agent loop's turns (agents).\n - `dep_install_timeout`: per-package budget for auto-installing a missing dep (env self-prep).\n\n Config-source precedence is owned by `looplab.core.appconfig` (the loader) — see its module\n docstring for the one canonical order.\n \"\"\"\n # Config precedence: see appconfig.py — the loader owns the full order. The `.env` is read\n # so `looplab run`/`looplab ui` pick up LOOPLAB_LLM_BASE_URL etc. without exporting them by hand\n # (keys MUST carry the LOOPLAB_ prefix, same as the env vars). utf-8-sig tolerates a BOM from\n # Windows editors. The test suite disables this (tests/conftest.py) so a dev's real .env in the\n # repo root can't leak into assertions.\n model_config = SettingsConfigDict(\n env_prefix=\"LOOPLAB_\",\n env_file=\".env\",\n env_file_encoding=\"utf-8-sig\",\n extra=\"ignore\",\n allow_inf_nan=False,\n )\n\n @classmethod\n def settings_customise_sources(\n cls, settings_cls, init_settings, env_settings, dotenv_settings,\n file_secret_settings):\n \"\"\"Preserve source precedence across canonical/legacy parallelism names.\n\n Pydantic ranks init > env > dotenv correctly, but it ranks field names rather than semantic\n alias families. Promoting a legacy value inside each source before the merge makes an old\n explicit snapshot/file value beat a lower-priority canonical env value. ``appconfig`` also\n promotes its file/typed/--set sub-layers before combining them into the init source.\n \"\"\"\n def _with_parallel_aliases(source):\n return lambda: canonicalize_parallelism_source(source())\n\n return (\n _with_parallel_aliases(init_settings),\n _with_parallel_aliases(env_settings),\n _with_parallel_aliases(dotenv_settings),\n _with_parallel_aliases(file_secret_settings),\n )\n\n # Run profile (config-first preset, see PROFILES above). \"default\"/\"fast\" = today's lean\n # defaults; \"thorough\" turns the built quality/trust machinery ON in one word. A profile only\n # fills fields the user did NOT set — every knob it touches stays individually overridable.\n profile: str = \"default\"\n\n # Bounds (review C/⚪): a non-positive node/seed budget or timeout is never valid. Upper bounds are\n # equally load-bearing — the UI /start path writes these straight into the engine's env, and an\n # unbounded `eval_parallel` (or legacy `max_parallel`; e.g. 100000 from a crafted preflight) would\n # fan out that many sandboxes → resource exhaustion. Reject at config time, not mid-run. Ceilings\n # are generous; tests top out near 50.\n # CODEX AGENT: Pydantic coerces JSON booleans into these numeric fields (`true` -> 1/1.0), so a UI\n # type error can silently collapse run/eval budgets instead of returning 422. Apply shared\n # before-validation that rejects bool at every numeric settings boundary.\n n_seeds: int = Field(default=3, ge=1, le=1024)\n max_nodes: int = Field(default=8, ge=1, le=1_000_000)\n # legacy evaluation-width input. `eval_parallel` below is canonical and wins when set;\n # keep this field for old env/config/snapshots and direct Engine call sites. A width > 1 admits\n # concurrent evals, whose resource reservations may be CPU-only or span one/more GPUs — there is no\n # one-node/one-GPU promise. `max_parallel=0` is launch-time AUTO (detected GPU count, at least 1).\n max_parallel: int = Field(default=1, ge=0, le=1024)\n # legacy build-width input. `llm_parallel` below is canonical and also controls the\n # shared provider-call total when positive. A width > 1 uses fresh pooled researcher/developer pairs;\n # it says nothing about the later eval's CPU/GPU reservation. Startup `0` derives build fan-out from\n # resolved eval width; a live Strategist/operator zero settles to 1. Without role_factory it clamps to 1.\n parallel_build: int = Field(default=1, ge=0, le=64)\n # Hypothesis-card Kanban re-architecture (docs/23, Layer 2). Two INDEPENDENT concurrency axes,\n # decoupled from each other so cost (LLM work) can be tuned separately from throughput (GPU evals).\n # These are the CANONICAL names going forward; `max_parallel`/`parallel_build` above are kept as thin\n # back-compat aliases (env vars / snapshots / ~100 Engine(...) call sites depend on the exact names,\n # so they cannot be renamed — CLAUDE.md). Resolution (orchestrator.__init__): a NEW name, when set,\n # WINS over its legacy alias; None => fall back to the legacy field => byte-identical to today.\n # `eval_parallel`: concurrent EVALS (the GPU/experiment consumer). None -> max_parallel; 0 = AUTO\n # (one experiment per detected GPU). This is the in-Run eval-concurrency ceiling; the resource\n # scheduler bin-packs declared footprints separately. Sibling local GPU-owning Runs serialize\n # through the conservative host-pool lease; external execution needs its own admission boundary.\n # `llm_parallel`: total concurrent LLM provider calls + BUILD fan-out. None -> legacy\n # parallel_build semantics without a shared total; 0 = startup AUTO (= resolved eval_parallel\n # for build fan-out, historical research overlap unbounded). A positive canonical value enables\n # the named-lane broker the Strategist can reallocate at cadence.\n eval_parallel: int | None = Field(default=None, ge=0, le=1024)\n llm_parallel: int | None = Field(default=None, ge=0, le=64)\n # Training-log monitor (I-series watchdog family): a per-eval background observer that tails the live\n # training log while a (often multi-hour) declared stage runs in a worker thread. ON in the product\n # surface (Settings). Its alert event is fold-ignored and cannot directly change lifecycle/champion;\n # with watchdog_reflection on, that alert is nevertheless advice in a later Researcher\n # prompt and can change future proposals. It only fires on the command-eval path with an LLM client;\n # bare-library EngineOptions stays OFF. See test_options_divergence.\n # `_interval_s` is the BASE tick cadence (the effective one adapts to the per-experiment budget).\n train_monitor: bool = True\n train_monitor_interval_s: float = Field(default=600.0, gt=0)\n # Phase 3 — INTERVENTION (opt-in, separate from observation): let the monitor tree-kill a training the\n # LLM judges 'broken' (diverged / silent CPU fallback / not learning) EARLY, instead of burning the\n # whole budget. Off by default — the observer only WATCHES unless this is on. A kill only fires on a\n # 'broken' verdict (a plateau is 'watch', never killed) with confidence >= the threshold; the node then\n # fails normally (reason='monitor_broken'), so replay reconstructs it from that one terminal event.\n train_monitor_kill: bool = False\n train_monitor_kill_confidence: float = Field(default=0.8, ge=0.0, le=1.0)\n # ASHA live-curve watchdog (sibling of the training monitor): reads the latest INTERMEDIATE value of\n # the objective metric off the live log (reusing the eval's OWN metric reader). Finished-endpoint rank\n # remains advisory. An opt-in KILL additionally requires an operator-declared metric.resource_key and\n # enough sibling observations at exactly that resource; absent comparable evidence cannot stop a run.\n # Records a fold-ignored `asha_rank` diagnostic + trace span; the library default is OFF (off == today).\n asha_live: bool = True\n # Opt-in INTERVENTION (separate from the advisory signal): let the watchdog tree-kill a node whose\n # intermediate metric stays below the SAME-RESOURCE bar past a short grace window. Off by default —\n # it only surfaces endpoint rank unless this is on. A kill fails normally (asha_underperforming).\n asha_live_kill: bool = False\n # The bar sits at `asha_live_quantile` along a WORST->BEST ordering of the finished siblings' finals:\n # 0.5 = the median (a node worse than the median peer flags); a SMALLER value is more conservative —\n # it lowers the bar toward the WORST finished peer, so only a node worse than nearly all peers flags\n # (0.0 = worse than the worst). `min_siblings` is the floor of finished peers required before it ranks\n # at all, so it never acts on too little evidence.\n asha_live_quantile: float = Field(default=0.5, ge=0.0, le=1.0)\n asha_live_min_siblings: int = Field(default=3, ge=1)\n timeout: float = Field(default=30.0, gt=0)\n # Hard ceiling for the Researcher's governed per-node `Idea.eval_timeout`. One hour preserves the\n # existing heavy/neural-net authoring example while staying well below the sandbox's defensive\n # 24-hour subprocess ceiling. This is operator-owned run config: agents may request less or more,\n # but the accepted action is always clamped here after the `agent_control.timeout` permission gate.\n max_eval_timeout: float = Field(default=3600.0, gt=0, le=24 * 3600.0)\n # Intra-node sweep: a sweep node runs a whole grid in one process, so it gets this multiple of\n # `timeout` as its wall-clock budget (solution.py path; RepoTasks use their per-profile timeout).\n sweep_timeout_mult: float = Field(default=8.0, ge=1)\n # Eval STALL watchdog CAP (seconds): a stage that is completely SILENT on stdout/stderr for this\n # long — while still alive and BELOW its wall-clock deadline — is tree-killed early with a STALLED\n # marker (a hung dataloader/deadlock dies in minutes instead of burning a multi-hour timeout). The\n # per-stage window is `min(this, the stage's own timeout)`, so a short scorer's deadline fires first.\n # `0` DISABLES the watchdog entirely (only the hard deadline applies) — for a legitimately quiet\n # non-Python stage (block-buffered stdout, a script that logs only to its own file). Threaded into\n # the eval and surfaced to the Developer so its code can emit periodic progress to stay alive.\n eval_stall_timeout_s: float = Field(default=1800.0, ge=0)\n # Sandbox tier (ADR-13): \"trusted_local\" (subprocess, no Docker) for the CLI;\n # \"untrusted\" (Docker --network none, shared-kernel runtime) for hosted/multi-tenant UI;\n # \"hostile\" (untrusted + a true-isolation OCI runtime, gVisor `runsc` by default / Kata) for\n # running untrusted code. See runtime/sandbox.py::make_sandbox for the tier selection.\n trust_mode: str = \"trusted_local\"\n # Image for the untrusted command-eval tier (RepoTask, Phase 4): the framework's deps\n # should be baked in (the container runs --network none, so a pip setup can't fetch).\n docker_image: str = \"python:3.12-slim\"\n # Resource caps for the untrusted/hostile Docker tier (passed to `docker run --memory/--cpus`).\n # The default 4g bound protects other tenants from an OOM, but a model-training MLE-bench eval\n # routinely needs more — raise it (e.g. \"16g\") for such runs. \"\" disables the cap (unbounded).\n # Ignored by the trusted_local (subprocess) tier.\n sandbox_memory: str = \"4g\"\n sandbox_cpus: str = \"\"\n # Best-effort host-OOM guard for the TRUSTED-LOCAL (subprocess) tier. When set (e.g. \"8g\"), each\n # eval child gets an RLIMIT_AS address-space cap so a runaway trainer hits MemoryError instead of\n # OOM-killing the whole host (and the engine). POSIX only; a no-op on Windows. Default \"\" = OFF:\n # RLIMIT_AS bounds VIRTUAL memory, and CUDA/torch reserve tens of GB of virtual without using it,\n # so a default cap would spuriously kill GPU evals — leave it off for those and use the Docker\n # tier's real --memory cgroup bound (sandbox_memory) instead. Ideal for CPU/numpy/sklearn runs.\n sandbox_memory_local: str = \"\"\n # Best-effort disk-fill guard for the TRUSTED-LOCAL tier (P1-5): an RLIMIT_FSIZE cap on the size of\n # any single file an eval child writes (e.g. \"2g\"), so a runaway that writes an unbounded file gets\n # SIGXFSZ instead of filling the host disk. POSIX only. Default \"\" = OFF (large model checkpoints\n # need big files); set it for tasks that write only small artifacts.\n sandbox_fsize_local: str = \"\"\n # Search policy (ADR-2): \"greedy\" | \"evolutionary\" | \"mcts\" | \"asha\".\n policy: str = \"greedy\"\n # Ablation-driven refinement (I7): every N improves, ablate the best to find the\n # highest-impact parameter and refine it. 0 = off. (GreedyTree only.)\n ablate_every: int = 0\n # A0a (MLE-STAR): when ablating, treat each generated pipeline *code block* (not just a\n # numeric param) as an ablation unit — neutralize a block, measure the metric delta, refine\n # the highest-impact block. Off => the classic numeric-param ablation. Config-first knob.\n ablate_code_blocks: bool = False\n # A0b: real merge/ensembling. \"mean\" = legacy mean-param merge; \"ensemble\" = the Developer\n # writes a code-recombination ensemble over the two parents' solutions (verified: agent-proposed\n # ensembling 37.9%->43.9%). \"auto\" (default) = ensemble whenever the Developer actually\n # generates code (LLM/agent backends; declared via `is_code_generating`), mean for the\n # templated/toy developers where a code ensemble is meaningless — the evidence says code\n # recombination is the strongest single merge (removing it costs ~9 pp), so it is the default\n # wherever it can work. Falls back to mean when the Developer can't ensemble.\n merge_mode: str = \"auto\"\n # A0d (AIRA): inject a dynamic complexity hint into the draft/improve prompt keyed on the\n # node's child count (few children -> keep minimal; many -> escalate to ensembling/HPO).\n complexity_cue: bool = False\n # I1 (CAAFE-style) LLM feature-engineering: instruct the proposer/developer to add\n # semantically-meaningful engineered features (ratios, interactions, aggregations) as code. The\n # existing cross-validation eval + best-selection is the CV GATE — a feature set that doesn't\n # improve CV is never selected (feature engineering is non-universal, so this gate is mandatory).\n # Tabular tasks (those exposing column data). Off by default.\n feature_engineering: bool = False\n # A5: surface the REMAINING eval-compute budget into the proposal prompt so the Researcher can\n # reason over how much compute is left (explore broad while flush, exploit/cheapen when low).\n # No-op unless a max_eval_seconds budget is set.\n budget_aware: bool = False\n # A4 (LATS-style): feed a summary of the most recent FAILED branches (operator + error reason)\n # back into the proposal prompt so the proposer reflects on and avoids repeating them. ON by\n # default: it is SELECTIVE by construction (injects only when recent failures exist — the\n # evidenced form of reflection; reflecting at every step shows no gains, arXiv:2506.12928).\n failure_reflection: bool = True\n # Feed the LIVE-WATCHDOG observations (train-monitor health verdicts + ASHA intermediate-rank flags)\n # of recent experiments back into the proposal prompt, so the Researcher avoids re-proposing a config\n # whose TRAINING was already observed to be weak — even when the watchdog kills are off (default) and\n # the node ran to completion (those diagnostics are fold-ignored, so failure_reflection never sees\n # them). ON by default and SELECTIVE like failure_reflection (injects only when a recent flag exists).\n watchdog_reflection: bool = True\n # C3 deep test-driven repair: hand the Developer the failure taxonomy + a structured \"reproduce\n # then fix\" directive on debug, not just the raw stderr tail. ON by default (product surface); the\n # conservative library default in EngineOptions stays off, per that module's contract.\n deep_repair: bool = True\n # === Inline crash repair ==============================================================\n # Hybrid in-node crash repair: when an LLM-generated node CRASHES at runtime (mechanical errors\n # — bad import, removed kwarg, typo), the agent triages it and may repair the code IN PLACE within\n # the same eval (cheap; does NOT consume max_nodes and does NOT add a node to the search tree).\n # Semantic failures still flow to a new idea/debug node via the policy. ON by default; set False\n # to restore the prior behavior (every crash waits for a budgeted inter-node debug node).\n inline_repair: bool = True\n # Max in-place repair attempts per node before it fails normally. Default 0 = UNLIMITED: the same\n # agent keeps fixing + re-evaluating IN THE SAME node until it produces a clean metric — no new\n # node just to retry. Runaway is prevented not by a count but by the anti-stuck guard\n # (`inline_repair_stuck_repeat`): when the SAME error signature repeats with no progress, or the\n # agent's crash-triage says \"abandon\", the node fails. Set a positive N to cap the retries instead.\n inline_repair_attempts: int = Field(default=0, ge=0)\n # Anti-stuck: abandon in-node repair when the SAME error recurs this many times in a row (no\n # progress). Keeps \"unlimited\" repair from looping forever on an unfixable error.\n inline_repair_stuck_repeat: int = Field(default=4, ge=2)\n # Which failure reasons (_failure_reason: crash|timeout|oom|setup|no_metric|drift) are eligible for\n # inline repair. Default: mechanical crashes, timeouts AND OOM-kills — a timeout/OOM means the code\n # was too slow / too memory-hungry for the budget (or the pod's cgroup limit), not that the idea is\n # wrong, so the agent repairs it by reducing compute/memory (fewer estimators/epochs/folds/seeds, a\n # lighter model, a smaller batch, subsampling) instead of letting the node die unrecovered. Env\n # override expects a JSON array.\n inline_repair_reasons: tuple[str, ...] = (\"crash\", \"timeout\", \"oom\")\n # Cost ceiling for in-node repair of a MULTI-STAGE eval: the max number of FULL pipeline re-runs\n # (i.e. re-trains from the first stage) the inline-repair loop may do before abandoning to the\n # inter-node debug operator. A late-stage failure (a broken `score`/eval script that didn't touch\n # `train`) REUSES the completed train checkpoint and re-runs only from the failed stage — that is\n # CHEAP and NOT counted here; only a repair that changes an EARLIER stage's code (train.py/loss.py/…)\n # forces a full re-train, which each costs the whole training time. Without this bound, a repair that\n # keeps changing training code could burn many full trains (the anti-stuck guard is error-signature\n # based, not cost based). 0 = unlimited (legacy behavior). Single-command evals ignore it.\n inline_repair_retrain_cap: int = Field(default=2, ge=0)\n # Environment self-prep: when a solution crashes purely because a KNOWN library isn't installed\n # (ModuleNotFoundError), the engine pip-installs it into the eval interpreter and re-runs — so a\n # torch/XGBoost/CatBoost idea (e.g. a GRU model) actually runs on a fresh box instead of being\n # thrown away as `idea_rejected`. Trusted_local tier only (the untrusted/hostile Docker tiers run\n # --network none). A typo'd or local helper module is NOT installed (that's a code bug to repair).\n auto_install_deps: bool = True\n # Per-package wall-clock budget for an auto-install (seconds). Generous: large wheels like torch\n # can take minutes to download/build.\n dep_install_timeout: float = Field(default=900.0, gt=0)\n # --- Agent governance: who may change a setting at runtime (per-setting allow-list of roles) ---\n # Roles: \"strategist\" (A7 meta-controller; run-wide), \"boss\" (run-chat operator-proxy; run-wide),\n # \"researcher\" (per-experiment proposer; PER-NODE — e.g. set a longer eval timeout for a neural-net\n # idea). A setting ABSENT from this map is LOCKED — no agent may change it, only a human via the\n # snapshot/UI. The sole conditional exception is Strategy-only `card_scoring`: Card mode grants\n # it implicitly without changing the default flag-off snapshot, while an explicit [] revokes it.\n # The gate is ENFORCED at every AGENT seam via `_agent_may` — the strategist's whole\n # control surface (policy/operators/novelty_stance/prefer_sweep/developer/fidelity/timeout/\n # eval_parallel/llm_parallel) is checked in `_apply_strategy`, so removing a role from a knob locks it,\n # not just greys out the UI pill. The boss/max_* grants document boss-relevant knobs, but a\n # `budget_extend` is a HUMAN control intent (the boss action-builder can only emit `add_nodes`), so\n # it is applied as-is — the human always wins via the UI/snapshot. Defaults are conservative: resource/\n # search-shape knobs the agents already reason about; provider infra (llm_model/base_url/api_key,\n # docker_image) stays locked. The UI shows S/B/R pills per setting. `timeout` granted to the\n # researcher IS the \"auto\" per-node mode — the researcher sizes each experiment's wall-clock\n # (Idea.eval_timeout); the config `timeout` is the fallback for nodes it doesn't size. Env override\n # expects a JSON object. (`fidelity`/`novelty_stance`/`prefer_sweep`/`developer` are governance keys\n # for the strategist's per-node dials — not 1:1 Settings fields, but gated the same way.) The default\n # lives in the module-level DEFAULT_AGENT_CONTROL so the Engine's direct-construction default matches.\n agent_control: dict[str, list[str]] = Field(default_factory=default_agent_control)\n # C1 (Agentless localization): for repo tasks, rank the source files most relevant to the most\n # recent failure (traceback + identifiers) and surface them in the proposal/repair prompt so the\n # Developer edits the right place. Off by default; repo tasks only.\n localize_faults: bool = False\n # A2 surrogate-guided proposal (BO-lite): fit a k-NN surrogate over (params->metric) and propose\n # by acquisition instead of random/hill-climb. Numeric-bounds tasks only; bootstraps via the\n # base Researcher. Off by default. `surrogate_explore` = UCB-style exploration weight.\n surrogate_proposer: bool = False\n surrogate_explore: float = 0.1\n # E2 researcher panel: generate K candidate ideas per proposal and keep the one ranked best by a\n # cheap empirical surrogate over the (params->metric) history (NOT an LLM-judge). 1 = off.\n researcher_panel: int = 1\n # A1 ASHA / successive-halving: reduction factor (keep top 1/eta per rung) and rung budget.\n asha_eta: int = Field(default=3, ge=2)\n # Rung-0 width for ASHA/BOHB (the wide base of cheap drafts). 0 = use n_seeds (default,\n # preserving prior behavior); set >0 to seed a wider/narrower base independent of n_seeds.\n asha_rung_nodes: int = Field(default=0, ge=0)\n # A6 proxy/predictive scoring: cheaply rank a candidate's potential from early-stage signals\n # and skip a full eval for the doomed bottom fraction. 0.0 = off (no candidate skipped).\n proxy_scoring: bool = False\n proxy_kill_fraction: float = Field(default=0.0, ge=0.0, le=0.9)\n # === Novelty / dedup ==================================================================\n # E1 novelty/dedup gate: reject near-duplicate proposals (normalized param-space distance below\n # `novelty_epsilon`) by deterministically nudging them off the duplicate — stops the search\n # wasting evals re-trying the same idea. Audit event `novelty_rejected`. Off by default.\n # Novelty/dedup MODE selector: how a fresh proposal is checked against already-tried experiments.\n # \"off\" — no explicit gate; the agentic Researcher's own read-the-history judgment is trusted.\n # \"algo\" — the deterministic gate: numeric param-distance (`novelty_epsilon`) + optional embedding\n # similarity (`novelty_semantic`). Cheap, no LLM call, but can't explain itself.\n # \"llm\" — an LLM adjudicates (reads the real experiments via tools) whether the idea is a\n # near-duplicate and, if so, asks the Researcher once more for something different. One\n # extra LLM call per proposal — highest quality, follows the \"let the LLM decide\" line.\n novelty_mode: str = \"llm\"\n # Legacy sub-toggles, honored by the \"algo\" mode (and back-compat: novelty_gate=True forces \"algo\"):\n novelty_gate: bool = False\n novelty_epsilon: float = 0.05\n # T5 semantic novelty (active whenever the deterministic gate runs — novelty_mode=algo,\n # novelty_gate=true which is the legacy alias for algo, OR the Strategist novelty stance is\n # \"explore\"; a no-op only under novelty_mode=llm/off with a non-explore stance): reject a proposal\n # whose idea TEXT embeds within\n # `novelty_semantic_threshold` cosine of an existing node's. OFF BY DEFAULT: novelty is the agentic\n # Researcher's call, made by READING the actual prior experiments (read_experiment /\n # find_analogous_across_runs / list_experiments), NOT an embedding-similarity auto-reject that can't\n # explain itself and mis-fires on paraphrases. Semantic/param search stays available as TOOLS in the\n # Researcher's context; the LLM decides whether an idea is a true duplicate.\n novelty_semantic: bool = False\n novelty_semantic_threshold: float = Field(default=0.92, ge=0.5, le=1.0)\n # T10: debug-lineage depth bound for every policy — how many error-feedback repairs a failing\n # lineage gets before it is abandoned. The old default (1) abandoned lineages after ONE repair;\n # multi-turn/deeper debugging is a verified lever (AIRA2 ReAct debug +5.5 percentile points).\n debug_depth: int = Field(default=2, ge=1)\n # P4 operator bandit (GreedyTree): replace the fixed merge/ablate cadences with a\n # deterministic UCB over per-operator yield (Δmetric per eval-second, folded from the run).\n # Off by default (the cadences are well-tested and the bandit has no direct published\n # ablation); `thorough` turns it on.\n operator_bandit: bool = False\n # P1 hypothesis ledger: ask the Researcher to state the one-line hypothesis each experiment tests,\n # register deep-research directions as hypotheses, and track them to a verdict on the board. ON by\n # default. The board is shown to the Researcher and can be foresight-ordered, so it can\n # steer later proposals even though it never directly re-ranks already-evaluated nodes. Set False\n # to drop the prompt nudge + registration.\n track_hypotheses: bool = True\n # E4/M2/M3 reflection-memory -> priors (gradient-free cross-run meta-learning): at run end distill\n # the winner into `<memory_dir>/meta_notes.jsonl` AND structured lessons (incl. NEGATIVE results:\n # tested/abandoned hypotheses + failure themes) into `lessons.jsonl` with a task fingerprint; at run\n # start, inject exact-task notes + fingerprint-matched lessons from SIMILAR tasks into the proposal\n # prompt. ON by default — but a NO-OP until `memory_dir` is set (that's where cross-run memory lives).\n reflection_priors: bool = True\n # M6 comparative lessons (MARS \"comparative reflective memory\", doc 13 §7 item 2): distill\n # credit-assigned lessons from PAIRS of solutions — which SPECIFIC change made a child beat\n # (or regress from) its parent, and what fixed a failure — on top of the one-shot run-end\n # reflection. At most one extra LLM call per distillation (all pairs batched); offline a\n # deterministic param-diff credit stands in. Gated on reflection_priors + memory_dir like the\n # rest of cross-run memory — and since memory_dir has a default (~/.looplab/memory), this is\n # ACTIVE out of the box; clear memory_dir to disable cross-run memory wholesale.\n comparative_lessons: bool = True\n # M6 mid-run cadences (doc 13 §7 item 5 — the AgentRxiv live-share pattern): every N created\n # nodes, WRITE comparative lessons to the shared cross-run store during the run (lessons_every\n # — one batched LLM call per firing) and RE-READ the store so lessons distilled by CONCURRENT\n # runs reach this run's proposals (lessons_refresh_every; file re-read, no LLM call, skipped\n # when the store is unchanged). ON by default (every 4 nodes) and, like comparative_lessons\n # above, live out of the box because memory_dir defaults on; 0 = off — run-end write /\n # run-start read only, the pre-M6 behavior.\n lessons_every: int = Field(default=4, ge=0)\n lessons_refresh_every: int = Field(default=4, ge=0)\n # B3 output redaction: mask credentials (known key shapes + high-entropy tokens) in the\n # stdout/stderr tail before it is persisted to the event log / spans / UI — a leaked secret in a\n # print()/traceback must not land in the durable log. Off by default; recommend on for untrusted.\n redact_output: bool = False\n # B5 reward-hacking detector: a host-side monitor that flags suspicious wins (grader/answer-key\n # access, runtime writes to frozen files, suspiciously-perfect metrics) as a `reward_hack_suspected`\n # audit event in the Trust panel. Off by default. Whether a flag CHANGES selection is governed by\n # `trust_gate` below (default: audit-only, never changes selection).\n reward_hack_detect: bool = False\n # Trust enforcement (T2): what a reward-hack / data-leakage flag actually DOES to selection.\n # \"audit\" (default) = surface it in the Trust panel, never change selection (today's behavior);\n # \"gate\" = a flagged node can no longer be selected as BEST **and isn't bred/confirmed from**\n # (breed_excluded — see replay._apply_trust_gate / models.breedable_nodes), but stays\n # FEASIBLE so it still counts for diversity/audit — closes the \"a hacked/leaky node can\n # win OR seed its lineage\" hole;\n # \"block\" = additionally mark it fully INFEASIBLE (removed from feasible_nodes entirely).\n # Deliberately gates only high-precision CHEATING/LEAKAGE signals. Broad `critic:*` findings stay\n # advisory; `critic:hardcoded_metric` is the one narrow high-precision critic exception\n # and gates because it requires a literal metric with no computed assignment anywhere.\n trust_gate: str = \"audit\"\n # I3 data-centric: static code-leakage scan of each evaluated solution (fit-before-split,\n # fit-on-test) surfaced into the Trust panel alongside reward-hack flags. Off by default.\n code_leakage_detect: bool = False\n # 4.4 sandbox instrumentation (needs reward_hack_detect): after each eval, flag a RUNTIME write\n # to a protected/frozen file (an answer key or grader tampered mid-run) — behavioral evidence a\n # static code scan misses. ON by default; only fires when the reward-hack detector is on.\n workdir_audit: bool = True\n # D8 decoupled research Verifier: check every Deep-Research memo's CLAIMS against their cited\n # evidence (node ids / urls) before recording — synthesis is the documented weak link (Kosmos\n # 57.9% accurate). Deterministic layer always; an LLM rubric pass when the DeepResearcher has a\n # client. The verdict cannot change this run's node/champion, but finalize uses aligned supported\n # verdicts as the evidence gate for persisted D8 cross-run claims; unavailable/misaligned stays\n # unverified. ON by default (no-op with no memo/claims).\n research_verify: bool = True\n # C4 independent critic: an execution-free critic of each solution (stub / hardcoded-metric /\n # params-ignored; on host-graded tasks the metric checks become a submission-output check)\n # surfaced in the Trust panel. Broad findings are advisory; `critic:hardcoded_metric` can gate under\n # trust_gate=gate/block. Off by default.\n critic_check: bool = False\n # A7 Strategist (NEW, user-requested): optional LLM/rule meta-controller that picks the search\n # policy/allocator + operator mix + fidelity (+ Developer backend) per situation. Config-first:\n # every choice the Strategist makes is also a direct knob. Defaults to \"agent\" so the agent adapts\n # the search policy/operators/fidelity per situation; set \"off\" for static config-driven search.\n # \"agent\" = a tool-using Strategist that READS the run/data/sibling-runs/KB/memory (B1-guarded)\n # before deciding, instead of the single-shot \"llm\" call over aggregate stats.\n strategist_backend: str = \"agent\" # \"off\" | \"rule\" | \"llm\" | \"agent\" — default AGENTIC: the\n # Strategist READS the run/data/sibling-runs/KB/memory with tools before deciding, not a single-shot\n # call over aggregate stats. \"llm\" = the old non-agentic single-shot; \"rule\"/\"off\" = no LLM.\n strategist_every: int = Field(default=3, ge=1) # consult cadence (created nodes)\n # PART V (F1): concept CLASSIFIER re-tag + consolidation cadence, DECOUPLED from strategist_every. The\n # LLM concept map is heavier and slower-moving than a strategy consult, so it refreshes on its own\n # (sparser) interval. Researcher-authored idea.concepts still fold immediately at node_created — this\n # only paces the classifier-evidence + consolidation refresh (and the concept-coverage pivot snapshot).\n concept_retag_every: int = Field(default=30, ge=1)\n # === Confirmation & holdout ===========================================================\n # Multi-seed confirmation (I12, ADR-15): confirm the top-k under N seeds before\n # finishing. 0 disables (default). Only meaningful when eval has variance.\n confirm_top_k: int = 0\n confirm_seeds: int = 0\n # D1 seed-holdout discipline: the FIRST seed the confirm phase uses. Search evals run with the\n # implicit seed 0 (LOOPLAB_EVAL_SEED unset -> \"0\"), so a base of 1 keeps every confirm split\n # DISJOINT from anything the search optimized against — the confirm metric is then a\n # generalization signal, not a re-measurement (AIRA: selecting on a signal the search saw\n # overfits by 9-13 pp). Set 0 to restore the legacy overlapping seeds 0..N-1.\n confirm_seed_base: int = Field(default=1, ge=0)\n # D1 holdout-gated promotion (B6, Arbor-style): for host-graded tasks, reserve this fraction of\n # the held-out labels as a FINAL holdout partition the search never sees — every search/confirm\n # eval is scored on the remaining rows only, and at finish the val-top-k are re-scored on the\n # holdout partition (free: the predictions already exist). 0.0 = off (legacy full-label scoring).\n holdout_fraction: float = Field(default=0.25, ge=0.0, le=0.9)\n # Champion selection prefers the HOLDOUT metric among the nodes that have one (the val-top-k).\n # Recorded in run_started so replay applies the same rule; old logs fold to False (legacy pick).\n # The per-node val-holdout `generalization_gap` folds into the Trust panel either way.\n holdout_select: bool = True\n # How many val-leaders get a holdout evaluation at finish (host-graded tasks).\n holdout_top_k: int = Field(default=3, ge=1)\n # R1-c / Part IV — calibrated §12-verifier tie-break in best-selection. When a fresh node's robust\n # metric EXACTLY TIES an already-eligible node, the engine runs the §12 verifier (grounded on each\n # node's realized result, `selection_criteria`) and records a soundness score per node. The tie-break\n # then applies across the WHOLE selection path: the mean pick breaks a robust_metric tie by the score,\n # and — when `holdout_select` is on — the holdout-slot ranking prefers the sounder node so it isn't\n # denied a holdout eval, and the final holdout override breaks a holdout_metric tie by it too. Strictly\n # a TIE-BREAK — it can NEVER move a node ahead of a strictly-better metric (§21.7 advisory-never-\n # overrides). Best-effort: verification is LAZY (only fires on an actual tie) and bounded per cadence,\n # so a large/late tie stays on deterministic metric+id order unless one atomic treatment scores every\n # member. Recorded in run_started so\n # replay applies the same rule; old logs fold to False (legacy pick). Opt-in (default off); needs a\n # reachable LLM client. Enable only after validating calibration offline with `verifier.calibrate`.\n # See trust/verifier.py + core/fitness.py.\n select_verifier: bool = False\n # R1-d (§21.19): widen the verifier tie-break from EXACT-metric to a conservative STATISTICAL tie.\n # The band is capped by both the metric leader's SE and the pooled SE-of-difference, so challenger\n # variance cannot widen it and a significant difference is never a tie (§21.7). Off -> exact only.\n verifier_ci_tie: bool = False\n # Verifier sample count for `select_verifier` (the §12 repeated-sampling expectation). 3 tames the\n # single-shot variance; 1 = single-shot (cheaper, noisier).\n select_verifier_samples: int = Field(default=3, ge=1, le=32)\n # Budget (I13): hard wall-clock ceiling; the run aborts cleanly when exceeded.\n max_seconds: float | None = None\n # Eval-compute budget (#2): hard ceiling on cumulative time spent INSIDE evals (training\n # runs), separate from wall-clock. Survives resume (summed from the event log). The real\n # guard against a silent multi-hour sweep when an eval is a minutes-hours training run.\n max_eval_seconds: float | None = None\n # Cross-run memory (I19, ADR-10): if set, the best result of each run is stored as\n # a case here, and the cases become retrievable knowledge for future runs.\n memory_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"memory\"))\n # HITL (I21, ADR-11): pause for human approval of the final best before finishing.\n require_approval: bool = False\n # Diversity archive (I22): niche bucket width in parameter space.\n archive_resolution: float = 1.0\n # Coverage read-model (narrowing signal): at the strategist cadence, compute a breadth summary\n # (themes / param-niches / theme entropy / dominant-theme fraction) from the folded run, record\n # it as a `coverage_snapshot` audit event, and surface it into the Strategist's decision context.\n # Deterministic, cheap, and purely additive context — no search-behavior change on its own. On by\n # default (like the rest of the always-on situational context). See search/coverage.py.\n coverage_context: bool = True\n # PART IV Phase 2a live steering (§21.11/§21.13). When on, the `concept_retag_every` cadence (NOT\n # `strategist_every` — the producer gates on `_should_consult_concepts`, which uses the seed boundary\n # + `concept_retag_every`, default 30) records a\n # concept-graph coverage + uncovered-region snapshot (deterministic heuristic tagger over the\n # task-type skeleton) and, on an `explore` stance, the Researcher's novelty hint names the exact\n # uncovered regions (\"0 coverage in {negatives/external-mining, distillation} — go there\") instead\n # of the vague \"broaden\". ON by default in the product Settings (ce4a379), EngineOptions off: it\n # only enriches an already-mode-gated explore hint, never forces exploration, and no-ops for a task\n # with no curated concept skeleton. The snapshot is auditable, but its prompt cue can change future\n # candidate generation; it does not directly rewrite best-metric selection. See search/concept_graph.py.\n # DESIGN NOTE (2026-07-17 critique): now ON unconditionally, but a toy / non-ML task yields EMPTY concept\n # tags while still paying the per-node LLM concept cost (~10 calls/node with the full Part IV/V bundle).\n # Consider AUTO-QUIESCING: if the heuristic tagger returns empty for the first N nodes (no concept signal),\n # skip the LLM concept work for the run — keeps it on for ML tasks, free on toy/degenerate ones.\n concept_pivot: bool = True\n # PART IV Phase 2b — D3 graded novelty into the LIVE novelty gate (§21.4/§21.13). When on and the\n # task has a curated concept skeleton, the novelty gate first grades a proposal over the concept\n # graph: a level-4 \"same direction, DIFFERENT implementation\" or a level-5 \"re-opens a\n # wrongly-abandoned FAILED direction\" proposal is ALLOWED through unchanged — the flat LLM/semantic\n # dedup gate can't tell \"this DCL tweak\" from \"the whole DCL branch\" and would wrongly reject a\n # legitimate variant or never re-open a sound-but-killed direction (the node_63 archetype). Levels\n # 1/2/3 (identical / near-dup / prior-run) still fall through to the existing gate. Deterministic\n # (heuristic tagger, no LLM), audit event `novelty_graded`, replay-safe. ON by default in the product\n # Settings (ce4a379), EngineOptions off; no-ops for a task with no skeleton. See search/graded_novelty.py.\n graded_novelty: bool = True\n # PART IV Phase 2b — D7 capability-expansion forced-jump DIRECTIVE (§21.8/§21.13, issue #7). When on\n # and the concept-graph cadence detects action-space LOCK-IN (the search has stayed inside one D5\n # branch for a long consecutive streak) on an `explore` stance, the Researcher's novelty hint\n # ESCALATES from \"broaden\" to \"expand the ACTION SPACE — build the missing infra (external mining,\n # a new data pipeline, a different eval), do NOT swap another {locked} lever.\" The same\n # gate also stamps the proposal's authoritative operator as `expand`; that node competes normally\n # under SearchFitness and its separate operator yield is measured. It does not force a winner.\n # Reads the 2a concept-coverage snapshot, so it needs `concept_pivot` to record one. OPT-IN\n # (default off). See engine/proposal_cues.py + search/lock_in.py.\n capability_expansion: bool = False\n # PART IV cross-run Step 0 (§21.20.12/§21.20.13). Universal task-fingerprint tokenization: drop the\n # ASCII-only `[a-z0-9]` allowlist on goal keywords so a non-Latin goal (Russian, CJK, …) is NOT\n # silently dropped from its cross-run fingerprint (today such a goal keys only on kind/dir/metric and\n # never reaches SIMILAR-task priors/lessons/cases). Uses `[^\\W_]+`/`.casefold()` — same splitting as\n # before, any script. ON by default in the product Settings (ce4a379); the bare-library EngineOptions\n # default stays off, so a run pinned to it won't silently re-key a portfolio mid-flight. See engine/memory.py.\n fingerprint_universal: bool = True\n # PART IV cross-run Step 2 (§21.20). Fill graded-novelty's `prior_concepts` from the cross-run\n # ConceptCapsuleStore: at run end write a per-run concept capsule (the shipped `node_concepts` tags +\n # best outcome, keyed by task_fingerprint) to `memory_dir`; when a later SIMILAR run proposes an idea\n # whose concept was tried before, SURFACE the prior outcome as a `cross_run_prior` audit event (never\n # reject — D3 level 3). Audit-only, off the selection path; ON by default in the product Settings\n # (ce4a379), EngineOptions off. Needs a `memory_dir` to share capsules.\n # See engine/memory.py (ConceptCapsuleStore) + engine/novelty.py.\n # PREREQUISITES (this flag is NOT standalone): the WRITE needs per-run concept tags\n # (`node_concepts`, produced by `concept_pivot`/F1 tagging) — no tags => no capsule; the SURFACE path\n # runs inside the graded-novelty precheck, so it also needs `graded_novelty` on. With only this flag +\n # `memory_dir` it persists/surfaces nothing. Enable `concept_pivot` + `graded_novelty` alongside it.\n # A non-Latin (Russian/CJK) portfolio should ALSO set `fingerprint_universal`, else the capsule's goal\n # tokens are dropped and its fingerprint over-matches on kind/dir/metric alone (the novelty direction\n # gate narrows this, but goal-word similarity is still lost).\n cross_run_concepts: bool = True\n # PART V (B): run-base + node-DELTA concept authoring. When on, once the first evaluated node has\n # authored concepts the engine seeds RunState.run_base_concepts from them (a one-shot EV_RUN_CONCEPTS,\n # idempotent because it only fires while the base is empty), and the Researcher's proposal hint tells it\n # to author only `concepts_added`/`concepts_removed` vs the run base + its parent — minimal per-node\n # annotations that inherit down the DAG (a swap propagates). Off -> every node authors its full concept\n # set as before. Product-on; the bare EngineOptions mirror defaults off (test_options_divergence).\n concept_run_base: bool = True\n # PART IV cross-run Step 5 advisory (§21.20.5). Fold the bounded cross-run CONTEXT PACK — evidence-\n # grounded claims with BOTH support and counter-evidence (Step 4) + a portfolio-coverage line (Step 3) —\n # into the Researcher's proposal prompt, exactly like the E4 cross-run prior note. Advisory ONLY: it is\n # prompt-grounding: it can change the Researcher's later candidates, but does not directly rewrite\n # best-metric selection (§21.7). Reads `memory_dir` (lessons.jsonl + concept_capsules.jsonl); \"\" when\n # empty. ON by explicit experimental product choice; no frozen A/B validation is claimed. The\n # bare-library EngineOptions default stays off (engine/options.py, frozen in\n # test_options_divergence). See engine/proposal_cues.py.\n cross_run_advisory: bool = True\n # PART IV cross-run §21.20.13 (full CR of the lean fuzzy claim merge). Switch the claim read-model to the\n # SCOPE+POLARITY-safe STRUCTURED claim key (engine/claim_key.py): claims from different tasks never\n # merge, opposite polarity (\"X helps\" vs \"X never helps\") is surfaced as a CONTRADICTION instead of being\n # collapsed, paraphrase/inflection variants group by exact structured key (no transitive over-merge), and\n # operator governance is scope-precise (a decision in task A cannot reach a same-worded claim in task B).\n # Affects the `cross_run_advisory` context pack only; ON by default in the product Settings (ce4a379);\n # the bare-library EngineOptions default stays off (engine/options.py). See engine/claims.py.\n cross_run_structured_claims: bool = True\n # PART IV cross-run §22.4 (AGENTIC taxonomy steward). At finalize, when an LLM client is available, let\n # the concept steward (engine/concept_steward.py) review the freshly-updated portfolio concept graph and\n # PROPOSE a curation — merge duplicate slugs / split conflated ones / purge noise. Proposals are LOGGED to\n # `concept_curation_log.jsonl` for operator ratification (via the serve/CLI governance surface); the LLM\n # never mutates fold state. The calls run synchronously during finalize, so they add latency and may add\n # paid inference; a caught steward failure does not prevent terminal completion or trigger a retry.\n # ON by explicit experimental product choice when `memory_dir` + an LLM backend are present; the\n # bare-library EngineOptions default stays off (engine/options.py). See engine/lessons.py.\n cross_run_curation: bool = True\n # Deprecated compatibility flag. Steward output is untrusted and finalize is proposal-only regardless\n # of this value; retained so old snapshots still validate and logs can disclose that auto was requested.\n cross_run_curation_auto: bool = False\n # Role backend (ADR-7/14): \"toy\" (offline optimizer) | \"llm\" (live model).\n # CODEX AGENT: non-typed sources (`--set`, file, env) accept any string; a typo then falls through to\n # toy roles instead of failing an intended LLM run. Validate this as Literal[\"toy\", \"llm\"] across\n # all construction paths.\n backend: str = \"toy\"\n # Developer backend (ADR-7): \"default\" (templated/LLM from the task) or an external\n # CLI coding agent: \"opencode\" | \"aider\" | \"goose\" | \"continue\".\n developer_backend: str = \"default\"\n # C2 best-of-N: generate N candidate implementations per node and keep the best by an\n # execution-free reward (static validity + metric-print). 1 = off. In-house LLM developer only\n # (not external agents — ADR-7 cost rule). The top SWE-bench reliability lever for weak models.\n best_of_n: int = 1\n # D10 list-wise best-of-N selection: when the top static-scorers TIE, break the tie with a\n # comparative LLM selection over the candidates presented together (+~3 pts vs pointwise on\n # GAIA, arXiv:2506.12928). The static score stays the primary filter — the LLM is a weak\n # comparative prior, never the sole oracle. ON by default (no-op when best_of_n==1).\n best_of_n_listwise: bool = True\n # === Foresight / predict-before-execute ===============================================\n # FOREAGENT predict-before-execute (arXiv:2601.05930): use the LLM as an IMPLICIT WORLD MODEL to\n # predict — WITHOUT executing — which candidate / hypothesis will score best, primed with a\n # Verified Data Analysis Report (data profile / brief) + the accumulated experiment memory. Master\n # switch, ON by default; a no-op wherever there is nothing to rank (best_of_n==1 and\n # foresight_panel==1) or no LLM client. See `looplab/search/foresight.py`.\n foresight: bool = True\n # Hypothesis foresight breadth: generate K candidate ideas per proposal and keep the one PREDICTED\n # best pre-execution by the world model — it ranks the STRUCTURAL / text ideas the numeric k-NN\n # surrogate (researcher_panel) is blind to, primed with the data profile + memory (the synergy).\n # >1 enables it (LLM backend only; needs `foresight` on); 2 = on by default at modest cost, 1 = off.\n foresight_panel: int = 2\n # AGENTIC foresight: run the ranking (hypothesis-board prioritization + K-idea pick) as a TOOL-USING\n # loop that can pull actual experiment results / data facts before deciding, instead of a one-shot\n # prediction from a pre-baked report. ON by default (needs foresight + a client; falls back to the\n # one-shot predictor on any hiccup). A few extra LLM calls per proposal; set False for the cheaper\n # single-call ranker.\n foresight_agentic: bool = True\n # §1 foresight confidence gate: the minimum predicted confidence at which a predict-before-execute\n # pick is ACTED ON. Below it the ranker abstains — the K-idea panel falls back to the first\n # proposal, best-of-N falls through to the D10 tie-break — instead of committing a low-confidence\n # choice. 0.0 (default) = OFF (act on every pick, the historical behavior); raise toward ~0.5 to\n # make the world model defer when it isn't sure. Pairs with the foresight track record (§1) the\n # predictor is now primed with. Range 0.0-1.0.\n foresight_min_confidence: float = 0.0\n # PART IV Phase 2c — replace the world model's SELF-REPORTED confidence (measured Pearson≈0 with the\n # realized outcome, §21.12) with a CALIBRATED §12-verifier score. When on and a client is available,\n # after the K-idea ranker picks the predicted-best candidate the §12 verifier scores it (grounded +\n # repeated + criteria-decomposed: `foresight_criteria` — likely to improve the objective, and\n # sound/feasible) and THAT calibrated score becomes the confidence the `foresight_min_confidence` gate\n # and the telemetry use. Degrades to the self-reported confidence without a client or on any verifier\n # error (the telemetry records which source was used). ON by default in the product Settings (ce4a379),\n # EngineOptions off; a few extra LLM calls per acted-on proposal. See looplab/trust/verifier.py + search/foresight.py.\n # REVISIT (cost/quality, not yet resolved): a live classification run measured ~10 LLM calls & ~3.8 min/node\n # on deepseek-v4-flash with the full Part IV/V default bundle; the calibrated verifier is part of that per-node\n # tax. Kept ON deliberately — it raises decision quality (self-reported foresight confidence has ≈0 Pearson\n # with realized outcome), and lowering quality to save calls is the wrong trade. TODO: run a frozen quality/cost\n # A/B (verifier-on vs -off) to quantify the recall@objective gain per extra call before considering a default flip.\n foresight_verify: bool = True\n # Verifier sample count for `foresight_verify` (the §12 repeated-sampling expectation on a no-logprob\n # backend). 3 tames the single-shot variance §21.12 measured; 1 = single-shot (cheaper, noisier).\n # RESUME-critical: this value is persisted in config.snapshot.json, so a hard `le` schema bound would\n # make an old run started with a larger LOOPLAB_FORESIGHT_VERIFY_SAMPLES un-resumable (Settings(**snapshot)\n # would raise on `looplab resume` / finalize-recovery / serve replay). CLAMP into [1, MAX] via a validator\n # instead of rejecting — the runtime cap is preserved without breaking the snapshots/env-compat rule.\n foresight_verify_samples: int = 3\n\n @field_validator(\"foresight_verify_samples\")\n @classmethod\n def _clamp_foresight_verify_samples(cls, v: int) -> int:\n # Coerce a persisted/env value into [1, MAX] rather than reject (see field note above).\n try:\n v = int(v)\n except (TypeError, ValueError):\n return 3\n return max(1, min(v, MAX_FORESIGHT_VERIFY_SAMPLES))\n\n # T7 LLM response cache: serve an identical DETERMINISTIC (temperature 0) request from an\n # in-process content-addressed cache instead of re-hitting the model — cuts cost on\n # retry/panel/verify flows. NEVER caches sampling calls (temp>0: best-of-N / panel / novelty\n # re-propose must vary). Off by default (most role calls run at temperature>0 anyway).\n llm_cache: bool = False\n agent_cmd: str | None = None # override the agent's launcher (path / wrapper)\n # External-agent validation (ADR-7): wrap a CLI-agent Developer with a validator that\n # audits each output (no-op/syntax/crash/timeout), retries with feedback, and falls\n # back to the in-house LLM Developer. Off -> use the raw agent output unchecked.\n validate_agent: bool = True\n agent_max_retries: int = 1 # re-prompts of the agent on an invalid result\n # Patch-gated multi-file agent (ADR-7 Rule 3): run the CLI agent in a git worktree and\n # accept only edits whose paths match `agent_surface` (reject-not-strip out-of-surface\n # touches). Lets the agent create helper modules, not just solution.py. Degrades to\n # whole-file readback if git is unavailable.\n agent_patch_gate: bool = True\n agent_surface: list[str] = [\"*.py\"] # edit-surface allow-list (globs)\n # Trust policy for an agent-authored eval/metric adapter (RepoTask onboarding, Phase 3):\n # \"ratify_freeze\" waits for human approval, \"autonomous\" accepts the proposed spec without that\n # gate, and \"ratify_freeze_drift\" also enforces the configured independent drift cross-check.\n eval_trust_mode: str = \"ratify_freeze\"\n # RepoTask node seeding policy (run-wide fallback; a task/editable `seed_mode` overrides):\n # \"auto\" (default) copies git-tracked files when the editable is a git repo (so a tree bloated\n # with untracked artifacts isn't deep-copied per node), else a full copy; \"tracked\" forces\n # code-only; \"all\" forces a full recursive copy (legacy). Genesis authors per-task from the\n # user's words; this is the global default when unspecified.\n seed_mode: str = \"auto\"\n llm_model: str = \"qwen3:8b\"\n # === LLM / transport ==================================================================\n llm_base_url: str = \"http://localhost:11434/v1\" # Ollama OpenAI-compatible endpoint\n llm_temperature: float = 0.6\n # H3 per-role model presets (5090 recipe): optionally run the Researcher and Developer on\n # DIFFERENT models/endpoints (e.g. Developer=Qwen3-Coder-30B for code, a fast model for breadth).\n # Blank = use the shared llm_model/llm_base_url. Generalizes the role-backend swap to per-role.\n researcher_model: str | None = None\n developer_model: str | None = None\n researcher_base_url: str | None = None\n developer_base_url: str | None = None\n # Per-role sampling temperature (§4.1): the Researcher wants breadth (higher temp = more diverse\n # ideas), the Developer wants determinism (lower temp = fewer syntax slips), the Strategist a\n # steady hand. Each overrides the shared `llm_temperature` for that role only; None = use the\n # shared value (byte-identical to before). Flat + nullable, mirroring the per-role model fields.\n researcher_temperature: float | None = None\n developer_temperature: float | None = None\n strategist_temperature: float | None = None\n # Unified self-driving agent: one LLM identity that plays Researcher + Developer (+ Strategist)\n # across pipeline stages, choosing its own model/toolset per stage. ON by default — the agent\n # drives the loop (incl. crash triage: repair/abandon/reject_idea) and, via\n # `agent_drives_actions`, picks the next macro action within a pure legal-action gate. Set both\n # to False for the legacy byte-identical split-role behavior. (No-op unless backend=\"llm\".)\n unified_agent: bool = True\n agent_drives_actions: bool = True\n # Layer 3 Card queue ownership. False preserves the existing policy/unified-pilot action path.\n # True is pinned in run_started because changing the selector on resume would mix two search\n # treatments in one run. When both this and agent_drives_actions are true, Card selection wins.\n card_driven_selection: bool = False\n # Layer 5 bounded speculative Card buffer. Zero is a hard OFF switch and preserves the\n # historical alternating build/eval spine; positive values are pinned by run_started so a\n # resume cannot silently mix search treatments after a snapshot/default edit.\n speculation_depth: int = Field(default=0, ge=0, le=64)\n # Local evidence receipt produced by ``looplab speculation-gate``. This is deliberately absent\n # from the curated Settings UI: positive-depth execution is a calibrated rollout decision, not a\n # casual tuning knob. The Engine validates the receipt against the current scorer,\n # implementation, GPU inventory and raw paired-run evidence before it enables speculation.\n speculation_gate_receipt: str | None = Field(default=None, max_length=4096)\n # Per-stage model/endpoint overrides for the unified agent. Recognized keys (see\n # tasks.build_unified_agent): `propose` (researcher), `implement`/`repair` (developer),\n # `strategy`, `pilot`. Unlisted keys are ignored. Empty = the per-role researcher_*/developer_*\n # models, then the shared llm_model. Explicit map wins. No-op unless `unified_agent` is true.\n agent_stage_models: dict = {}\n agent_stage_base_urls: dict = {}\n llm_parser: str = \"tool_call\"\n # Reasoning/thinking toggle sent IN the request (providers differ — see `llm.reasoning_body`):\n # Qwen3 on vLLM/SGLang -> chat_template_kwargs.enable_thinking (bool)\n # OpenAI/Ollama-v1/DeepSeek -> reasoning_effort (low|medium|high|none)\n # `llm_reasoning`: \"\" = send nothing (server default — current behavior); off = actively disable;\n # on = enable at default depth; low|medium|high = enable at that effort.\n # Defaults to \"high\" so the agent reasons before proposing/repairing/triaging (better code, fewer\n # crashes); set \"\" to send nothing (server default) or \"off\" to disable. The model's chain-of-thought\n # is CAPTURED either way (reasoning_content field or inline <think>). NOTE: to get a SEPARATE\n # reasoning_content field from SGLang/vLLM, the server also needs `--reasoning-parser qwen3`.\n llm_reasoning: str = \"high\"\n llm_reasoning_style: str = \"auto\" # auto | qwen | effort | none — how to shape the request param\n llm_reasoning_extra: dict = {} # raw fields merged into the body (escape hatch, e.g. Anthropic thinking)\n # H1: drive structured calls with the endpoint's constrained decoding (vLLM/SGLang guided_json +\n # response_format json_schema) so weak models can't emit invalid JSON. Off for Ollama (default).\n llm_guided_json: bool = False\n # Stream every LLM request (SSE) and reassemble it, so the request `timeout` is an INTER-TOKEN\n # idle timeout rather than a whole-request deadline: a long-but-alive generation is never cut off\n # (tokens keep resetting the timer), while a genuinely STALLED endpoint (no token for `timeout` s)\n # is detected and retried on a fresh connection — the fix for silent multi-minute hangs. Set False\n # to use one blocking read (old per-op timeout semantics) if an endpoint streams badly.\n llm_stream: bool = True\n # LLM IDLE timeout (seconds). Stream mode: the INTER-TOKEN stall limit — a steady generation is\n # never cut off, only a silent endpoint. Non-stream: bounds the whole read. Raise for endpoints\n # with a long prefill on huge prompts; lower to fail fast on a flaky shared endpoint.\n llm_timeout: float = Field(default=180.0, gt=0)\n # First-byte (response-headers) window for STREAM attempts, seconds: an SSE response sends its\n # headers on admission, so no-headers ≈ a black-holed request — fail over to a fresh connection\n # fast instead of waiting the full idle timeout. Clamped to llm_timeout. Non-stream attempts are\n # NOT bounded by this (their headers arrive only after the whole generation). The default mirrors\n # `DEFAULT_HEADER_TIMEOUT_S` in looplab/core/llm.py is the single source (imported above).\n llm_header_timeout: float = Field(default=DEFAULT_HEADER_TIMEOUT_S, gt=0)\n # Honour HTTP(S)_PROXY / NO_PROXY / SSL_CERT_FILE env vars for LLM calls. Default False: the\n # transport connects DIRECTLY (many internal/OLAP endpoints are reachable only by bypassing an\n # ambient proxy — a picked-up proxy yields \"connection refused\"). Set true when the endpoint is\n # reachable ONLY via a corporate proxy or needs a custom CA bundle from the environment.\n llm_trust_env: bool = False\n # H4/C2: compact the growing agentic tool-call history once it exceeds this many chars (auto_summary\n # LLM-summarizes the stale middle; else middle-truncate). Sized to the model's real context window so\n # files read earlier STAY in context instead of being compacted away and re-read: ~1,000,000 chars ≈\n # 250k tokens (≈4 chars/token), matching the configured deepseek-v4-flash (≥200k tokens confirmed).\n # The old 120k-char (~30k-token) default was ~8× too aggressive and drove a read-thrash loop. 0 = off.\n context_budget_chars: int = 1_000_000\n # M5: the Researcher's always-on experiments digest budget (chars). 0 = AUTO — scales with the\n # run size (60 chars/node, bounded [1200, 6000]) instead of one flat cap for an 8-node toy run\n # and a 200-node benchmark run alike. Set a positive value to pin it.\n digest_char_cap: int = Field(default=0, ge=0)\n # D11 compression model slot (open_deep_research's four-slot pattern): a dedicated CHEAP model\n # for agent-history summarization (C2 auto-summary), so compression doesn't pay the main\n # model's price. Blank = use each loop's own model (legacy). `compressor_base_url` blank =\n # the shared llm_base_url.\n compressor_model: str | None = None\n compressor_base_url: str | None = None\n # === Agentic tool-loop ================================================================\n # Agentic tool-loop limits — apply to EVERY tool-using agent (the LLM Researcher, the unified\n # agent's pilot + crash-triage, Deep-Research, the run-chat Boss, the genesis repo-scout, and the\n # cross-run report synthesizer). The loop lets the model call read-only tools across turns before\n # emitting its structured result. Both default to UNLIMITED so the agent takes as many turns / as\n # long as it needs — it is never cut off mid-reasoning; set a positive value here (or per-run, or\n # in the UI settings) to bound latency/cost. These used to be hardcoded per call site (e.g. the\n # boss at max_turns=3 / 45s), which silently dropped a slow reasoning model to a no-op reply.\n agent_max_turns: int = 0 # max tool turns before the emit is forced (0 = unlimited)\n agent_time_budget_s: float = 0.0 # wall-clock ceiling across the loop's turns (0 = no cap)\n # G · Soft convergence SAFETY NET (not a research cap). Two thresholds on the agent's tool-turn count:\n # at `agent_emit_after` the loop NUDGES the agent once (\"you've investigated enough — emit now\"); at\n # `agent_emit_force` it FORCES the emit from what was gathered. Set GENEROUSLY — a focused researcher\n # may legitimately read many files/experiments before it can propose a grounded idea; these only catch\n # a genuine runaway that keeps issuing DIFFERENT calls forever (which stuck-detection, keyed on\n # repeats, misses). NB: the old 499-read runaway (node 63) was 79% REDUNDANT re-reads caused by a\n # broken read_file — fixing read_file (pagination) is the real cure; this is just insurance. 0 = off.\n agent_emit_after: int = 300\n agent_emit_force: int = 500\n # B1 · No-progress / stuck detection — the safety net that makes \"unlimited turns\" safe. The\n # turn/time ceilings above are only BACKSTOPS; this is what actually stops a runaway loop, on the\n # cheapest signal: when the model repeats the SAME tool call (or ping-pongs between two, or keeps\n # hitting the SAME error) with no progress, the loop forces the final emit and finishes. ON by\n # default; reading DIFFERENT files or running ONE long command never trips it. (OpenHands-style.)\n agent_stuck_detection: bool = True\n agent_stuck_repeat: int = 4 # identical calls in a row that count as \"stuck\" (>=2)\n agent_stuck_alternate: int = 4 # ping-pong cycles between two calls that count as \"stuck\" (>=2)\n # C1 · Self-plan (TodoWrite-style): expose an `update_plan` tool so a long-running agent keeps its\n # OWN working TODO and re-surfaces it every `agent_plan_reinject_every` turns — keeps the goal in\n # view across a long loop so the agent \"writes its own context\" (Claude Code style). ON by default.\n agent_self_plan: bool = True\n agent_plan_reinject_every: int = 5\n # C2 · Auto-summary (ON by default): when the tool-loop history grows long, LLM-summarize the\n # stale middle instead of only middle-truncating it. The trigger is `context_budget_chars` if set,\n # else a built-in high-water mark (context_budget.DEFAULT_SUMMARY_CHARS ≈ 120k chars) so short\n # loops are untouched. Falls back to truncation on any summarizer error; one extra model call per\n # over-budget turn.\n agent_auto_summary: bool = True\n # C4 · Repo-developer plan decomposition. A big feature attempted in ONE developer session made a\n # non-converging reasoning model loop indefinitely — it kept writing + exploring without ever\n # emitting `done` (10k+ LLM calls / hours; agentic stuck-detection can't catch VARIED exploration,\n # only literal repeats). Fix: the developer first proposes an ordered plan of ATOMIC steps; a\n # multi-step plan is executed step-by-step, each in a FRESH bounded session with only that step's\n # scope + the files accumulated so far, syntax-validated per write. Every session stays\n # small-context and convergent. ON by default for repo tasks (a 1-step plan == the old single pass).\n developer_plan_decompose: bool = True\n developer_plan_min_steps: int = 2 # a proposed plan with >= this many steps runs step-by-step\n developer_plan_max_steps: int = 8 # cap on plan length (a runaway planner can't spawn 100 steps)\n # Hard per-session backstop for the repo developer — the ONE write-heavy agent that can run away\n # even with stuck-detection on (varied writes/reads never trip the repeat/ping-pong signal). Bounds\n # the plan phase, EACH step, and the single-session fallback. Unlike the global agent_* limits\n # (0 = unlimited, meant for read-only agents), the developer always gets a FINITE ceiling so a\n # model that never emits `done` fails cleanly with the code it wrote so far, instead of looping.\n developer_session_max_turns: int = 500\n developer_session_time_budget_s: float = 1200.0 # 20 min wall-clock per developer session\n # Phase-handoff summaries. Each LLM phase in a node build (Researcher·propose → Developer·stages →\n # plan → implement) ends with ONE extra LLM call that distills its transcript — the repo structure\n # it mapped, files/data confirmed, decisions made — into a brief injected into the NEXT phase (even\n # across the role boundary). The next phase TRUSTS that instead of re-reading the same files, which\n # cuts the biggest source of tool-call thrash (stages/plan/implement each re-exploring the repo).\n # One summary call per phase in exchange for many fewer read calls downstream. ON by default.\n phase_handoff_summary: bool = True\n # Observability (ADR-17): capture each LLM call's full prompt + completion into the active\n # span (spans.jsonl) so the UI can show exactly what the model read and wrote per node.\n # Diagnostics only — `replay.fold` never reads spans. Default on for local single-user; set\n # LOOPLAB_TRACE_LLM_IO=0 to suppress (e.g. to keep spans.jsonl small or avoid storing prompts).\n trace_llm_io: bool = True\n # Run-introspection tools (ON by default): make the LLM Researcher (and DeepResearcher) a\n # tool-using agent that can read its OWN experiments (list/read/find-analogous/themes/code) and\n # the task data (schema/profile/asset) mid-loop, instead of seeing only best+parent. Advisory —\n # never changes best-selection. Off = the legacy single-shot Researcher (richer digest still added).\n researcher_tools: bool = True\n # Cross-run introspection: give the agentic Researcher / pilot read-only tools to look at\n # SIBLING runs (same task_id, same run-root) — list them, read an experiment / its code, and\n # find analogous configs across runs — so a run can build on what neighbouring runs already\n # learned instead of rediscovering it. Advisory; never changes best-selection. Needs the run's\n # own dir wired through (no-op for unit-built roles). Off = the legacy single-run view only.\n cross_run_tools: bool = True\n # Read-only access to EVERY run on this machine ACROSS ALL TASKS (not just same-task siblings):\n # the Developer/Researcher get list_all_runs + read_run_code + read_run_experiment so they can read\n # the code + result of ANY past experiment anywhere and reuse an approach. Broader than\n # `cross_run_tools` (same-task only); the agent decides when a foreign run is relevant. Advisory;\n # never changes best-selection. Needs the run's own dir wired through (no-op for unit-built roles).\n all_runs_tools: bool = True\n # PART V §22 — read-only CROSS-RUN KNOWLEDGE tool for the reasoning roles (Researcher, Strategist,\n # deep-research, and a Developer-scoped variant). Adds `cross_run_prior_attempts` / `cross_run_claims`\n # / `cross_run_atlas` over the §21.20 read-models (concept overview + claim assessments + atlas), read\n # from `memory_dir` (lessons.jsonl + concept_capsules.jsonl). Read-only is a storage-authority\n # boundary, not a behavioral no-op: retrieved content can steer later proposals, and a tool-using\n # reasoning loop can consume additional model calls. The tools never mutate cross-run truth (facts\n # are engine-written, verdicts operator-ratified, §22.4) or directly select a winner. ON by explicit\n # experimental product choice, EngineOptions off; stores only exist once Part-IV features have run.\n cross_run_read_tools: bool = True\n # Agentic retrieval (ADR-16): if set, the LLM Researcher gets grep/kb_search/read\n # tools over this directory of markdown notes and chooses when to use them.\n knowledge_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"knowledge\"))\n # T4 real embeddings: model id for an OpenAI-compatible `/embeddings` endpoint used for\n # kb_search / case retrieval. Blank (default) = the dependency-free lexical `hash_embed` (today's\n # behavior). Set e.g. \"nomic-embed-text\" (Ollama) or \"text-embedding-3-small\" for SEMANTIC\n # retrieval; a misconfigured/offline endpoint degrades back to hash_embed at call time (never\n # crashes a run). `embed_base_url` blank = reuse `llm_base_url`; the shared `llm_api_key` is used.\n embed_model: str | None = None\n embed_base_url: str | None = None\n # Harmonic memory (Memora, ICML'26 — idea import). When ON, cross-run cases and knowledge notes are\n # indexed by a short ABSTRACTION + cue ANCHORS instead of their raw text, near-duplicate memories are\n # CONSOLIDATED into one entry under a matching abstraction, and `kb_search`/case retrieval EXPAND\n # through the top hits' anchors to surface related-but-not-similar memories. ON by default. Set\n # `memora=false` to restore the pre-Memora raw-text index. Never a source of truth — abstractions\n # live only in the derived, rebuildable retrieval index (never the event log or canonical cases.jsonl).\n memora: bool = True\n # Write abstractions with the wired chat model (richer than the deterministic lexical fallback). ON\n # by default; results are CACHED by content hash (see `memora_cache`) so a re-built index doesn't\n # re-call the model on unchanged notes/cases, and any endpoint failure degrades to lexical at call\n # time — so an offline box just gets lexical abstractions, never a crash. Set false to force lexical.\n memora_llm: bool = True\n # === Cross-run memory / Memora ========================================================\n memora_anchors: int = 6 # max cue anchors kept per memory\n # Cosine similarity at/above which a new case is CONSOLIDATED into an existing one (same evolving\n # topic) rather than stored as a separate entry. Only used when `memora` is on.\n memora_consolidate_threshold: float = 0.86\n # Where the LLM-abstraction cache lives (JSON). Blank (default) derives it from `memory_dir`\n # (`<memory_dir>/memora_cache.json`) or else `knowledge_dir` (`<knowledge_dir>/.memora_cache.json`);\n # with neither set the cache is in-memory only (per process). No-op unless `memora_llm` is on.\n memora_cache: str | None = None\n # E3 literature-grounded ideation (network-OPTIONAL): give the agentic Researcher an arXiv search\n # tool to ground ideas in real techniques. Off by default (egress is unreliable on some boxes);\n # fails gracefully if the network is blocked.\n literature_search: bool = False\n # Deep-Research stage (network-OPTIONAL): give the DeepResearcher a general web search/fetch\n # tool (DuckDuckGo) on top of arXiv to read across results + the web before steering the next\n # batch. Off by default (egress is unreliable on some boxes); fails gracefully when blocked.\n web_search: bool = False\n # Cadence for the Deep-Research stage: run it automatically every N created nodes. Zero disables\n # only the cadence; manual `deep_research` and Strategist `request_research` triggers still fire.\n # Default 3: deep research analyzes the accumulating results and steers the next batch (its\n # directions become hints + open hypotheses); paired with `concurrent_research` it overlaps the\n # think with the GPU-bound eval, so the cadence costs LLM tokens but no wall-clock on the search.\n deep_research_every: int = 3\n # Overlap a DUE deep-research \"think\" with the GPU-bound eval instead of running it in its own\n # serial step (the agent is otherwise idle while a node trains). research() computes from a state\n # snapshot; the joined background task records only allowlisted, order-tolerant\n # research/hint/hypothesis events as soon as the memo finishes. ON by default: the LLM is typically\n # remote (no GPU contention with eval), so overlapping hides latency behind long training.\n concurrent_research: bool = True\n # Concurrent-research REPEAT — don't idle a multi-DAY eval. The overlapped \"think\" fires ONCE per\n # window by default; ON here, it RE-RUNS on an adaptive time cadence for the WHOLE eval window\n # (a two-day training must not leave the reasoning agents idle after one memo). Self-paced: it\n # only records a memo whose CONTENT is new (identical re-runs are skipped, so the log/board don't\n # bloat) and backs off geometrically as the analysis converges, capped so it always re-checks.\n # The appended records never rewrite the current metric champion, but their hints/open hypotheses\n # deliberately steer later proposals and replay reconstructs that advice. The library default is\n # one-shot (== today, byte-identical); this product default turns the repetition on.\n concurrent_research_repeat: bool = True\n # Base cadence (seconds) between repeated concurrent-research passes. Research is EXPENSIVE\n # (multi-turn LLM + web/arXiv), so this is a FLOOR, not a ceiling: the effective cadence is\n # max(this, ~5% of the per-experiment time budget) — a two-day eval is re-researched about hourly,\n # a short eval not at all (the first tick outlasts it). Only matters when the repeat is on.\n concurrent_research_interval_s: float = Field(default=1800.0, gt=0)\n # Per-eval-window backstop on repeated-research LLM calls (0 = cadence-only, no cap). The adaptive\n # cadence + convergence backoff are the primary budget control; this bounds a pathological\n # never-converging window. Past it the loop stops calling the LLM (the health monitor still runs).\n concurrent_research_max_calls: int = Field(default=40, ge=0)\n # Concurrent hypothesis-board CONSOLIDATION during the eval window. Repeated research keeps ADDING\n # near-duplicate directions to the open board (each memo registers up to 5 as open hypotheses), so a\n # long eval's board bloats before the between-nodes merge runs. This dedups/merges it on the SAME\n # background loop so the board stays clean for the next proposal. In Card-driven mode the same merge\n # changes ownership/readiness, so it is deferred to the joined main-task cadence instead. Needs the\n # reflect client + `track_hypotheses`.\n # Only active while `concurrent_research` runs the overlap loop. Library default off (== today).\n concurrent_consolidate: bool = True\n # Cadence for the agent-authored run report: regenerate the conclusion-first narrative every N\n # created nodes (0 = off; it still regenerates on a manual `report_refresh` from the UI). The\n # deterministic report always renders from the node set regardless of this knob.\n report_every: int = 3\n # Agent Skills (I18, ADR-9): dir of SKILL.md the Researcher can list/load as tools.\n skills_dir: str | None = None\n # Prompt store (I18, ADR-8): dir of editable, hot-reloaded role prompt .md files.\n prompt_dir: str | None = None\n # API key as a reference, never serialized as a value (ADR-11). Local servers\n # ignore it; default to a placeholder.\n llm_api_key: SecretStr | None = None\n\n @model_validator(mode=\"before\")\n @classmethod\n def _apply_profile(cls, data):\n \"\"\"Expand `profile` into a bundle of setting defaults BEFORE validation.\n\n pydantic-settings hands this validator the fully-merged input from ALL explicit sources\n (init kwargs + LOOPLAB_* env + .env), with fields the user left unset simply ABSENT (they\n fall to field defaults afterward). So a profile value is applied only when its key is not\n already present — i.e. any explicit file/CLI/env setting wins over the profile, and the\n profile wins over the bare field default. This makes `thorough` a true convenience preset,\n never an override of what the user asked for.\"\"\"\n if not isinstance(data, dict):\n return data\n prof = data.get(\"profile\", \"default\")\n # env may deliver the key as \"profile\" (env_prefix stripped) — normalize to a str\n prof = str(prof).strip().lower() if prof is not None else \"default\"\n overrides = PROFILES.get(prof)\n if overrides is None:\n raise ValueError(f\"unknown profile {prof!r}; known: {sorted(PROFILES)}\")\n for key, val in overrides.items():\n if key not in data: # explicit source (file/CLI/env) always wins\n data[key] = val\n return data\n\n @model_validator(mode=\"after\")\n def _check_trust_gate(self):\n if self.trust_gate not in (\"audit\", \"gate\", \"block\"):\n raise ValueError(\n f\"trust_gate must be audit|gate|block, got {self.trust_gate!r}\")\n if self.merge_mode not in (\"auto\", \"mean\", \"ensemble\"):\n raise ValueError(\n f\"merge_mode must be auto|mean|ensemble, got {self.merge_mode!r}\")\n # novelty_mode drives the dedup/novelty gate; an out-of-set value (e.g. a mis-cased\n # LOOPLAB_NOVELTY_MODE=LLM, or \"on\") used to fall through _apply_novelty_gate silently as a\n # NO-OP — turning the gate off with no diagnostic. Fail loudly at config time like trust_gate/\n # merge_mode do. (Other enum-ish string fields — seed_mode/eval_trust_mode/strategist_backend —\n # share the pattern; left unvalidated here only because their full value sets are less settled.)\n if self.novelty_mode not in (\"off\", \"algo\", \"llm\"):\n raise ValueError(\n f\"novelty_mode must be off|algo|llm, got {self.novelty_mode!r}\")\n # The remaining enum-ish string fields (arch-review §5 P3): a typo used to be accepted at\n # construction and only fail-safe/later-loud downstream (e.g. a mis-cased strategist_backend\n # silently ran the default). Fail loudly here like the fields above.\n if self.strategist_backend not in (\"off\", \"rule\", \"llm\", \"agent\"):\n raise ValueError(\n f\"strategist_backend must be off|rule|llm|agent, got {self.strategist_backend!r}\")\n if self.eval_trust_mode not in (\"ratify_freeze\", \"autonomous\", \"ratify_freeze_drift\"):\n raise ValueError(\n \"eval_trust_mode must be ratify_freeze|autonomous|ratify_freeze_drift, \"\n f\"got {self.eval_trust_mode!r}\")\n if self.seed_mode not in (\"auto\", \"tracked\", \"all\"):\n raise ValueError(f\"seed_mode must be auto|tracked|all, got {self.seed_mode!r}\")\n return self\n\n def masked_snapshot(self) -> dict:\n # Snapshots are a JSON contract (they are written verbatim to config.snapshot.json), not a\n # Python-object dump. In particular tuple-valued schema fields such as\n # ``inline_repair_reasons`` must become JSON arrays before callers hash the runtime envelope;\n # otherwise the strict speculation-scope canonicalizer sees a different/non-JSON preimage\n # from the one that ``json.dumps`` eventually persists.\n d = self.model_dump(mode=\"json\")\n if self.llm_api_key is not None:\n d[\"llm_api_key\"] = \"***\"\n else:\n d[\"llm_api_key\"] = None\n return d\n\n\n# Pre-versioned snapshots are full Settings dumps, so absence is a reliable per-field version marker.\n# Keep their historical effective behavior when newer product defaults become active: re-entry must not\n# silently add paid calls, interventions, concurrency or a different selection policy to an old run.\nLEGACY_CONFIG_SNAPSHOT_DEFAULTS: dict[str, object] = {\n \"parallel_build\": 1,\n \"eval_parallel\": None,\n \"llm_parallel\": None,\n \"train_monitor\": False,\n \"train_monitor_interval_s\": 600.0,\n \"train_monitor_kill\": False,\n \"train_monitor_kill_confidence\": 0.8,\n \"asha_live\": False,\n \"asha_live_kill\": False,\n \"asha_live_quantile\": 0.5,\n \"asha_live_min_siblings\": 3,\n # Before this operator ceiling existed, the sandbox's 24-hour process limit was authoritative.\n \"max_eval_timeout\": 24 * 3600.0,\n \"watchdog_reflection\": False,\n \"card_driven_selection\": False,\n \"speculation_depth\": 0,\n \"speculation_gate_receipt\": None,\n \"concurrent_research_repeat\": False,\n \"concurrent_research_interval_s\": 1800.0,\n # Zero means unlimited; 40 was the first bounded default, and is latent while repeat is disabled.\n \"concurrent_research_max_calls\": 40,\n \"concurrent_consolidate\": False,\n}\n\n\ndef migrate_config_snapshot(data: dict) -> dict:\n \"\"\"Return an effective copy of one legacy/current snapshot without rewriting evidence bytes.\"\"\"\n if not isinstance(data, dict):\n raise TypeError(\"config snapshot must be a JSON object\")\n migrated = dict(data)\n for key, value in LEGACY_CONFIG_SNAPSHOT_DEFAULTS.items():\n migrated.setdefault(key, value)\n return migrated\n\n\ndef settings_from_snapshot(data: dict) -> Settings:\n \"\"\"Build re-entry Settings from a masked snapshot under its historical missing-field contract.\"\"\"\n migrated = migrate_config_snapshot(data)\n migrated.pop(\"llm_api_key\", None)\n return Settings(**migrated)\n"}, "1527835d34d1e9562f8bba6007749eb0e73a5aac": {"t": 1785768767.78788, "n": 97945, "h": "84f1febe553a4ef4", "c": "\"\"\"Engine config: the schema (what settings exist). (I0, ADR-11)\n\nOne pydantic-settings class declaring every engine knob, its default, and its docs. The loader —\nhow a run file / CLI / env becomes a `Settings` — is `looplab.core.appconfig`, which also owns\nthe config-precedence order. A resolved, secret-masked snapshot is written next to each run for\nreproducibility. (No real secrets in P0, but the masking discipline is in place.)\n\"\"\"\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nfrom pydantic import Field, SecretStr, field_validator, model_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n# Single source of the LLM first-byte (response-headers) default — see core/llm.py.\nfrom looplab.core.llm import DEFAULT_HEADER_TIMEOUT_S\n\n# Default home for cross-run memory + the knowledge base (shared across every run). Both are ON by\n# default now (a user asked for it) so a fresh install accumulates + consults knowledge with no setup;\n# set the env var to \"\" to disable, or to a path to relocate. `~/.looplab/` keeps them out of any one\n# project + shared across projects.\n_LL_HOME = Path.home() / \".looplab\"\n\n# Autonomous roles that may be granted per-setting write access (see Settings.agent_control).\n# strategist = A7 meta-controller (run-wide); boss = run-chat operator-proxy (run-wide);\n# researcher = per-experiment proposer (per-node sizing, e.g. eval timeout for a heavy model).\nAGENT_ROLES = (\"strategist\", \"boss\", \"researcher\")\n\n# A foresight score is computed for every acted-on proposal. Keep this lower than the generic\n# verifier's emergency ceiling so a typo cannot multiply paid synchronous calls across a run.\nMAX_FORESIGHT_VERIFY_SAMPLES = 8\n\n\n# Layer-2 parallelism renamed two public settings without removing the durable legacy fields. Keep\n# the equivalence in one place: config-source precedence, governance migration, and live strategy\n# application must all agree which names describe the same authority/value.\nPARALLELISM_ALIASES: dict[str, str] = {\n \"max_parallel\": \"eval_parallel\",\n \"parallel_build\": \"llm_parallel\",\n}\n\n\ndef parallelism_aliases(setting: str) -> tuple[str, ...]:\n \"\"\"Return every spelling in ``setting``'s alias family, canonical name first.\"\"\"\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if setting in (legacy, canonical):\n return canonical, legacy\n return (setting,)\n\n\ndef canonicalize_parallelism_source(\n values: dict, *, promote_build_to_llm_parallel: bool = False,\n) -> dict:\n \"\"\"Promote legacy parallel keys *within one config-precedence source*.\n\n Applying this only after file/CLI/env were flattened would let a lower-priority canonical env\n value shadow a higher-priority legacy file value (or a file canonical value shadow a CLI legacy\n override), so callers with sub-layers normalize each layer independently.\n\n ``max_parallel``/``eval_parallel`` are true aliases (pure eval width, no extra semantics) and are\n always promoted. ``parallel_build``/``llm_parallel`` are NOT: a positive ``llm_parallel`` also flips\n on the finite shared LLM broker (``orchestrator._startup_llm_total``), while legacy ``parallel_build``\n means \"legacy build width, unbounded overlap, no shared total\". So promoting a legacy-only\n ``parallel_build`` layer (e.g. just ``LOOPLAB_PARALLEL_BUILD=1`` in env) would silently serialize\n every provider call — contradicting llm_broker.py's docstring, orchestrator's \"legacy-only preserves\n unbounded overlap\" note, the broker-neutral live-control path, and the configuration.md ``llm_parallel``\n row (\"falls back ... without enabling a shared total\"). Config/startup loads therefore keep broker\n opt-in tied to an EXPLICITLY-spelled canonical ``llm_parallel``; the orchestrator's own width fallback\n (``llm_parallel if not None else parallel_build``) still carries the legacy build width. Only the\n Strategist live-control path (``canonicalize_strategy_parallelism``) opts into\n ``promote_build_to_llm_parallel``, where ``parallel_build`` is a deliberate full alias of the\n canonical knob (see strategy.py ``_apply_strategy``).\n \"\"\"\n if not isinstance(values, dict):\n return values\n out = dict(values)\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if canonical == \"llm_parallel\" and not promote_build_to_llm_parallel:\n continue\n # None is the durable legacy mode for llm_parallel, not a missing value. A normal snapshot\n # contains llm_parallel:null plus parallel_build:1; promoting null to 1 would unexpectedly enable\n # the global one-call broker. Promote aliases only when the canonical key is absent, and pin\n # snapshot round-trip parity. Canonicalize inside each precedence layer, never across the\n # flattened result, so the alias migration cannot invert CLI > file > env precedence.\n if canonical not in out and out.get(legacy) is not None:\n out[canonical] = out[legacy]\n return out\n\n# Settings whose semantics are committed by ``run_started`` and restored from the folded event log\n# on every re-entry. Changing one only in ``config.snapshot.json`` would mix incompatible evidence\n# (a different holdout split) or make the live producer disagree with replay's selection policy.\n# The HTTP config editor and the engine both consume this one contract; keep deliberately mutable\n# controls such as ``trust_gate`` out (that field has its own durable change event).\nRUN_START_PINNED_FIELDS = frozenset({\n \"card_driven_selection\",\n \"speculation_depth\",\n \"holdout_fraction\",\n \"holdout_select\",\n \"select_verifier\",\n \"select_verifier_samples\",\n \"verifier_ci_tie\",\n})\n\n\ndef run_start_pinned_settings(state) -> dict:\n \"\"\"Return the effective Settings names committed by a folded run-start record.\n\n Pre-pin legacy logs have no recorded holdout fraction, so their snapshot remains authoritative\n for both holdout knobs. Verifier fields, however, have always been re-pinned from the legacy-safe\n fold defaults whenever a valid ``run_started`` identity exists.\n \"\"\"\n if not getattr(state, \"run_id\", \"\"):\n return {}\n values = {\n # Layer 3 changes which queue owns macro-action selection. Once a run starts, replay and\n # resume must keep the same owner even if the snapshot or ambient default changes later.\n \"card_driven_selection\": bool(getattr(state, \"card_driven_selection\", False)),\n # Layer 5 overlaps Card production with evaluation only when this pinned treatment is\n # positive. Old logs omit it and therefore retain the exact serial build/eval spine.\n \"speculation_depth\": int(getattr(state, \"speculation_depth\", 0)),\n \"select_verifier\": bool(getattr(state, \"select_verifier_tiebreak\", False)),\n \"select_verifier_samples\": int(getattr(state, \"select_verifier_samples\", 3)),\n \"verifier_ci_tie\": bool(getattr(state, \"verifier_ci_tie\", False)),\n }\n holdout_fraction = getattr(state, \"holdout_fraction\", None)\n if holdout_fraction is not None:\n values.update({\n \"holdout_fraction\": holdout_fraction,\n \"holdout_select\": bool(getattr(state, \"holdout_select\", False)),\n })\n if not values.keys() <= RUN_START_PINNED_FIELDS:\n raise RuntimeError(\"folded run-start pinned settings contract drifted\")\n return values\n\n# --- Run profiles (config-first presets) -------------------------------------------------------\n# A `profile` is a NAMED BUNDLE of setting defaults — nothing more. It only fills fields the user\n# did NOT set explicitly (via file / --set / LOOPLAB_* env / init-kwargs), so any explicit knob\n# always wins (see `_apply_profile`). Most engine intelligence lives behind individual flags. The\n# PART IV/V cross-run + concept-graph machinery (concept_pivot, graded_novelty, cross_run_concepts /\n# _advisory / _structured_claims / _curation / _read_tools, fingerprint_universal, foresight_verify)\n# now ships ON in the product `Settings` default. That is an explicit experimental product choice,\n# not a claim that a frozen A/B or workload-wide cost/quality gate has cleared: the bundle can steer\n# prompts and candidate generation, change graded-novelty admission / pre-execution choices, and add\n# paid LLM work when its client and memory prerequisites are present. The bare-library `EngineOptions`\n# default stays lean (see tests/test_options_divergence.py — product side is the aggressive one) so a\n# toy `Engine(...)` in a test doesn't fire cross-run LLM work unasked. The trust/confirm/ablate QUALITY\n# bundle still ships OFF and is turned on by `thorough`. Every value here is reachable by hand — the\n# profile is a convenience, never a hidden mode.\n# keep this rationale behavioral and evidence-based; do not relabel read-only storage\n# access or proposal-only governance as a no-op for agent behavior, cost, or proposal admission.\n#\n# `thorough` deliberately touches only QUALITY/TRUST machinery, not spend: it does NOT raise\n# max_nodes / eval_parallel (those are cost knobs the user owns). It enables multi-seed confirmation\n# (demote seed-lucky leaders), the reward-hack / leakage / critic monitors AND flips them from\n# advisory to *gating* (`trust_gate=\"gate\"` — a\n# flagged win can no longer be selected as best), ablation-driven refinement, and the prompt cues\n# (complexity / budget / failure-reflection / watchdog-reflection). Agentic novelty and the experimental\n# Part IV/V bundle are already product defaults, not profile changes. Effect is large; marginal cost is a\n# few extra evals.\nPROFILES: dict[str, dict] = {\n \"default\": {}, # explicit no-op alias for \"ship-as-is\" (== omitting profile)\n \"fast\": {}, # today's lean defaults, named for symmetry with `thorough`\n \"thorough\": {\n \"confirm_top_k\": 3,\n \"confirm_seeds\": 3,\n # novelty_gate intentionally NOT enabled here: duplicate-detection is the agentic Researcher's\n # job (it reads past experiments + find_analogous_across_runs and DECIDES), not an algorithmic\n # param-distance / embedding auto-reject that mis-fired (it nudged a good fresh idea onto a\n # twice-dead lineage — live node 61). Search stays as tools; the LLM judges novelty.\n \"operator_bandit\": True, # P4: adaptive operator mix over folded yields (GreedyTree)\n \"reward_hack_detect\": True,\n \"code_leakage_detect\": True,\n \"critic_check\": True,\n \"trust_gate\": \"gate\", # detectors stop gating a flagged node from WINNING (not just audit)\n \"ablate_every\": 3,\n \"complexity_cue\": True,\n \"budget_aware\": True,\n \"failure_reflection\": True,\n \"watchdog_reflection\": True, # feed recent live-watchdog (train-monitor/ASHA) flags to proposals\n \"reflection_priors\": True, # no-op unless memory_dir is set (cross-run priors)\n },\n}\n\n\n# The shipped agent-governance matrix (who may change which setting at runtime). Defined as a module\n# constant so the Engine's direct-construction default matches this shipped default exactly (the\n# EngineOptions \"Engine() == shipped defaults\" invariant) instead of diverging to an all-locked map.\nDEFAULT_AGENT_CONTROL: dict[str, list[str]] = {\n \"timeout\": [\"researcher\", \"strategist\"],\n # Layer-2 canonical names are the presented/default governance surface. `_agent_may` falls back\n # to an old snapshot's legacy grant only when its canonical counterpart is absent, so removing\n # duplicate defaults preserves resume authority while an explicit canonical revocation still wins.\n \"eval_parallel\": [\"strategist\"],\n \"llm_parallel\": [\"strategist\"],\n # Strategy-only pseudo-setting: the closed per-lane allocation inside the canonical LLM total.\n # It is not a launch Settings field; operators pin it through `set_strategy` and the Strategist\n # may reallocate it only while this grant is present.\n \"llm_lane_limits\": [\"strategist\"],\n \"max_nodes\": [\"strategist\", \"boss\"],\n \"max_eval_seconds\": [\"strategist\", \"boss\"],\n \"policy\": [\"strategist\"],\n # The policy NAME and its PARAMS are gated independently in `_apply_strategy`; grant both by default\n # so the Strategist that may switch to (say) MCTS may also apply the `c`/`eta` it decided — else its\n # tuned params are silently dropped and the recorded `active_strategy` diverges from the live engine.\n \"policy_params\": [\"strategist\"],\n \"n_seeds\": [\"strategist\"],\n \"ablate_every\": [\"strategist\"],\n \"merge_mode\": [\"strategist\"],\n \"complexity_cue\": [\"strategist\"],\n \"ablate_code_blocks\": [\"strategist\"],\n \"prefer_sweep\": [\"strategist\"],\n \"novelty_stance\": [\"strategist\"],\n \"developer\": [\"strategist\"],\n \"fidelity\": [\"strategist\", \"researcher\"],\n}\n\n\ndef default_agent_control() -> dict[str, list[str]]:\n \"\"\"A FRESH deep-ish copy of the shipped governance matrix (each role list copied so a caller can\n mutate its own map without touching the module constant). The SINGLE constructor of the default,\n called by both `Settings.agent_control`'s default_factory AND the Engine's direct-construction\n default — so \"Engine() == shipped defaults\" can't drift if the copy depth ever needs to change\n (e.g. nested values requiring copy.deepcopy), instead of two verbatim copy expressions.\"\"\"\n return {k: list(v) for k, v in DEFAULT_AGENT_CONTROL.items()}\n\n\nclass Settings(BaseSettings):\n \"\"\"The engine settings schema (every knob a run accepts).\n\n Timeout family — six distinct knobs, each owned by a different subsystem:\n - `timeout`: per-eval wall-clock budget for ONE experiment's evaluation (engine/eval).\n - `max_eval_timeout`: hard ceiling for a governed per-node Researcher timeout override.\n - `llm_timeout`: LLM request idle timeout — inter-token stall limit in stream mode\n (core.llm OpenAICompatibleClient).\n - `llm_header_timeout`: LLM first-byte (response-headers) window for stream attempts (core.llm).\n - `agent_time_budget_s`: wall-clock ceiling across a tool-using agent loop's turns (agents).\n - `dep_install_timeout`: per-package budget for auto-installing a missing dep (env self-prep).\n\n Config-source precedence is owned by `looplab.core.appconfig` (the loader) — see its module\n docstring for the one canonical order.\n \"\"\"\n # Config precedence: see appconfig.py — the loader owns the full order. The `.env` is read\n # so `looplab run`/`looplab ui` pick up LOOPLAB_LLM_BASE_URL etc. without exporting them by hand\n # (keys MUST carry the LOOPLAB_ prefix, same as the env vars). utf-8-sig tolerates a BOM from\n # Windows editors. The test suite disables this (tests/conftest.py) so a dev's real .env in the\n # repo root can't leak into assertions.\n model_config = SettingsConfigDict(\n env_prefix=\"LOOPLAB_\",\n env_file=\".env\",\n env_file_encoding=\"utf-8-sig\",\n extra=\"ignore\",\n allow_inf_nan=False,\n )\n\n @classmethod\n def settings_customise_sources(\n cls, settings_cls, init_settings, env_settings, dotenv_settings,\n file_secret_settings):\n \"\"\"Preserve source precedence across canonical/legacy parallelism names.\n\n Pydantic ranks init > env > dotenv correctly, but it ranks field names rather than semantic\n alias families. Promoting a legacy value inside each source before the merge makes an old\n explicit snapshot/file value beat a lower-priority canonical env value. ``appconfig`` also\n promotes its file/typed/--set sub-layers before combining them into the init source.\n \"\"\"\n def _with_parallel_aliases(source):\n return lambda: canonicalize_parallelism_source(source())\n\n return (\n _with_parallel_aliases(init_settings),\n _with_parallel_aliases(env_settings),\n _with_parallel_aliases(dotenv_settings),\n _with_parallel_aliases(file_secret_settings),\n )\n\n # Run profile (config-first preset, see PROFILES above). \"default\"/\"fast\" = today's lean\n # defaults; \"thorough\" turns the built quality/trust machinery ON in one word. A profile only\n # fills fields the user did NOT set — every knob it touches stays individually overridable.\n profile: str = \"default\"\n\n # Bounds (review C/⚪): a non-positive node/seed budget or timeout is never valid. Upper bounds are\n # equally load-bearing — the UI /start path writes these straight into the engine's env, and an\n # unbounded `eval_parallel` (or legacy `max_parallel`; e.g. 100000 from a crafted preflight) would\n # fan out that many sandboxes → resource exhaustion. Reject at config time, not mid-run. Ceilings\n # are generous; tests top out near 50.\n n_seeds: int = Field(default=3, ge=1, le=1024)\n max_nodes: int = Field(default=8, ge=1, le=1_000_000)\n # legacy evaluation-width input. `eval_parallel` below is canonical and wins when set;\n # keep this field for old env/config/snapshots and direct Engine call sites. A width > 1 admits\n # concurrent evals, whose resource reservations may be CPU-only or span one/more GPUs — there is no\n # one-node/one-GPU promise. `max_parallel=0` is launch-time AUTO (detected GPU count, at least 1).\n max_parallel: int = Field(default=1, ge=0, le=1024)\n # legacy build-width input. `llm_parallel` below is canonical and also controls the\n # shared provider-call total when positive. A width > 1 uses fresh pooled researcher/developer pairs;\n # it says nothing about the later eval's CPU/GPU reservation. Startup `0` derives build fan-out from\n # resolved eval width; a live Strategist/operator zero settles to 1. Without role_factory it clamps to 1.\n parallel_build: int = Field(default=1, ge=0, le=64)\n # Hypothesis-card Kanban re-architecture (docs/23, Layer 2). Two INDEPENDENT concurrency axes,\n # decoupled from each other so cost (LLM work) can be tuned separately from throughput (GPU evals).\n # These are the CANONICAL names going forward; `max_parallel`/`parallel_build` above are kept as thin\n # back-compat aliases (env vars / snapshots / ~100 Engine(...) call sites depend on the exact names,\n # so they cannot be renamed — CLAUDE.md). Resolution (orchestrator.__init__): a NEW name, when set,\n # WINS over its legacy alias; None => fall back to the legacy field => byte-identical to today.\n # `eval_parallel`: concurrent EVALS (the GPU/experiment consumer). None -> max_parallel; 0 = AUTO\n # (one experiment per detected GPU). This is the in-Run eval-concurrency ceiling; the resource\n # scheduler bin-packs declared footprints separately. Sibling local GPU-owning Runs serialize\n # through the conservative host-pool lease; external execution needs its own admission boundary.\n # `llm_parallel`: total concurrent LLM provider calls + BUILD fan-out. None -> legacy\n # parallel_build semantics without a shared total; 0 = startup AUTO (= resolved eval_parallel\n # for build fan-out, historical research overlap unbounded). A positive canonical value enables\n # the named-lane broker the Strategist can reallocate at cadence.\n eval_parallel: int | None = Field(default=None, ge=0, le=1024)\n llm_parallel: int | None = Field(default=None, ge=0, le=64)\n # Training-log monitor (I-series watchdog family): a per-eval background observer that tails the live\n # training log while a (often multi-hour) declared stage runs in a worker thread. ON in the product\n # surface (Settings). Its alert event is fold-ignored and cannot directly change lifecycle/champion;\n # with watchdog_reflection on, that alert is nevertheless advice in a later Researcher\n # prompt and can change future proposals. It only fires on the command-eval path with an LLM client;\n # bare-library EngineOptions stays OFF. See test_options_divergence.\n # `_interval_s` is the BASE tick cadence (the effective one adapts to the per-experiment budget).\n train_monitor: bool = True\n train_monitor_interval_s: float = Field(default=600.0, gt=0)\n # Phase 3 — INTERVENTION (opt-in, separate from observation): let the monitor tree-kill a training the\n # LLM judges 'broken' (diverged / silent CPU fallback / not learning) EARLY, instead of burning the\n # whole budget. Off by default — the observer only WATCHES unless this is on. A kill only fires on a\n # 'broken' verdict (a plateau is 'watch', never killed) with confidence >= the threshold; the node then\n # fails normally (reason='monitor_broken'), so replay reconstructs it from that one terminal event.\n train_monitor_kill: bool = False\n train_monitor_kill_confidence: float = Field(default=0.8, ge=0.0, le=1.0)\n # ASHA live-curve watchdog (sibling of the training monitor): reads the latest INTERMEDIATE value of\n # the objective metric off the live log (reusing the eval's OWN metric reader). Finished-endpoint rank\n # remains advisory. An opt-in KILL additionally requires an operator-declared metric.resource_key and\n # enough sibling observations at exactly that resource; absent comparable evidence cannot stop a run.\n # Records a fold-ignored `asha_rank` diagnostic + trace span; the library default is OFF (off == today).\n asha_live: bool = True\n # Opt-in INTERVENTION (separate from the advisory signal): let the watchdog tree-kill a node whose\n # intermediate metric stays below the SAME-RESOURCE bar past a short grace window. Off by default —\n # it only surfaces endpoint rank unless this is on. A kill fails normally (asha_underperforming).\n asha_live_kill: bool = False\n # The bar sits at `asha_live_quantile` along a WORST->BEST ordering of the finished siblings' finals:\n # 0.5 = the median (a node worse than the median peer flags); a SMALLER value is more conservative —\n # it lowers the bar toward the WORST finished peer, so only a node worse than nearly all peers flags\n # (0.0 = worse than the worst). `min_siblings` is the floor of finished peers required before it ranks\n # at all, so it never acts on too little evidence.\n asha_live_quantile: float = Field(default=0.5, ge=0.0, le=1.0)\n asha_live_min_siblings: int = Field(default=3, ge=1)\n timeout: float = Field(default=30.0, gt=0)\n # Hard ceiling for the Researcher's governed per-node `Idea.eval_timeout`. One hour preserves the\n # existing heavy/neural-net authoring example while staying well below the sandbox's defensive\n # 24-hour subprocess ceiling. This is operator-owned run config: agents may request less or more,\n # but the accepted action is always clamped here after the `agent_control.timeout` permission gate.\n max_eval_timeout: float = Field(default=3600.0, gt=0, le=24 * 3600.0)\n # Intra-node sweep: a sweep node runs a whole grid in one process, so it gets this multiple of\n # `timeout` as its wall-clock budget (solution.py path; RepoTasks use their per-profile timeout).\n sweep_timeout_mult: float = Field(default=8.0, ge=1)\n # Eval STALL watchdog CAP (seconds): a stage that is completely SILENT on stdout/stderr for this\n # long — while still alive and BELOW its wall-clock deadline — is tree-killed early with a STALLED\n # marker (a hung dataloader/deadlock dies in minutes instead of burning a multi-hour timeout). The\n # per-stage window is `min(this, the stage's own timeout)`, so a short scorer's deadline fires first.\n # `0` DISABLES the watchdog entirely (only the hard deadline applies) — for a legitimately quiet\n # non-Python stage (block-buffered stdout, a script that logs only to its own file). Threaded into\n # the eval and surfaced to the Developer so its code can emit periodic progress to stay alive.\n eval_stall_timeout_s: float = Field(default=1800.0, ge=0)\n # Sandbox tier (ADR-13): \"trusted_local\" (subprocess, no Docker) for the CLI;\n # \"untrusted\" (Docker --network none, shared-kernel runtime) for hosted/multi-tenant UI;\n # \"hostile\" (untrusted + a true-isolation OCI runtime, gVisor `runsc` by default / Kata) for\n # running untrusted code. See runtime/sandbox.py::make_sandbox for the tier selection.\n trust_mode: str = \"trusted_local\"\n # Image for the untrusted command-eval tier (RepoTask, Phase 4): the framework's deps\n # should be baked in (the container runs --network none, so a pip setup can't fetch).\n docker_image: str = \"python:3.12-slim\"\n # Resource caps for the untrusted/hostile Docker tier (passed to `docker run --memory/--cpus`).\n # The default 4g bound protects other tenants from an OOM, but a model-training MLE-bench eval\n # routinely needs more — raise it (e.g. \"16g\") for such runs. \"\" disables the cap (unbounded).\n # Ignored by the trusted_local (subprocess) tier.\n sandbox_memory: str = \"4g\"\n sandbox_cpus: str = \"\"\n # Best-effort host-OOM guard for the TRUSTED-LOCAL (subprocess) tier. When set (e.g. \"8g\"), each\n # eval child gets an RLIMIT_AS address-space cap so a runaway trainer hits MemoryError instead of\n # OOM-killing the whole host (and the engine). POSIX only; a no-op on Windows. Default \"\" = OFF:\n # RLIMIT_AS bounds VIRTUAL memory, and CUDA/torch reserve tens of GB of virtual without using it,\n # so a default cap would spuriously kill GPU evals — leave it off for those and use the Docker\n # tier's real --memory cgroup bound (sandbox_memory) instead. Ideal for CPU/numpy/sklearn runs.\n sandbox_memory_local: str = \"\"\n # Best-effort disk-fill guard for the TRUSTED-LOCAL tier (P1-5): an RLIMIT_FSIZE cap on the size of\n # any single file an eval child writes (e.g. \"2g\"), so a runaway that writes an unbounded file gets\n # SIGXFSZ instead of filling the host disk. POSIX only. Default \"\" = OFF (large model checkpoints\n # need big files); set it for tasks that write only small artifacts.\n sandbox_fsize_local: str = \"\"\n # Search policy (ADR-2): \"greedy\" | \"evolutionary\" | \"mcts\" | \"asha\".\n policy: str = \"greedy\"\n # Ablation-driven refinement (I7): every N improves, ablate the best to find the\n # highest-impact parameter and refine it. 0 = off. (GreedyTree only.)\n ablate_every: int = 0\n # A0a (MLE-STAR): when ablating, treat each generated pipeline *code block* (not just a\n # numeric param) as an ablation unit — neutralize a block, measure the metric delta, refine\n # the highest-impact block. Off => the classic numeric-param ablation. Config-first knob.\n ablate_code_blocks: bool = False\n # A0b: real merge/ensembling. \"mean\" = legacy mean-param merge; \"ensemble\" = the Developer\n # writes a code-recombination ensemble over the two parents' solutions (verified: agent-proposed\n # ensembling 37.9%->43.9%). \"auto\" (default) = ensemble whenever the Developer actually\n # generates code (LLM/agent backends; declared via `is_code_generating`), mean for the\n # templated/toy developers where a code ensemble is meaningless — the evidence says code\n # recombination is the strongest single merge (removing it costs ~9 pp), so it is the default\n # wherever it can work. Falls back to mean when the Developer can't ensemble.\n merge_mode: str = \"auto\"\n # A0d (AIRA): inject a dynamic complexity hint into the draft/improve prompt keyed on the\n # node's child count (few children -> keep minimal; many -> escalate to ensembling/HPO).\n complexity_cue: bool = False\n # I1 (CAAFE-style) LLM feature-engineering: instruct the proposer/developer to add\n # semantically-meaningful engineered features (ratios, interactions, aggregations) as code. The\n # existing cross-validation eval + best-selection is the CV GATE — a feature set that doesn't\n # improve CV is never selected (feature engineering is non-universal, so this gate is mandatory).\n # Tabular tasks (those exposing column data). Off by default.\n feature_engineering: bool = False\n # A5: surface the REMAINING eval-compute budget into the proposal prompt so the Researcher can\n # reason over how much compute is left (explore broad while flush, exploit/cheapen when low).\n # No-op unless a max_eval_seconds budget is set.\n budget_aware: bool = False\n # A4 (LATS-style): feed a summary of the most recent FAILED branches (operator + error reason)\n # back into the proposal prompt so the proposer reflects on and avoids repeating them. ON by\n # default: it is SELECTIVE by construction (injects only when recent failures exist — the\n # evidenced form of reflection; reflecting at every step shows no gains, arXiv:2506.12928).\n failure_reflection: bool = True\n # Feed the LIVE-WATCHDOG observations (train-monitor health verdicts + ASHA intermediate-rank flags)\n # of recent experiments back into the proposal prompt, so the Researcher avoids re-proposing a config\n # whose TRAINING was already observed to be weak — even when the watchdog kills are off (default) and\n # the node ran to completion (those diagnostics are fold-ignored, so failure_reflection never sees\n # them). ON by default and SELECTIVE like failure_reflection (injects only when a recent flag exists).\n watchdog_reflection: bool = True\n # C3 deep test-driven repair: hand the Developer the failure taxonomy + a structured \"reproduce\n # then fix\" directive on debug, not just the raw stderr tail. ON by default (product surface); the\n # conservative library default in EngineOptions stays off, per that module's contract.\n deep_repair: bool = True\n # === Inline crash repair ==============================================================\n # Hybrid in-node crash repair: when an LLM-generated node CRASHES at runtime (mechanical errors\n # — bad import, removed kwarg, typo), the agent triages it and may repair the code IN PLACE within\n # the same eval (cheap; does NOT consume max_nodes and does NOT add a node to the search tree).\n # Semantic failures still flow to a new idea/debug node via the policy. ON by default; set False\n # to restore the prior behavior (every crash waits for a budgeted inter-node debug node).\n inline_repair: bool = True\n # Max in-place repair attempts per node before it fails normally. Default 0 = UNLIMITED: the same\n # agent keeps fixing + re-evaluating IN THE SAME node until it produces a clean metric — no new\n # node just to retry. Runaway is prevented not by a count but by the anti-stuck guard\n # (`inline_repair_stuck_repeat`): when the SAME error signature repeats with no progress, or the\n # agent's crash-triage says \"abandon\", the node fails. Set a positive N to cap the retries instead.\n inline_repair_attempts: int = Field(default=0, ge=0)\n # Anti-stuck: abandon in-node repair when the SAME error recurs this many times in a row (no\n # progress). Keeps \"unlimited\" repair from looping forever on an unfixable error.\n inline_repair_stuck_repeat: int = Field(default=4, ge=2)\n # Which failure reasons (_failure_reason: crash|timeout|oom|setup|no_metric|drift) are eligible for\n # inline repair. Default: mechanical crashes, timeouts AND OOM-kills — a timeout/OOM means the code\n # was too slow / too memory-hungry for the budget (or the pod's cgroup limit), not that the idea is\n # wrong, so the agent repairs it by reducing compute/memory (fewer estimators/epochs/folds/seeds, a\n # lighter model, a smaller batch, subsampling) instead of letting the node die unrecovered. Env\n # override expects a JSON array.\n inline_repair_reasons: tuple[str, ...] = (\"crash\", \"timeout\", \"oom\")\n # Cost ceiling for in-node repair of a MULTI-STAGE eval: the max number of FULL pipeline re-runs\n # (i.e. re-trains from the first stage) the inline-repair loop may do before abandoning to the\n # inter-node debug operator. A late-stage failure (a broken `score`/eval script that didn't touch\n # `train`) REUSES the completed train checkpoint and re-runs only from the failed stage — that is\n # CHEAP and NOT counted here; only a repair that changes an EARLIER stage's code (train.py/loss.py/…)\n # forces a full re-train, which each costs the whole training time. Without this bound, a repair that\n # keeps changing training code could burn many full trains (the anti-stuck guard is error-signature\n # based, not cost based). 0 = unlimited (legacy behavior). Single-command evals ignore it.\n inline_repair_retrain_cap: int = Field(default=2, ge=0)\n # Environment self-prep: when a solution crashes purely because a KNOWN library isn't installed\n # (ModuleNotFoundError), the engine pip-installs it into the eval interpreter and re-runs — so a\n # torch/XGBoost/CatBoost idea (e.g. a GRU model) actually runs on a fresh box instead of being\n # thrown away as `idea_rejected`. Trusted_local tier only (the untrusted/hostile Docker tiers run\n # --network none). A typo'd or local helper module is NOT installed (that's a code bug to repair).\n auto_install_deps: bool = True\n # Per-package wall-clock budget for an auto-install (seconds). Generous: large wheels like torch\n # can take minutes to download/build.\n dep_install_timeout: float = Field(default=900.0, gt=0)\n # --- Agent governance: who may change a setting at runtime (per-setting allow-list of roles) ---\n # Roles: \"strategist\" (A7 meta-controller; run-wide), \"boss\" (run-chat operator-proxy; run-wide),\n # \"researcher\" (per-experiment proposer; PER-NODE — e.g. set a longer eval timeout for a neural-net\n # idea). A setting ABSENT from this map is LOCKED — no agent may change it, only a human via the\n # snapshot/UI. The sole conditional exception is Strategy-only `card_scoring`: Card mode grants\n # it implicitly without changing the default flag-off snapshot, while an explicit [] revokes it.\n # The gate is ENFORCED at every AGENT seam via `_agent_may` — the strategist's whole\n # control surface (policy/operators/novelty_stance/prefer_sweep/developer/fidelity/timeout/\n # eval_parallel/llm_parallel) is checked in `_apply_strategy`, so removing a role from a knob locks it,\n # not just greys out the UI pill. The boss/max_* grants document boss-relevant knobs, but a\n # `budget_extend` is a HUMAN control intent (the boss action-builder can only emit `add_nodes`), so\n # it is applied as-is — the human always wins via the UI/snapshot. Defaults are conservative: resource/\n # search-shape knobs the agents already reason about; provider infra (llm_model/base_url/api_key,\n # docker_image) stays locked. The UI shows S/B/R pills per setting. `timeout` granted to the\n # researcher IS the \"auto\" per-node mode — the researcher sizes each experiment's wall-clock\n # (Idea.eval_timeout); the config `timeout` is the fallback for nodes it doesn't size. Env override\n # expects a JSON object. (`fidelity`/`novelty_stance`/`prefer_sweep`/`developer` are governance keys\n # for the strategist's per-node dials — not 1:1 Settings fields, but gated the same way.) The default\n # lives in the module-level DEFAULT_AGENT_CONTROL so the Engine's direct-construction default matches.\n agent_control: dict[str, list[str]] = Field(default_factory=default_agent_control)\n # C1 (Agentless localization): for repo tasks, rank the source files most relevant to the most\n # recent failure (traceback + identifiers) and surface them in the proposal/repair prompt so the\n # Developer edits the right place. Off by default; repo tasks only.\n localize_faults: bool = False\n # A2 surrogate-guided proposal (BO-lite): fit a k-NN surrogate over (params->metric) and propose\n # by acquisition instead of random/hill-climb. Numeric-bounds tasks only; bootstraps via the\n # base Researcher. Off by default. `surrogate_explore` = UCB-style exploration weight.\n surrogate_proposer: bool = False\n surrogate_explore: float = 0.1\n # E2 researcher panel: generate K candidate ideas per proposal and keep the one ranked best by a\n # cheap empirical surrogate over the (params->metric) history (NOT an LLM-judge). 1 = off.\n researcher_panel: int = 1\n # A1 ASHA / successive-halving: reduction factor (keep top 1/eta per rung) and rung budget.\n asha_eta: int = Field(default=3, ge=2)\n # Rung-0 width for ASHA/BOHB (the wide base of cheap drafts). 0 = use n_seeds (default,\n # preserving prior behavior); set >0 to seed a wider/narrower base independent of n_seeds.\n asha_rung_nodes: int = Field(default=0, ge=0)\n # A6 proxy/predictive scoring: cheaply rank a candidate's potential from early-stage signals\n # and skip a full eval for the doomed bottom fraction. 0.0 = off (no candidate skipped).\n proxy_scoring: bool = False\n proxy_kill_fraction: float = Field(default=0.0, ge=0.0, le=0.9)\n # === Novelty / dedup ==================================================================\n # E1 novelty/dedup gate: reject near-duplicate proposals (normalized param-space distance below\n # `novelty_epsilon`) by deterministically nudging them off the duplicate — stops the search\n # wasting evals re-trying the same idea. Audit event `novelty_rejected`. Off by default.\n # Novelty/dedup MODE selector: how a fresh proposal is checked against already-tried experiments.\n # \"off\" — no explicit gate; the agentic Researcher's own read-the-history judgment is trusted.\n # \"algo\" — the deterministic gate: numeric param-distance (`novelty_epsilon`) + optional embedding\n # similarity (`novelty_semantic`). Cheap, no LLM call, but can't explain itself.\n # \"llm\" — an LLM adjudicates (reads the real experiments via tools) whether the idea is a\n # near-duplicate and, if so, asks the Researcher once more for something different. One\n # extra LLM call per proposal — highest quality, follows the \"let the LLM decide\" line.\n novelty_mode: str = \"llm\"\n # Legacy sub-toggles, honored by the \"algo\" mode (and back-compat: novelty_gate=True forces \"algo\"):\n novelty_gate: bool = False\n novelty_epsilon: float = 0.05\n # T5 semantic novelty (active whenever the deterministic gate runs — novelty_mode=algo,\n # novelty_gate=true which is the legacy alias for algo, OR the Strategist novelty stance is\n # \"explore\"; a no-op only under novelty_mode=llm/off with a non-explore stance): reject a proposal\n # whose idea TEXT embeds within\n # `novelty_semantic_threshold` cosine of an existing node's. OFF BY DEFAULT: novelty is the agentic\n # Researcher's call, made by READING the actual prior experiments (read_experiment /\n # find_analogous_across_runs / list_experiments), NOT an embedding-similarity auto-reject that can't\n # explain itself and mis-fires on paraphrases. Semantic/param search stays available as TOOLS in the\n # Researcher's context; the LLM decides whether an idea is a true duplicate.\n novelty_semantic: bool = False\n novelty_semantic_threshold: float = Field(default=0.92, ge=0.5, le=1.0)\n # T10: debug-lineage depth bound for every policy — how many error-feedback repairs a failing\n # lineage gets before it is abandoned. The old default (1) abandoned lineages after ONE repair;\n # multi-turn/deeper debugging is a verified lever (AIRA2 ReAct debug +5.5 percentile points).\n debug_depth: int = Field(default=2, ge=1)\n # P4 operator bandit (GreedyTree): replace the fixed merge/ablate cadences with a\n # deterministic UCB over per-operator yield (Δmetric per eval-second, folded from the run).\n # Off by default (the cadences are well-tested and the bandit has no direct published\n # ablation); `thorough` turns it on.\n operator_bandit: bool = False\n # P1 hypothesis ledger: ask the Researcher to state the one-line hypothesis each experiment tests,\n # register deep-research directions as hypotheses, and track them to a verdict on the board. ON by\n # default. The board is shown to the Researcher and can be foresight-ordered, so it can\n # steer later proposals even though it never directly re-ranks already-evaluated nodes. Set False\n # to drop the prompt nudge + registration.\n track_hypotheses: bool = True\n # E4/M2/M3 reflection-memory -> priors (gradient-free cross-run meta-learning): at run end distill\n # the winner into `<memory_dir>/meta_notes.jsonl` AND structured lessons (incl. NEGATIVE results:\n # tested/abandoned hypotheses + failure themes) into `lessons.jsonl` with a task fingerprint; at run\n # start, inject exact-task notes + fingerprint-matched lessons from SIMILAR tasks into the proposal\n # prompt. ON by default — but a NO-OP until `memory_dir` is set (that's where cross-run memory lives).\n reflection_priors: bool = True\n # M6 comparative lessons (MARS \"comparative reflective memory\", doc 13 §7 item 2): distill\n # credit-assigned lessons from PAIRS of solutions — which SPECIFIC change made a child beat\n # (or regress from) its parent, and what fixed a failure — on top of the one-shot run-end\n # reflection. At most one extra LLM call per distillation (all pairs batched); offline a\n # deterministic param-diff credit stands in. Gated on reflection_priors + memory_dir like the\n # rest of cross-run memory — and since memory_dir has a default (~/.looplab/memory), this is\n # ACTIVE out of the box; clear memory_dir to disable cross-run memory wholesale.\n comparative_lessons: bool = True\n # M6 mid-run cadences (doc 13 §7 item 5 — the AgentRxiv live-share pattern): every N created\n # nodes, WRITE comparative lessons to the shared cross-run store during the run (lessons_every\n # — one batched LLM call per firing) and RE-READ the store so lessons distilled by CONCURRENT\n # runs reach this run's proposals (lessons_refresh_every; file re-read, no LLM call, skipped\n # when the store is unchanged). ON by default (every 4 nodes) and, like comparative_lessons\n # above, live out of the box because memory_dir defaults on; 0 = off — run-end write /\n # run-start read only, the pre-M6 behavior.\n lessons_every: int = Field(default=4, ge=0)\n lessons_refresh_every: int = Field(default=4, ge=0)\n # B3 output redaction: mask credentials (known key shapes + high-entropy tokens) in the\n # stdout/stderr tail before it is persisted to the event log / spans / UI — a leaked secret in a\n # print()/traceback must not land in the durable log. Off by default; recommend on for untrusted.\n redact_output: bool = False\n # B5 reward-hacking detector: a host-side monitor that flags suspicious wins (grader/answer-key\n # access, runtime writes to frozen files, suspiciously-perfect metrics) as a `reward_hack_suspected`\n # audit event in the Trust panel. Off by default. Whether a flag CHANGES selection is governed by\n # `trust_gate` below (default: audit-only, never changes selection).\n reward_hack_detect: bool = False\n # Trust enforcement (T2): what a reward-hack / data-leakage flag actually DOES to selection.\n # \"audit\" (default) = surface it in the Trust panel, never change selection (today's behavior);\n # \"gate\" = a flagged node can no longer be selected as BEST **and isn't bred/confirmed from**\n # (breed_excluded — see replay._apply_trust_gate / models.breedable_nodes), but stays\n # FEASIBLE so it still counts for diversity/audit — closes the \"a hacked/leaky node can\n # win OR seed its lineage\" hole;\n # \"block\" = additionally mark it fully INFEASIBLE (removed from feasible_nodes entirely).\n # Deliberately gates only high-precision CHEATING/LEAKAGE signals. Broad `critic:*` findings stay\n # advisory; `critic:hardcoded_metric` is the one narrow high-precision critic exception\n # and gates because it requires a literal metric with no computed assignment anywhere.\n trust_gate: str = \"audit\"\n # I3 data-centric: static code-leakage scan of each evaluated solution (fit-before-split,\n # fit-on-test) surfaced into the Trust panel alongside reward-hack flags. Off by default.\n code_leakage_detect: bool = False\n # 4.4 sandbox instrumentation (needs reward_hack_detect): after each eval, flag a RUNTIME write\n # to a protected/frozen file (an answer key or grader tampered mid-run) — behavioral evidence a\n # static code scan misses. ON by default; only fires when the reward-hack detector is on.\n workdir_audit: bool = True\n # D8 decoupled research Verifier: check every Deep-Research memo's CLAIMS against their cited\n # evidence (node ids / urls) before recording — synthesis is the documented weak link (Kosmos\n # 57.9% accurate). Deterministic layer always; an LLM rubric pass when the DeepResearcher has a\n # client. The verdict cannot change this run's node/champion, but finalize uses aligned supported\n # verdicts as the evidence gate for persisted D8 cross-run claims; unavailable/misaligned stays\n # unverified. ON by default (no-op with no memo/claims).\n research_verify: bool = True\n # C4 independent critic: an execution-free critic of each solution (stub / hardcoded-metric /\n # params-ignored; on host-graded tasks the metric checks become a submission-output check)\n # surfaced in the Trust panel. Broad findings are advisory; `critic:hardcoded_metric` can gate under\n # trust_gate=gate/block. Off by default.\n critic_check: bool = False\n # A7 Strategist (NEW, user-requested): optional LLM/rule meta-controller that picks the search\n # policy/allocator + operator mix + fidelity (+ Developer backend) per situation. Config-first:\n # every choice the Strategist makes is also a direct knob. Defaults to \"agent\" so the agent adapts\n # the search policy/operators/fidelity per situation; set \"off\" for static config-driven search.\n # \"agent\" = a tool-using Strategist that READS the run/data/sibling-runs/KB/memory (B1-guarded)\n # before deciding, instead of the single-shot \"llm\" call over aggregate stats.\n strategist_backend: str = \"agent\" # \"off\" | \"rule\" | \"llm\" | \"agent\" — default AGENTIC: the\n # Strategist READS the run/data/sibling-runs/KB/memory with tools before deciding, not a single-shot\n # call over aggregate stats. \"llm\" = the old non-agentic single-shot; \"rule\"/\"off\" = no LLM.\n strategist_every: int = Field(default=3, ge=1) # consult cadence (created nodes)\n # PART V (F1): concept CLASSIFIER re-tag + consolidation cadence, DECOUPLED from strategist_every. The\n # LLM concept map is heavier and slower-moving than a strategy consult, so it refreshes on its own\n # (sparser) interval. Researcher-authored idea.concepts still fold immediately at node_created — this\n # only paces the classifier-evidence + consolidation refresh (and the concept-coverage pivot snapshot).\n concept_retag_every: int = Field(default=30, ge=1)\n # === Confirmation & holdout ===========================================================\n # Multi-seed confirmation (I12, ADR-15): confirm the top-k under N seeds before\n # finishing. 0 disables (default). Only meaningful when eval has variance.\n confirm_top_k: int = 0\n confirm_seeds: int = 0\n # D1 seed-holdout discipline: the FIRST seed the confirm phase uses. Search evals run with the\n # implicit seed 0 (LOOPLAB_EVAL_SEED unset -> \"0\"), so a base of 1 keeps every confirm split\n # DISJOINT from anything the search optimized against — the confirm metric is then a\n # generalization signal, not a re-measurement (AIRA: selecting on a signal the search saw\n # overfits by 9-13 pp). Set 0 to restore the legacy overlapping seeds 0..N-1.\n confirm_seed_base: int = Field(default=1, ge=0)\n # D1 holdout-gated promotion (B6, Arbor-style): for host-graded tasks, reserve this fraction of\n # the held-out labels as a FINAL holdout partition the search never sees — every search/confirm\n # eval is scored on the remaining rows only, and at finish the val-top-k are re-scored on the\n # holdout partition (free: the predictions already exist). 0.0 = off (legacy full-label scoring).\n holdout_fraction: float = Field(default=0.25, ge=0.0, le=0.9)\n # Champion selection prefers the HOLDOUT metric among the nodes that have one (the val-top-k).\n # Recorded in run_started so replay applies the same rule; old logs fold to False (legacy pick).\n # The per-node val-holdout `generalization_gap` folds into the Trust panel either way.\n holdout_select: bool = True\n # How many val-leaders get a holdout evaluation at finish (host-graded tasks).\n holdout_top_k: int = Field(default=3, ge=1)\n # R1-c / Part IV — calibrated §12-verifier tie-break in best-selection. When a fresh node's robust\n # metric EXACTLY TIES an already-eligible node, the engine runs the §12 verifier (grounded on each\n # node's realized result, `selection_criteria`) and records a soundness score per node. The tie-break\n # then applies across the WHOLE selection path: the mean pick breaks a robust_metric tie by the score,\n # and — when `holdout_select` is on — the holdout-slot ranking prefers the sounder node so it isn't\n # denied a holdout eval, and the final holdout override breaks a holdout_metric tie by it too. Strictly\n # a TIE-BREAK — it can NEVER move a node ahead of a strictly-better metric (§21.7 advisory-never-\n # overrides). Best-effort: verification is LAZY (only fires on an actual tie) and bounded per cadence,\n # so a large/late tie stays on deterministic metric+id order unless one atomic treatment scores every\n # member. Recorded in run_started so\n # replay applies the same rule; old logs fold to False (legacy pick). Opt-in (default off); needs a\n # reachable LLM client. Enable only after validating calibration offline with `verifier.calibrate`.\n # See trust/verifier.py + core/fitness.py.\n select_verifier: bool = False\n # R1-d (§21.19): widen the verifier tie-break from EXACT-metric to a conservative STATISTICAL tie.\n # The band is capped by both the metric leader's SE and the pooled SE-of-difference, so challenger\n # variance cannot widen it and a significant difference is never a tie (§21.7). Off -> exact only.\n verifier_ci_tie: bool = False\n # Verifier sample count for `select_verifier` (the §12 repeated-sampling expectation). 3 tames the\n # single-shot variance; 1 = single-shot (cheaper, noisier).\n select_verifier_samples: int = Field(default=3, ge=1, le=32)\n # Budget (I13): hard wall-clock ceiling; the run aborts cleanly when exceeded.\n max_seconds: float | None = None\n # Eval-compute budget (#2): hard ceiling on cumulative time spent INSIDE evals (training\n # runs), separate from wall-clock. Survives resume (summed from the event log). The real\n # guard against a silent multi-hour sweep when an eval is a minutes-hours training run.\n max_eval_seconds: float | None = None\n # Cross-run memory (I19, ADR-10): if set, the best result of each run is stored as\n # a case here, and the cases become retrievable knowledge for future runs.\n memory_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"memory\"))\n # HITL (I21, ADR-11): pause for human approval of the final best before finishing.\n require_approval: bool = False\n # Diversity archive (I22): niche bucket width in parameter space.\n archive_resolution: float = 1.0\n # Coverage read-model (narrowing signal): at the strategist cadence, compute a breadth summary\n # (themes / param-niches / theme entropy / dominant-theme fraction) from the folded run, record\n # it as a `coverage_snapshot` audit event, and surface it into the Strategist's decision context.\n # Deterministic, cheap, and purely additive context — no search-behavior change on its own. On by\n # default (like the rest of the always-on situational context). See search/coverage.py.\n coverage_context: bool = True\n # PART IV Phase 2a live steering (§21.11/§21.13). When on, the `concept_retag_every` cadence (NOT\n # `strategist_every` — the producer gates on `_should_consult_concepts`, which uses the seed boundary\n # + `concept_retag_every`, default 30) records a\n # concept-graph coverage + uncovered-region snapshot (deterministic heuristic tagger over the\n # task-type skeleton) and, on an `explore` stance, the Researcher's novelty hint names the exact\n # uncovered regions (\"0 coverage in {negatives/external-mining, distillation} — go there\") instead\n # of the vague \"broaden\". ON by default in the product Settings (ce4a379), EngineOptions off: it\n # only enriches an already-mode-gated explore hint, never forces exploration, and no-ops for a task\n # with no curated concept skeleton. The snapshot is auditable, but its prompt cue can change future\n # candidate generation; it does not directly rewrite best-metric selection. See search/concept_graph.py.\n # DESIGN NOTE (2026-07-17 critique): now ON unconditionally, but a toy / non-ML task yields EMPTY concept\n # tags while still paying the per-node LLM concept cost (~10 calls/node with the full Part IV/V bundle).\n # Consider AUTO-QUIESCING: if the heuristic tagger returns empty for the first N nodes (no concept signal),\n # skip the LLM concept work for the run — keeps it on for ML tasks, free on toy/degenerate ones.\n concept_pivot: bool = True\n # PART IV Phase 2b — D3 graded novelty into the LIVE novelty gate (§21.4/§21.13). When on and the\n # task has a curated concept skeleton, the novelty gate first grades a proposal over the concept\n # graph: a level-4 \"same direction, DIFFERENT implementation\" or a level-5 \"re-opens a\n # wrongly-abandoned FAILED direction\" proposal is ALLOWED through unchanged — the flat LLM/semantic\n # dedup gate can't tell \"this DCL tweak\" from \"the whole DCL branch\" and would wrongly reject a\n # legitimate variant or never re-open a sound-but-killed direction (the node_63 archetype). Levels\n # 1/2/3 (identical / near-dup / prior-run) still fall through to the existing gate. Deterministic\n # (heuristic tagger, no LLM), audit event `novelty_graded`, replay-safe. ON by default in the product\n # Settings (ce4a379), EngineOptions off; no-ops for a task with no skeleton. See search/graded_novelty.py.\n graded_novelty: bool = True\n # PART IV Phase 2b — D7 capability-expansion forced-jump DIRECTIVE (§21.8/§21.13, issue #7). When on\n # and the concept-graph cadence detects action-space LOCK-IN (the search has stayed inside one D5\n # branch for a long consecutive streak) on an `explore` stance, the Researcher's novelty hint\n # ESCALATES from \"broaden\" to \"expand the ACTION SPACE — build the missing infra (external mining,\n # a new data pipeline, a different eval), do NOT swap another {locked} lever.\" The same\n # gate also stamps the proposal's authoritative operator as `expand`; that node competes normally\n # under SearchFitness and its separate operator yield is measured. It does not force a winner.\n # Reads the 2a concept-coverage snapshot, so it needs `concept_pivot` to record one. OPT-IN\n # (default off). See engine/proposal_cues.py + search/lock_in.py.\n capability_expansion: bool = False\n # PART IV cross-run Step 0 (§21.20.12/§21.20.13). Universal task-fingerprint tokenization: drop the\n # ASCII-only `[a-z0-9]` allowlist on goal keywords so a non-Latin goal (Russian, CJK, …) is NOT\n # silently dropped from its cross-run fingerprint (today such a goal keys only on kind/dir/metric and\n # never reaches SIMILAR-task priors/lessons/cases). Uses `[^\\W_]+`/`.casefold()` — same splitting as\n # before, any script. ON by default in the product Settings (ce4a379); the bare-library EngineOptions\n # default stays off, so a run pinned to it won't silently re-key a portfolio mid-flight. See engine/memory.py.\n fingerprint_universal: bool = True\n # PART IV cross-run Step 2 (§21.20). Fill graded-novelty's `prior_concepts` from the cross-run\n # ConceptCapsuleStore: at run end write a per-run concept capsule (the shipped `node_concepts` tags +\n # best outcome, keyed by task_fingerprint) to `memory_dir`; when a later SIMILAR run proposes an idea\n # whose concept was tried before, SURFACE the prior outcome as a `cross_run_prior` audit event (never\n # reject — D3 level 3). Audit-only, off the selection path; ON by default in the product Settings\n # (ce4a379), EngineOptions off. Needs a `memory_dir` to share capsules.\n # See engine/memory.py (ConceptCapsuleStore) + engine/novelty.py.\n # PREREQUISITES (this flag is NOT standalone): the WRITE needs per-run concept tags\n # (`node_concepts`, produced by `concept_pivot`/F1 tagging) — no tags => no capsule; the SURFACE path\n # runs inside the graded-novelty precheck, so it also needs `graded_novelty` on. With only this flag +\n # `memory_dir` it persists/surfaces nothing. Enable `concept_pivot` + `graded_novelty` alongside it.\n # A non-Latin (Russian/CJK) portfolio should ALSO set `fingerprint_universal`, else the capsule's goal\n # tokens are dropped and its fingerprint over-matches on kind/dir/metric alone (the novelty direction\n # gate narrows this, but goal-word similarity is still lost).\n cross_run_concepts: bool = True\n # PART V (B): run-base + node-DELTA concept authoring. When on, once the first evaluated node has\n # authored concepts the engine seeds RunState.run_base_concepts from them (a one-shot EV_RUN_CONCEPTS,\n # idempotent because it only fires while the base is empty), and the Researcher's proposal hint tells it\n # to author only `concepts_added`/`concepts_removed` vs the run base + its parent — minimal per-node\n # annotations that inherit down the DAG (a swap propagates). Off -> every node authors its full concept\n # set as before. Product-on; the bare EngineOptions mirror defaults off (test_options_divergence).\n concept_run_base: bool = True\n # PART IV cross-run Step 5 advisory (§21.20.5). Fold the bounded cross-run CONTEXT PACK — evidence-\n # grounded claims with BOTH support and counter-evidence (Step 4) + a portfolio-coverage line (Step 3) —\n # into the Researcher's proposal prompt, exactly like the E4 cross-run prior note. Advisory ONLY: it is\n # prompt-grounding: it can change the Researcher's later candidates, but does not directly rewrite\n # best-metric selection (§21.7). Reads `memory_dir` (lessons.jsonl + concept_capsules.jsonl); \"\" when\n # empty. ON by explicit experimental product choice; no frozen A/B validation is claimed. The\n # bare-library EngineOptions default stays off (engine/options.py, frozen in\n # test_options_divergence). See engine/proposal_cues.py.\n cross_run_advisory: bool = True\n # PART IV cross-run §21.20.13 (full CR of the lean fuzzy claim merge). Switch the claim read-model to the\n # SCOPE+POLARITY-safe STRUCTURED claim key (engine/claim_key.py): claims from different tasks never\n # merge, opposite polarity (\"X helps\" vs \"X never helps\") is surfaced as a CONTRADICTION instead of being\n # collapsed, paraphrase/inflection variants group by exact structured key (no transitive over-merge), and\n # operator governance is scope-precise (a decision in task A cannot reach a same-worded claim in task B).\n # Affects the `cross_run_advisory` context pack only; ON by default in the product Settings (ce4a379);\n # the bare-library EngineOptions default stays off (engine/options.py). See engine/claims.py.\n cross_run_structured_claims: bool = True\n # PART IV cross-run §22.4 (AGENTIC taxonomy steward). At finalize, when an LLM client is available, let\n # the concept steward (engine/concept_steward.py) review the freshly-updated portfolio concept graph and\n # PROPOSE a curation — merge duplicate slugs / split conflated ones / purge noise. Proposals are LOGGED to\n # `concept_curation_log.jsonl` for operator ratification (via the serve/CLI governance surface); the LLM\n # never mutates fold state. The calls run synchronously during finalize, so they add latency and may add\n # paid inference; a caught steward failure does not prevent terminal completion or trigger a retry.\n # ON by explicit experimental product choice when `memory_dir` + an LLM backend are present; the\n # bare-library EngineOptions default stays off (engine/options.py). See engine/lessons.py.\n cross_run_curation: bool = True\n # Deprecated compatibility flag. Steward output is untrusted and finalize is proposal-only regardless\n # of this value; retained so old snapshots still validate and logs can disclose that auto was requested.\n cross_run_curation_auto: bool = False\n # Role backend (ADR-7/14): \"toy\" (offline optimizer) | \"llm\" (live model).\n backend: str = \"toy\"\n # Developer backend (ADR-7): \"default\" (templated/LLM from the task) or an external\n # CLI coding agent: \"opencode\" | \"aider\" | \"goose\" | \"continue\".\n developer_backend: str = \"default\"\n # C2 best-of-N: generate N candidate implementations per node and keep the best by an\n # execution-free reward (static validity + metric-print). 1 = off. In-house LLM developer only\n # (not external agents — ADR-7 cost rule). The top SWE-bench reliability lever for weak models.\n best_of_n: int = 1\n # D10 list-wise best-of-N selection: when the top static-scorers TIE, break the tie with a\n # comparative LLM selection over the candidates presented together (+~3 pts vs pointwise on\n # GAIA, arXiv:2506.12928). The static score stays the primary filter — the LLM is a weak\n # comparative prior, never the sole oracle. ON by default (no-op when best_of_n==1).\n best_of_n_listwise: bool = True\n # === Foresight / predict-before-execute ===============================================\n # FOREAGENT predict-before-execute (arXiv:2601.05930): use the LLM as an IMPLICIT WORLD MODEL to\n # predict — WITHOUT executing — which candidate / hypothesis will score best, primed with a\n # Verified Data Analysis Report (data profile / brief) + the accumulated experiment memory. Master\n # switch, ON by default; a no-op wherever there is nothing to rank (best_of_n==1 and\n # foresight_panel==1) or no LLM client. See `looplab/search/foresight.py`.\n foresight: bool = True\n # Hypothesis foresight breadth: generate K candidate ideas per proposal and keep the one PREDICTED\n # best pre-execution by the world model — it ranks the STRUCTURAL / text ideas the numeric k-NN\n # surrogate (researcher_panel) is blind to, primed with the data profile + memory (the synergy).\n # >1 enables it (LLM backend only; needs `foresight` on); 2 = on by default at modest cost, 1 = off.\n foresight_panel: int = 2\n # AGENTIC foresight: run the ranking (hypothesis-board prioritization + K-idea pick) as a TOOL-USING\n # loop that can pull actual experiment results / data facts before deciding, instead of a one-shot\n # prediction from a pre-baked report. ON by default (needs foresight + a client; falls back to the\n # one-shot predictor on any hiccup). A few extra LLM calls per proposal; set False for the cheaper\n # single-call ranker.\n foresight_agentic: bool = True\n # §1 foresight confidence gate: the minimum predicted confidence at which a predict-before-execute\n # pick is ACTED ON. Below it the ranker abstains — the K-idea panel falls back to the first\n # proposal, best-of-N falls through to the D10 tie-break — instead of committing a low-confidence\n # choice. 0.0 (default) = OFF (act on every pick, the historical behavior); raise toward ~0.5 to\n # make the world model defer when it isn't sure. Pairs with the foresight track record (§1) the\n # predictor is now primed with. Range 0.0-1.0.\n foresight_min_confidence: float = 0.0\n # PART IV Phase 2c — replace the world model's SELF-REPORTED confidence (measured Pearson≈0 with the\n # realized outcome, §21.12) with a CALIBRATED §12-verifier score. When on and a client is available,\n # after the K-idea ranker picks the predicted-best candidate the §12 verifier scores it (grounded +\n # repeated + criteria-decomposed: `foresight_criteria` — likely to improve the objective, and\n # sound/feasible) and THAT calibrated score becomes the confidence the `foresight_min_confidence` gate\n # and the telemetry use. Degrades to the self-reported confidence without a client or on any verifier\n # error (the telemetry records which source was used). ON by default in the product Settings (ce4a379),\n # EngineOptions off; a few extra LLM calls per acted-on proposal. See looplab/trust/verifier.py + search/foresight.py.\n # REVISIT (cost/quality, not yet resolved): a live classification run measured ~10 LLM calls & ~3.8 min/node\n # on deepseek-v4-flash with the full Part IV/V default bundle; the calibrated verifier is part of that per-node\n # tax. Kept ON deliberately — it raises decision quality (self-reported foresight confidence has ≈0 Pearson\n # with realized outcome), and lowering quality to save calls is the wrong trade. TODO: run a frozen quality/cost\n # A/B (verifier-on vs -off) to quantify the recall@objective gain per extra call before considering a default flip.\n foresight_verify: bool = True\n # Verifier sample count for `foresight_verify` (the §12 repeated-sampling expectation on a no-logprob\n # backend). 3 tames the single-shot variance §21.12 measured; 1 = single-shot (cheaper, noisier).\n # RESUME-critical: this value is persisted in config.snapshot.json, so a hard `le` schema bound would\n # make an old run started with a larger LOOPLAB_FORESIGHT_VERIFY_SAMPLES un-resumable (Settings(**snapshot)\n # would raise on `looplab resume` / finalize-recovery / serve replay). CLAMP into [1, MAX] via a validator\n # instead of rejecting — the runtime cap is preserved without breaking the snapshots/env-compat rule.\n foresight_verify_samples: int = 3\n\n @field_validator(\"foresight_verify_samples\")\n @classmethod\n def _clamp_foresight_verify_samples(cls, v: int) -> int:\n # Coerce a persisted/env value into [1, MAX] rather than reject (see field note above).\n try:\n v = int(v)\n except (TypeError, ValueError):\n return 3\n return max(1, min(v, MAX_FORESIGHT_VERIFY_SAMPLES))\n\n # T7 LLM response cache: serve an identical DETERMINISTIC (temperature 0) request from an\n # in-process content-addressed cache instead of re-hitting the model — cuts cost on\n # retry/panel/verify flows. NEVER caches sampling calls (temp>0: best-of-N / panel / novelty\n # re-propose must vary). Off by default (most role calls run at temperature>0 anyway).\n llm_cache: bool = False\n agent_cmd: str | None = None # override the agent's launcher (path / wrapper)\n # External-agent validation (ADR-7): wrap a CLI-agent Developer with a validator that\n # audits each output (no-op/syntax/crash/timeout), retries with feedback, and falls\n # back to the in-house LLM Developer. Off -> use the raw agent output unchecked.\n validate_agent: bool = True\n agent_max_retries: int = 1 # re-prompts of the agent on an invalid result\n # Patch-gated multi-file agent (ADR-7 Rule 3): run the CLI agent in a git worktree and\n # accept only edits whose paths match `agent_surface` (reject-not-strip out-of-surface\n # touches). Lets the agent create helper modules, not just solution.py. Degrades to\n # whole-file readback if git is unavailable.\n agent_patch_gate: bool = True\n agent_surface: list[str] = [\"*.py\"] # edit-surface allow-list (globs)\n # Trust policy for an agent-authored eval/metric adapter (RepoTask onboarding, Phase 3):\n # \"ratify_freeze\" waits for human approval, \"autonomous\" accepts the proposed spec without that\n # gate, and \"ratify_freeze_drift\" also enforces the configured independent drift cross-check.\n eval_trust_mode: str = \"ratify_freeze\"\n # RepoTask node seeding policy (run-wide fallback; a task/editable `seed_mode` overrides):\n # \"auto\" (default) copies git-tracked files when the editable is a git repo (so a tree bloated\n # with untracked artifacts isn't deep-copied per node), else a full copy; \"tracked\" forces\n # code-only; \"all\" forces a full recursive copy (legacy). Genesis authors per-task from the\n # user's words; this is the global default when unspecified.\n seed_mode: str = \"auto\"\n llm_model: str = \"qwen3:8b\"\n # === LLM / transport ==================================================================\n llm_base_url: str = \"http://localhost:11434/v1\" # Ollama OpenAI-compatible endpoint\n llm_temperature: float = 0.6\n # H3 per-role model presets (5090 recipe): optionally run the Researcher and Developer on\n # DIFFERENT models/endpoints (e.g. Developer=Qwen3-Coder-30B for code, a fast model for breadth).\n # Blank = use the shared llm_model/llm_base_url. Generalizes the role-backend swap to per-role.\n researcher_model: str | None = None\n developer_model: str | None = None\n researcher_base_url: str | None = None\n developer_base_url: str | None = None\n # Per-role sampling temperature (§4.1): the Researcher wants breadth (higher temp = more diverse\n # ideas), the Developer wants determinism (lower temp = fewer syntax slips), the Strategist a\n # steady hand. Each overrides the shared `llm_temperature` for that role only; None = use the\n # shared value (byte-identical to before). Flat + nullable, mirroring the per-role model fields.\n researcher_temperature: float | None = None\n developer_temperature: float | None = None\n strategist_temperature: float | None = None\n # Unified self-driving agent: one LLM identity that plays Researcher + Developer (+ Strategist)\n # across pipeline stages, choosing its own model/toolset per stage. ON by default — the agent\n # drives the loop (incl. crash triage: repair/abandon/reject_idea) and, via\n # `agent_drives_actions`, picks the next macro action within a pure legal-action gate. Set both\n # to False for the legacy byte-identical split-role behavior. (No-op unless backend=\"llm\".)\n unified_agent: bool = True\n agent_drives_actions: bool = True\n # Layer 3 Card queue ownership. False preserves the existing policy/unified-pilot action path.\n # True is pinned in run_started because changing the selector on resume would mix two search\n # treatments in one run. When both this and agent_drives_actions are true, Card selection wins.\n card_driven_selection: bool = False\n # Layer 5 bounded speculative Card buffer. Zero is a hard OFF switch and preserves the\n # historical alternating build/eval spine; positive values are pinned by run_started so a\n # resume cannot silently mix search treatments after a snapshot/default edit.\n speculation_depth: int = Field(default=0, ge=0, le=64)\n # Local evidence receipt produced by ``looplab speculation-gate``. This is deliberately absent\n # from the curated Settings UI: positive-depth execution is a calibrated rollout decision, not a\n # casual tuning knob. The Engine validates the receipt against the current scorer,\n # implementation, GPU inventory and raw paired-run evidence before it enables speculation.\n speculation_gate_receipt: str | None = Field(default=None, max_length=4096)\n # Per-stage model/endpoint overrides for the unified agent. Recognized keys (see\n # tasks.build_unified_agent): `propose` (researcher), `implement`/`repair` (developer),\n # `strategy`, `pilot`. Unlisted keys are ignored. Empty = the per-role researcher_*/developer_*\n # models, then the shared llm_model. Explicit map wins. No-op unless `unified_agent` is true.\n agent_stage_models: dict = {}\n agent_stage_base_urls: dict = {}\n llm_parser: str = \"tool_call\"\n # Reasoning/thinking toggle sent IN the request (providers differ — see `llm.reasoning_body`):\n # Qwen3 on vLLM/SGLang -> chat_template_kwargs.enable_thinking (bool)\n # OpenAI/Ollama-v1/DeepSeek -> reasoning_effort (low|medium|high|none)\n # `llm_reasoning`: \"\" = send nothing (server default — current behavior); off = actively disable;\n # on = enable at default depth; low|medium|high = enable at that effort.\n # Defaults to \"high\" so the agent reasons before proposing/repairing/triaging (better code, fewer\n # crashes); set \"\" to send nothing (server default) or \"off\" to disable. The model's chain-of-thought\n # is CAPTURED either way (reasoning_content field or inline <think>). NOTE: to get a SEPARATE\n # reasoning_content field from SGLang/vLLM, the server also needs `--reasoning-parser qwen3`.\n llm_reasoning: str = \"high\"\n llm_reasoning_style: str = \"auto\" # auto | qwen | effort | none — how to shape the request param\n llm_reasoning_extra: dict = {} # raw fields merged into the body (escape hatch, e.g. Anthropic thinking)\n # H1: drive structured calls with the endpoint's constrained decoding (vLLM/SGLang guided_json +\n # response_format json_schema) so weak models can't emit invalid JSON. Off for Ollama (default).\n llm_guided_json: bool = False\n # Stream every LLM request (SSE) and reassemble it, so the request `timeout` is an INTER-TOKEN\n # idle timeout rather than a whole-request deadline: a long-but-alive generation is never cut off\n # (tokens keep resetting the timer), while a genuinely STALLED endpoint (no token for `timeout` s)\n # is detected and retried on a fresh connection — the fix for silent multi-minute hangs. Set False\n # to use one blocking read (old per-op timeout semantics) if an endpoint streams badly.\n llm_stream: bool = True\n # LLM IDLE timeout (seconds). Stream mode: the INTER-TOKEN stall limit — a steady generation is\n # never cut off, only a silent endpoint. Non-stream: bounds the whole read. Raise for endpoints\n # with a long prefill on huge prompts; lower to fail fast on a flaky shared endpoint.\n llm_timeout: float = Field(default=180.0, gt=0)\n # First-byte (response-headers) window for STREAM attempts, seconds: an SSE response sends its\n # headers on admission, so no-headers ≈ a black-holed request — fail over to a fresh connection\n # fast instead of waiting the full idle timeout. Clamped to llm_timeout. Non-stream attempts are\n # NOT bounded by this (their headers arrive only after the whole generation). The default mirrors\n # `DEFAULT_HEADER_TIMEOUT_S` in looplab/core/llm.py is the single source (imported above).\n llm_header_timeout: float = Field(default=DEFAULT_HEADER_TIMEOUT_S, gt=0)\n # Honour HTTP(S)_PROXY / NO_PROXY / SSL_CERT_FILE env vars for LLM calls. Default False: the\n # transport connects DIRECTLY (many internal/OLAP endpoints are reachable only by bypassing an\n # ambient proxy — a picked-up proxy yields \"connection refused\"). Set true when the endpoint is\n # reachable ONLY via a corporate proxy or needs a custom CA bundle from the environment.\n llm_trust_env: bool = False\n # H4/C2: compact the growing agentic tool-call history once it exceeds this many chars (auto_summary\n # LLM-summarizes the stale middle; else middle-truncate). Sized to the model's real context window so\n # files read earlier STAY in context instead of being compacted away and re-read: ~1,000,000 chars ≈\n # 250k tokens (≈4 chars/token), matching the configured deepseek-v4-flash (≥200k tokens confirmed).\n # The old 120k-char (~30k-token) default was ~8× too aggressive and drove a read-thrash loop. 0 = off.\n context_budget_chars: int = 1_000_000\n # M5: the Researcher's always-on experiments digest budget (chars). 0 = AUTO — scales with the\n # run size (60 chars/node, bounded [1200, 6000]) instead of one flat cap for an 8-node toy run\n # and a 200-node benchmark run alike. Set a positive value to pin it.\n digest_char_cap: int = Field(default=0, ge=0)\n # D11 compression model slot (open_deep_research's four-slot pattern): a dedicated CHEAP model\n # for agent-history summarization (C2 auto-summary), so compression doesn't pay the main\n # model's price. Blank = use each loop's own model (legacy). `compressor_base_url` blank =\n # the shared llm_base_url.\n compressor_model: str | None = None\n compressor_base_url: str | None = None\n # === Agentic tool-loop ================================================================\n # Agentic tool-loop limits — apply to EVERY tool-using agent (the LLM Researcher, the unified\n # agent's pilot + crash-triage, Deep-Research, the run-chat Boss, the genesis repo-scout, and the\n # cross-run report synthesizer). The loop lets the model call read-only tools across turns before\n # emitting its structured result. Both default to UNLIMITED so the agent takes as many turns / as\n # long as it needs — it is never cut off mid-reasoning; set a positive value here (or per-run, or\n # in the UI settings) to bound latency/cost. These used to be hardcoded per call site (e.g. the\n # boss at max_turns=3 / 45s), which silently dropped a slow reasoning model to a no-op reply.\n agent_max_turns: int = 0 # max tool turns before the emit is forced (0 = unlimited)\n agent_time_budget_s: float = 0.0 # wall-clock ceiling across the loop's turns (0 = no cap)\n # G · Soft convergence SAFETY NET (not a research cap). Two thresholds on the agent's tool-turn count:\n # at `agent_emit_after` the loop NUDGES the agent once (\"you've investigated enough — emit now\"); at\n # `agent_emit_force` it FORCES the emit from what was gathered. Set GENEROUSLY — a focused researcher\n # may legitimately read many files/experiments before it can propose a grounded idea; these only catch\n # a genuine runaway that keeps issuing DIFFERENT calls forever (which stuck-detection, keyed on\n # repeats, misses). NB: the old 499-read runaway (node 63) was 79% REDUNDANT re-reads caused by a\n # broken read_file — fixing read_file (pagination) is the real cure; this is just insurance. 0 = off.\n agent_emit_after: int = 300\n agent_emit_force: int = 500\n # B1 · No-progress / stuck detection — the safety net that makes \"unlimited turns\" safe. The\n # turn/time ceilings above are only BACKSTOPS; this is what actually stops a runaway loop, on the\n # cheapest signal: when the model repeats the SAME tool call (or ping-pongs between two, or keeps\n # hitting the SAME error) with no progress, the loop forces the final emit and finishes. ON by\n # default; reading DIFFERENT files or running ONE long command never trips it. (OpenHands-style.)\n agent_stuck_detection: bool = True\n agent_stuck_repeat: int = 4 # identical calls in a row that count as \"stuck\" (>=2)\n agent_stuck_alternate: int = 4 # ping-pong cycles between two calls that count as \"stuck\" (>=2)\n # C1 · Self-plan (TodoWrite-style): expose an `update_plan` tool so a long-running agent keeps its\n # OWN working TODO and re-surfaces it every `agent_plan_reinject_every` turns — keeps the goal in\n # view across a long loop so the agent \"writes its own context\" (Claude Code style). ON by default.\n agent_self_plan: bool = True\n agent_plan_reinject_every: int = 5\n # C2 · Auto-summary (ON by default): when the tool-loop history grows long, LLM-summarize the\n # stale middle instead of only middle-truncating it. The trigger is `context_budget_chars` if set,\n # else a built-in high-water mark (context_budget.DEFAULT_SUMMARY_CHARS ≈ 120k chars) so short\n # loops are untouched. Falls back to truncation on any summarizer error; one extra model call per\n # over-budget turn.\n agent_auto_summary: bool = True\n # C4 · Repo-developer plan decomposition. A big feature attempted in ONE developer session made a\n # non-converging reasoning model loop indefinitely — it kept writing + exploring without ever\n # emitting `done` (10k+ LLM calls / hours; agentic stuck-detection can't catch VARIED exploration,\n # only literal repeats). Fix: the developer first proposes an ordered plan of ATOMIC steps; a\n # multi-step plan is executed step-by-step, each in a FRESH bounded session with only that step's\n # scope + the files accumulated so far, syntax-validated per write. Every session stays\n # small-context and convergent. ON by default for repo tasks (a 1-step plan == the old single pass).\n developer_plan_decompose: bool = True\n developer_plan_min_steps: int = 2 # a proposed plan with >= this many steps runs step-by-step\n developer_plan_max_steps: int = 8 # cap on plan length (a runaway planner can't spawn 100 steps)\n # Hard per-session backstop for the repo developer — the ONE write-heavy agent that can run away\n # even with stuck-detection on (varied writes/reads never trip the repeat/ping-pong signal). Bounds\n # the plan phase, EACH step, and the single-session fallback. Unlike the global agent_* limits\n # (0 = unlimited, meant for read-only agents), the developer always gets a FINITE ceiling so a\n # model that never emits `done` fails cleanly with the code it wrote so far, instead of looping.\n developer_session_max_turns: int = 500\n developer_session_time_budget_s: float = 1200.0 # 20 min wall-clock per developer session\n # Phase-handoff summaries. Each LLM phase in a node build (Researcher·propose → Developer·stages →\n # plan → implement) ends with ONE extra LLM call that distills its transcript — the repo structure\n # it mapped, files/data confirmed, decisions made — into a brief injected into the NEXT phase (even\n # across the role boundary). The next phase TRUSTS that instead of re-reading the same files, which\n # cuts the biggest source of tool-call thrash (stages/plan/implement each re-exploring the repo).\n # One summary call per phase in exchange for many fewer read calls downstream. ON by default.\n phase_handoff_summary: bool = True\n # Observability (ADR-17): capture each LLM call's full prompt + completion into the active\n # span (spans.jsonl) so the UI can show exactly what the model read and wrote per node.\n # Diagnostics only — `replay.fold` never reads spans. Default on for local single-user; set\n # LOOPLAB_TRACE_LLM_IO=0 to suppress (e.g. to keep spans.jsonl small or avoid storing prompts).\n trace_llm_io: bool = True\n # Run-introspection tools (ON by default): make the LLM Researcher (and DeepResearcher) a\n # tool-using agent that can read its OWN experiments (list/read/find-analogous/themes/code) and\n # the task data (schema/profile/asset) mid-loop, instead of seeing only best+parent. Advisory —\n # never changes best-selection. Off = the legacy single-shot Researcher (richer digest still added).\n researcher_tools: bool = True\n # Cross-run introspection: give the agentic Researcher / pilot read-only tools to look at\n # SIBLING runs (same task_id, same run-root) — list them, read an experiment / its code, and\n # find analogous configs across runs — so a run can build on what neighbouring runs already\n # learned instead of rediscovering it. Advisory; never changes best-selection. Needs the run's\n # own dir wired through (no-op for unit-built roles). Off = the legacy single-run view only.\n cross_run_tools: bool = True\n # Read-only access to EVERY run on this machine ACROSS ALL TASKS (not just same-task siblings):\n # the Developer/Researcher get list_all_runs + read_run_code + read_run_experiment so they can read\n # the code + result of ANY past experiment anywhere and reuse an approach. Broader than\n # `cross_run_tools` (same-task only); the agent decides when a foreign run is relevant. Advisory;\n # never changes best-selection. Needs the run's own dir wired through (no-op for unit-built roles).\n all_runs_tools: bool = True\n # PART V §22 — read-only CROSS-RUN KNOWLEDGE tool for the reasoning roles (Researcher, Strategist,\n # deep-research, and a Developer-scoped variant). Adds `cross_run_prior_attempts` / `cross_run_claims`\n # / `cross_run_atlas` over the §21.20 read-models (concept overview + claim assessments + atlas), read\n # from `memory_dir` (lessons.jsonl + concept_capsules.jsonl). Read-only is a storage-authority\n # boundary, not a behavioral no-op: retrieved content can steer later proposals, and a tool-using\n # reasoning loop can consume additional model calls. The tools never mutate cross-run truth (facts\n # are engine-written, verdicts operator-ratified, §22.4) or directly select a winner. ON by explicit\n # experimental product choice, EngineOptions off; stores only exist once Part-IV features have run.\n cross_run_read_tools: bool = True\n # Agentic retrieval (ADR-16): if set, the LLM Researcher gets grep/kb_search/read\n # tools over this directory of markdown notes and chooses when to use them.\n knowledge_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"knowledge\"))\n # T4 real embeddings: model id for an OpenAI-compatible `/embeddings` endpoint used for\n # kb_search / case retrieval. Blank (default) = the dependency-free lexical `hash_embed` (today's\n # behavior). Set e.g. \"nomic-embed-text\" (Ollama) or \"text-embedding-3-small\" for SEMANTIC\n # retrieval; a misconfigured/offline endpoint degrades back to hash_embed at call time (never\n # crashes a run). `embed_base_url` blank = reuse `llm_base_url`; the shared `llm_api_key` is used.\n embed_model: str | None = None\n embed_base_url: str | None = None\n # Harmonic memory (Memora, ICML'26 — idea import). When ON, cross-run cases and knowledge notes are\n # indexed by a short ABSTRACTION + cue ANCHORS instead of their raw text, near-duplicate memories are\n # CONSOLIDATED into one entry under a matching abstraction, and `kb_search`/case retrieval EXPAND\n # through the top hits' anchors to surface related-but-not-similar memories. ON by default. Set\n # `memora=false` to restore the pre-Memora raw-text index. Never a source of truth — abstractions\n # live only in the derived, rebuildable retrieval index (never the event log or canonical cases.jsonl).\n memora: bool = True\n # Write abstractions with the wired chat model (richer than the deterministic lexical fallback). ON\n # by default; results are CACHED by content hash (see `memora_cache`) so a re-built index doesn't\n # re-call the model on unchanged notes/cases, and any endpoint failure degrades to lexical at call\n # time — so an offline box just gets lexical abstractions, never a crash. Set false to force lexical.\n memora_llm: bool = True\n # === Cross-run memory / Memora ========================================================\n memora_anchors: int = 6 # max cue anchors kept per memory\n # Cosine similarity at/above which a new case is CONSOLIDATED into an existing one (same evolving\n # topic) rather than stored as a separate entry. Only used when `memora` is on.\n memora_consolidate_threshold: float = 0.86\n # Where the LLM-abstraction cache lives (JSON). Blank (default) derives it from `memory_dir`\n # (`<memory_dir>/memora_cache.json`) or else `knowledge_dir` (`<knowledge_dir>/.memora_cache.json`);\n # with neither set the cache is in-memory only (per process). No-op unless `memora_llm` is on.\n memora_cache: str | None = None\n # E3 literature-grounded ideation (network-OPTIONAL): give the agentic Researcher an arXiv search\n # tool to ground ideas in real techniques. Off by default (egress is unreliable on some boxes);\n # fails gracefully if the network is blocked.\n literature_search: bool = False\n # Deep-Research stage (network-OPTIONAL): give the DeepResearcher a general web search/fetch\n # tool (DuckDuckGo) on top of arXiv to read across results + the web before steering the next\n # batch. Off by default (egress is unreliable on some boxes); fails gracefully when blocked.\n web_search: bool = False\n # Cadence for the Deep-Research stage: run it automatically every N created nodes. Zero disables\n # only the cadence; manual `deep_research` and Strategist `request_research` triggers still fire.\n # Default 3: deep research analyzes the accumulating results and steers the next batch (its\n # directions become hints + open hypotheses); paired with `concurrent_research` it overlaps the\n # think with the GPU-bound eval, so the cadence costs LLM tokens but no wall-clock on the search.\n deep_research_every: int = 3\n # Overlap a DUE deep-research \"think\" with the GPU-bound eval instead of running it in its own\n # serial step (the agent is otherwise idle while a node trains). research() computes from a state\n # snapshot; the joined background task records only allowlisted, order-tolerant\n # research/hint/hypothesis events as soon as the memo finishes. ON by default: the LLM is typically\n # remote (no GPU contention with eval), so overlapping hides latency behind long training.\n concurrent_research: bool = True\n # Concurrent-research REPEAT — don't idle a multi-DAY eval. The overlapped \"think\" fires ONCE per\n # window by default; ON here, it RE-RUNS on an adaptive time cadence for the WHOLE eval window\n # (a two-day training must not leave the reasoning agents idle after one memo). Self-paced: it\n # only records a memo whose CONTENT is new (identical re-runs are skipped, so the log/board don't\n # bloat) and backs off geometrically as the analysis converges, capped so it always re-checks.\n # The appended records never rewrite the current metric champion, but their hints/open hypotheses\n # deliberately steer later proposals and replay reconstructs that advice. The library default is\n # one-shot (== today, byte-identical); this product default turns the repetition on.\n concurrent_research_repeat: bool = True\n # Base cadence (seconds) between repeated concurrent-research passes. Research is EXPENSIVE\n # (multi-turn LLM + web/arXiv), so this is a FLOOR, not a ceiling: the effective cadence is\n # max(this, ~5% of the per-experiment time budget) — a two-day eval is re-researched about hourly,\n # a short eval not at all (the first tick outlasts it). Only matters when the repeat is on.\n concurrent_research_interval_s: float = Field(default=1800.0, gt=0)\n # Per-eval-window backstop on repeated-research LLM calls (0 = cadence-only, no cap). The adaptive\n # cadence + convergence backoff are the primary budget control; this bounds a pathological\n # never-converging window. Past it the loop stops calling the LLM (the health monitor still runs).\n concurrent_research_max_calls: int = Field(default=40, ge=0)\n # Concurrent hypothesis-board CONSOLIDATION during the eval window. Repeated research keeps ADDING\n # near-duplicate directions to the open board (each memo registers up to 5 as open hypotheses), so a\n # long eval's board bloats before the between-nodes merge runs. This dedups/merges it on the SAME\n # background loop so the board stays clean for the next proposal. In Card-driven mode the same merge\n # changes ownership/readiness, so it is deferred to the joined main-task cadence instead. Needs the\n # reflect client + `track_hypotheses`.\n # Only active while `concurrent_research` runs the overlap loop. Library default off (== today).\n concurrent_consolidate: bool = True\n # Cadence for the agent-authored run report: regenerate the conclusion-first narrative every N\n # created nodes (0 = off; it still regenerates on a manual `report_refresh` from the UI). The\n # deterministic report always renders from the node set regardless of this knob.\n report_every: int = 3\n # Agent Skills (I18, ADR-9): dir of SKILL.md the Researcher can list/load as tools.\n skills_dir: str | None = None\n # Prompt store (I18, ADR-8): dir of editable, hot-reloaded role prompt .md files.\n prompt_dir: str | None = None\n # API key as a reference, never serialized as a value (ADR-11). Local servers\n # ignore it; default to a placeholder.\n llm_api_key: SecretStr | None = None\n\n @model_validator(mode=\"before\")\n @classmethod\n def _apply_profile(cls, data):\n \"\"\"Expand `profile` into a bundle of setting defaults BEFORE validation.\n\n pydantic-settings hands this validator the fully-merged input from ALL explicit sources\n (init kwargs + LOOPLAB_* env + .env), with fields the user left unset simply ABSENT (they\n fall to field defaults afterward). So a profile value is applied only when its key is not\n already present — i.e. any explicit file/CLI/env setting wins over the profile, and the\n profile wins over the bare field default. This makes `thorough` a true convenience preset,\n never an override of what the user asked for.\"\"\"\n if not isinstance(data, dict):\n return data\n prof = data.get(\"profile\", \"default\")\n # env may deliver the key as \"profile\" (env_prefix stripped) — normalize to a str\n prof = str(prof).strip().lower() if prof is not None else \"default\"\n overrides = PROFILES.get(prof)\n if overrides is None:\n raise ValueError(f\"unknown profile {prof!r}; known: {sorted(PROFILES)}\")\n for key, val in overrides.items():\n if key not in data: # explicit source (file/CLI/env) always wins\n data[key] = val\n return data\n\n @model_validator(mode=\"after\")\n def _check_trust_gate(self):\n if self.trust_gate not in (\"audit\", \"gate\", \"block\"):\n raise ValueError(\n f\"trust_gate must be audit|gate|block, got {self.trust_gate!r}\")\n if self.merge_mode not in (\"auto\", \"mean\", \"ensemble\"):\n raise ValueError(\n f\"merge_mode must be auto|mean|ensemble, got {self.merge_mode!r}\")\n # novelty_mode drives the dedup/novelty gate; an out-of-set value (e.g. a mis-cased\n # LOOPLAB_NOVELTY_MODE=LLM, or \"on\") used to fall through _apply_novelty_gate silently as a\n # NO-OP — turning the gate off with no diagnostic. Fail loudly at config time like trust_gate/\n # merge_mode do. (Other enum-ish string fields — seed_mode/eval_trust_mode/strategist_backend —\n # share the pattern; left unvalidated here only because their full value sets are less settled.)\n if self.novelty_mode not in (\"off\", \"algo\", \"llm\"):\n raise ValueError(\n f\"novelty_mode must be off|algo|llm, got {self.novelty_mode!r}\")\n # The remaining enum-ish string fields (arch-review §5 P3): a typo used to be accepted at\n # construction and only fail-safe/later-loud downstream (e.g. a mis-cased strategist_backend\n # silently ran the default). Fail loudly here like the fields above.\n if self.strategist_backend not in (\"off\", \"rule\", \"llm\", \"agent\"):\n raise ValueError(\n f\"strategist_backend must be off|rule|llm|agent, got {self.strategist_backend!r}\")\n if self.eval_trust_mode not in (\"ratify_freeze\", \"autonomous\", \"ratify_freeze_drift\"):\n raise ValueError(\n \"eval_trust_mode must be ratify_freeze|autonomous|ratify_freeze_drift, \"\n f\"got {self.eval_trust_mode!r}\")\n if self.seed_mode not in (\"auto\", \"tracked\", \"all\"):\n raise ValueError(f\"seed_mode must be auto|tracked|all, got {self.seed_mode!r}\")\n return self\n\n def masked_snapshot(self) -> dict:\n # Snapshots are a JSON contract (they are written verbatim to config.snapshot.json), not a\n # Python-object dump. In particular tuple-valued schema fields such as\n # ``inline_repair_reasons`` must become JSON arrays before callers hash the runtime envelope;\n # otherwise the strict speculation-scope canonicalizer sees a different/non-JSON preimage\n # from the one that ``json.dumps`` eventually persists.\n d = self.model_dump(mode=\"json\")\n if self.llm_api_key is not None:\n d[\"llm_api_key\"] = \"***\"\n else:\n d[\"llm_api_key\"] = None\n return d\n\n\n# Pre-versioned snapshots are full Settings dumps, so absence is a reliable per-field version marker.\n# Keep their historical effective behavior when newer product defaults become active: re-entry must not\n# silently add paid calls, interventions, concurrency or a different selection policy to an old run.\nLEGACY_CONFIG_SNAPSHOT_DEFAULTS: dict[str, object] = {\n \"parallel_build\": 1,\n \"eval_parallel\": None,\n \"llm_parallel\": None,\n \"train_monitor\": False,\n \"train_monitor_interval_s\": 600.0,\n \"train_monitor_kill\": False,\n \"train_monitor_kill_confidence\": 0.8,\n \"asha_live\": False,\n \"asha_live_kill\": False,\n \"asha_live_quantile\": 0.5,\n \"asha_live_min_siblings\": 3,\n # Before this operator ceiling existed, the sandbox's 24-hour process limit was authoritative.\n \"max_eval_timeout\": 24 * 3600.0,\n \"watchdog_reflection\": False,\n \"card_driven_selection\": False,\n \"speculation_depth\": 0,\n \"speculation_gate_receipt\": None,\n \"concurrent_research_repeat\": False,\n \"concurrent_research_interval_s\": 1800.0,\n # Zero means unlimited; 40 was the first bounded default, and is latent while repeat is disabled.\n \"concurrent_research_max_calls\": 40,\n \"concurrent_consolidate\": False,\n}\n\n\ndef migrate_config_snapshot(data: dict) -> dict:\n \"\"\"Return an effective copy of one legacy/current snapshot without rewriting evidence bytes.\"\"\"\n if not isinstance(data, dict):\n raise TypeError(\"config snapshot must be a JSON object\")\n migrated = dict(data)\n for key, value in LEGACY_CONFIG_SNAPSHOT_DEFAULTS.items():\n migrated.setdefault(key, value)\n return migrated\n\n\ndef settings_from_snapshot(data: dict) -> Settings:\n \"\"\"Build re-entry Settings from a masked snapshot under its historical missing-field contract.\"\"\"\n migrated = migrate_config_snapshot(data)\n migrated.pop(\"llm_api_key\", None)\n return Settings(**migrated)\n"}, "8f9119a5feb74c4d7933d4242e87fd0d4f760aee": {"t": 1785768769.7232509, "n": 98057, "h": "dddefa040f8d4877", "c": "\"\"\"Engine config: the schema (what settings exist). (I0, ADR-11)\n\nOne pydantic-settings class declaring every engine knob, its default, and its docs. The loader —\nhow a run file / CLI / env becomes a `Settings` — is `looplab.core.appconfig`, which also owns\nthe config-precedence order. A resolved, secret-masked snapshot is written next to each run for\nreproducibility. (No real secrets in P0, but the masking discipline is in place.)\n\"\"\"\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nfrom pydantic import Field, SecretStr, field_validator, model_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n# Single source of the LLM first-byte (response-headers) default — see core/llm.py.\nfrom looplab.core.llm import DEFAULT_HEADER_TIMEOUT_S\n\n# Default home for cross-run memory + the knowledge base (shared across every run). Both are ON by\n# default now (a user asked for it) so a fresh install accumulates + consults knowledge with no setup;\n# set the env var to \"\" to disable, or to a path to relocate. `~/.looplab/` keeps them out of any one\n# project + shared across projects.\n_LL_HOME = Path.home() / \".looplab\"\n\n# Autonomous roles that may be granted per-setting write access (see Settings.agent_control).\n# strategist = A7 meta-controller (run-wide); boss = run-chat operator-proxy (run-wide);\n# researcher = per-experiment proposer (per-node sizing, e.g. eval timeout for a heavy model).\nAGENT_ROLES = (\"strategist\", \"boss\", \"researcher\")\n\n# A foresight score is computed for every acted-on proposal. Keep this lower than the generic\n# verifier's emergency ceiling so a typo cannot multiply paid synchronous calls across a run.\nMAX_FORESIGHT_VERIFY_SAMPLES = 8\n\n\n# Layer-2 parallelism renamed two public settings without removing the durable legacy fields. Keep\n# the equivalence in one place: config-source precedence, governance migration, and live strategy\n# application must all agree which names describe the same authority/value.\nPARALLELISM_ALIASES: dict[str, str] = {\n \"max_parallel\": \"eval_parallel\",\n \"parallel_build\": \"llm_parallel\",\n}\n\n\ndef parallelism_aliases(setting: str) -> tuple[str, ...]:\n \"\"\"Return every spelling in ``setting``'s alias family, canonical name first.\"\"\"\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if setting in (legacy, canonical):\n return canonical, legacy\n return (setting,)\n\n\ndef canonicalize_parallelism_source(\n values: dict, *, promote_build_to_llm_parallel: bool = False,\n) -> dict:\n \"\"\"Promote legacy parallel keys *within one config-precedence source*.\n\n Applying this only after file/CLI/env were flattened would let a lower-priority canonical env\n value shadow a higher-priority legacy file value (or a file canonical value shadow a CLI legacy\n override), so callers with sub-layers normalize each layer independently.\n\n ``max_parallel``/``eval_parallel`` are true aliases (pure eval width, no extra semantics) and are\n always promoted. ``parallel_build``/``llm_parallel`` are NOT: a positive ``llm_parallel`` also flips\n on the finite shared LLM broker (``orchestrator._startup_llm_total``), while legacy ``parallel_build``\n means \"legacy build width, unbounded overlap, no shared total\". So promoting a legacy-only\n ``parallel_build`` layer (e.g. just ``LOOPLAB_PARALLEL_BUILD=1`` in env) would silently serialize\n every provider call — contradicting llm_broker.py's docstring, orchestrator's \"legacy-only preserves\n unbounded overlap\" note, the broker-neutral live-control path, and the configuration.md ``llm_parallel``\n row (\"falls back ... without enabling a shared total\"). Config/startup loads therefore keep broker\n opt-in tied to an EXPLICITLY-spelled canonical ``llm_parallel``; the orchestrator's own width fallback\n (``llm_parallel if not None else parallel_build``) still carries the legacy build width. Only the\n Strategist live-control path (``canonicalize_strategy_parallelism``) opts into\n ``promote_build_to_llm_parallel``, where ``parallel_build`` is a deliberate full alias of the\n canonical knob (see strategy.py ``_apply_strategy``).\n \"\"\"\n if not isinstance(values, dict):\n return values\n out = dict(values)\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if canonical == \"llm_parallel\" and not promote_build_to_llm_parallel:\n continue\n # None is the durable legacy mode for llm_parallel, not a missing value. A normal snapshot\n # contains llm_parallel:null plus parallel_build:1; promoting null to 1 would unexpectedly enable\n # the global one-call broker. Promote aliases only when the canonical key is absent, and pin\n # snapshot round-trip parity. Canonicalize inside each precedence layer, never across the\n # flattened result, so the alias migration cannot invert CLI > file > env precedence.\n if canonical not in out and out.get(legacy) is not None:\n out[canonical] = out[legacy]\n return out\n\n# Settings whose semantics are committed by ``run_started`` and restored from the folded event log\n# on every re-entry. Changing one only in ``config.snapshot.json`` would mix incompatible evidence\n# (a different holdout split) or make the live producer disagree with replay's selection policy.\n# The HTTP config editor and the engine both consume this one contract; keep deliberately mutable\n# controls such as ``trust_gate`` out (that field has its own durable change event).\nRUN_START_PINNED_FIELDS = frozenset({\n \"card_driven_selection\",\n \"speculation_depth\",\n \"holdout_fraction\",\n \"holdout_select\",\n \"select_verifier\",\n \"select_verifier_samples\",\n \"verifier_ci_tie\",\n})\n\n\ndef run_start_pinned_settings(state) -> dict:\n \"\"\"Return the effective Settings names committed by a folded run-start record.\n\n Pre-pin legacy logs have no recorded holdout fraction, so their snapshot remains authoritative\n for both holdout knobs. Verifier fields, however, have always been re-pinned from the legacy-safe\n fold defaults whenever a valid ``run_started`` identity exists.\n \"\"\"\n if not getattr(state, \"run_id\", \"\"):\n return {}\n values = {\n # Layer 3 changes which queue owns macro-action selection. Once a run starts, replay and\n # resume must keep the same owner even if the snapshot or ambient default changes later.\n \"card_driven_selection\": bool(getattr(state, \"card_driven_selection\", False)),\n # Layer 5 overlaps Card production with evaluation only when this pinned treatment is\n # positive. Old logs omit it and therefore retain the exact serial build/eval spine.\n \"speculation_depth\": int(getattr(state, \"speculation_depth\", 0)),\n \"select_verifier\": bool(getattr(state, \"select_verifier_tiebreak\", False)),\n \"select_verifier_samples\": int(getattr(state, \"select_verifier_samples\", 3)),\n \"verifier_ci_tie\": bool(getattr(state, \"verifier_ci_tie\", False)),\n }\n holdout_fraction = getattr(state, \"holdout_fraction\", None)\n if holdout_fraction is not None:\n values.update({\n \"holdout_fraction\": holdout_fraction,\n \"holdout_select\": bool(getattr(state, \"holdout_select\", False)),\n })\n if not values.keys() <= RUN_START_PINNED_FIELDS:\n raise RuntimeError(\"folded run-start pinned settings contract drifted\")\n return values\n\n# --- Run profiles (config-first presets) -------------------------------------------------------\n# A `profile` is a NAMED BUNDLE of setting defaults — nothing more. It only fills fields the user\n# did NOT set explicitly (via file / --set / LOOPLAB_* env / init-kwargs), so any explicit knob\n# always wins (see `_apply_profile`). Most engine intelligence lives behind individual flags. The\n# PART IV/V cross-run + concept-graph machinery (concept_pivot, graded_novelty, cross_run_concepts /\n# _advisory / _structured_claims / _curation / _read_tools, fingerprint_universal, foresight_verify)\n# now ships ON in the product `Settings` default. That is an explicit experimental product choice,\n# not a claim that a frozen A/B or workload-wide cost/quality gate has cleared: the bundle can steer\n# prompts and candidate generation, change graded-novelty admission / pre-execution choices, and add\n# paid LLM work when its client and memory prerequisites are present. The bare-library `EngineOptions`\n# default stays lean (see tests/test_options_divergence.py — product side is the aggressive one) so a\n# toy `Engine(...)` in a test doesn't fire cross-run LLM work unasked. The trust/confirm/ablate QUALITY\n# bundle still ships OFF and is turned on by `thorough`. Every value here is reachable by hand — the\n# profile is a convenience, never a hidden mode.\n# CODEX AGENT: keep this rationale behavioral and evidence-based; do not relabel read-only storage\n# access or proposal-only governance as a no-op for agent behavior, cost, or proposal admission.\n#\n# `thorough` deliberately touches only QUALITY/TRUST machinery, not spend: it does NOT raise\n# max_nodes / eval_parallel (those are cost knobs the user owns). It enables multi-seed confirmation\n# (demote seed-lucky leaders), the reward-hack / leakage / critic monitors AND flips them from\n# advisory to *gating* (`trust_gate=\"gate\"` — a\n# flagged win can no longer be selected as best), ablation-driven refinement, and the prompt cues\n# (complexity / budget / failure-reflection / watchdog-reflection). Agentic novelty and the experimental\n# Part IV/V bundle are already product defaults, not profile changes. Effect is large; marginal cost is a\n# few extra evals.\nPROFILES: dict[str, dict] = {\n \"default\": {}, # explicit no-op alias for \"ship-as-is\" (== omitting profile)\n \"fast\": {}, # today's lean defaults, named for symmetry with `thorough`\n \"thorough\": {\n \"confirm_top_k\": 3,\n \"confirm_seeds\": 3,\n # novelty_gate intentionally NOT enabled here: duplicate-detection is the agentic Researcher's\n # job (it reads past experiments + find_analogous_across_runs and DECIDES), not an algorithmic\n # param-distance / embedding auto-reject that mis-fired (it nudged a good fresh idea onto a\n # twice-dead lineage — live node 61). Search stays as tools; the LLM judges novelty.\n \"operator_bandit\": True, # P4: adaptive operator mix over folded yields (GreedyTree)\n \"reward_hack_detect\": True,\n \"code_leakage_detect\": True,\n \"critic_check\": True,\n \"trust_gate\": \"gate\", # detectors stop gating a flagged node from WINNING (not just audit)\n \"ablate_every\": 3,\n \"complexity_cue\": True,\n \"budget_aware\": True,\n \"failure_reflection\": True,\n \"watchdog_reflection\": True, # feed recent live-watchdog (train-monitor/ASHA) flags to proposals\n \"reflection_priors\": True, # no-op unless memory_dir is set (cross-run priors)\n },\n}\n\n\n# The shipped agent-governance matrix (who may change which setting at runtime). Defined as a module\n# constant so the Engine's direct-construction default matches this shipped default exactly (the\n# EngineOptions \"Engine() == shipped defaults\" invariant) instead of diverging to an all-locked map.\nDEFAULT_AGENT_CONTROL: dict[str, list[str]] = {\n \"timeout\": [\"researcher\", \"strategist\"],\n # Layer-2 canonical names are the presented/default governance surface. `_agent_may` falls back\n # to an old snapshot's legacy grant only when its canonical counterpart is absent, so removing\n # duplicate defaults preserves resume authority while an explicit canonical revocation still wins.\n \"eval_parallel\": [\"strategist\"],\n \"llm_parallel\": [\"strategist\"],\n # Strategy-only pseudo-setting: the closed per-lane allocation inside the canonical LLM total.\n # It is not a launch Settings field; operators pin it through `set_strategy` and the Strategist\n # may reallocate it only while this grant is present.\n \"llm_lane_limits\": [\"strategist\"],\n \"max_nodes\": [\"strategist\", \"boss\"],\n \"max_eval_seconds\": [\"strategist\", \"boss\"],\n \"policy\": [\"strategist\"],\n # The policy NAME and its PARAMS are gated independently in `_apply_strategy`; grant both by default\n # so the Strategist that may switch to (say) MCTS may also apply the `c`/`eta` it decided — else its\n # tuned params are silently dropped and the recorded `active_strategy` diverges from the live engine.\n \"policy_params\": [\"strategist\"],\n \"n_seeds\": [\"strategist\"],\n \"ablate_every\": [\"strategist\"],\n \"merge_mode\": [\"strategist\"],\n \"complexity_cue\": [\"strategist\"],\n \"ablate_code_blocks\": [\"strategist\"],\n \"prefer_sweep\": [\"strategist\"],\n \"novelty_stance\": [\"strategist\"],\n \"developer\": [\"strategist\"],\n \"fidelity\": [\"strategist\", \"researcher\"],\n}\n\n\ndef default_agent_control() -> dict[str, list[str]]:\n \"\"\"A FRESH deep-ish copy of the shipped governance matrix (each role list copied so a caller can\n mutate its own map without touching the module constant). The SINGLE constructor of the default,\n called by both `Settings.agent_control`'s default_factory AND the Engine's direct-construction\n default — so \"Engine() == shipped defaults\" can't drift if the copy depth ever needs to change\n (e.g. nested values requiring copy.deepcopy), instead of two verbatim copy expressions.\"\"\"\n return {k: list(v) for k, v in DEFAULT_AGENT_CONTROL.items()}\n\n\nclass Settings(BaseSettings):\n \"\"\"The engine settings schema (every knob a run accepts).\n\n Timeout family — six distinct knobs, each owned by a different subsystem:\n - `timeout`: per-eval wall-clock budget for ONE experiment's evaluation (engine/eval).\n - `max_eval_timeout`: hard ceiling for a governed per-node Researcher timeout override.\n - `llm_timeout`: LLM request idle timeout — inter-token stall limit in stream mode\n (core.llm OpenAICompatibleClient).\n - `llm_header_timeout`: LLM first-byte (response-headers) window for stream attempts (core.llm).\n - `agent_time_budget_s`: wall-clock ceiling across a tool-using agent loop's turns (agents).\n - `dep_install_timeout`: per-package budget for auto-installing a missing dep (env self-prep).\n\n Config-source precedence is owned by `looplab.core.appconfig` (the loader) — see its module\n docstring for the one canonical order.\n \"\"\"\n # Config precedence: see appconfig.py — the loader owns the full order. The `.env` is read\n # so `looplab run`/`looplab ui` pick up LOOPLAB_LLM_BASE_URL etc. without exporting them by hand\n # (keys MUST carry the LOOPLAB_ prefix, same as the env vars). utf-8-sig tolerates a BOM from\n # Windows editors. The test suite disables this (tests/conftest.py) so a dev's real .env in the\n # repo root can't leak into assertions.\n model_config = SettingsConfigDict(\n env_prefix=\"LOOPLAB_\",\n env_file=\".env\",\n env_file_encoding=\"utf-8-sig\",\n extra=\"ignore\",\n allow_inf_nan=False,\n )\n\n @classmethod\n def settings_customise_sources(\n cls, settings_cls, init_settings, env_settings, dotenv_settings,\n file_secret_settings):\n \"\"\"Preserve source precedence across canonical/legacy parallelism names.\n\n Pydantic ranks init > env > dotenv correctly, but it ranks field names rather than semantic\n alias families. Promoting a legacy value inside each source before the merge makes an old\n explicit snapshot/file value beat a lower-priority canonical env value. ``appconfig`` also\n promotes its file/typed/--set sub-layers before combining them into the init source.\n \"\"\"\n def _with_parallel_aliases(source):\n return lambda: canonicalize_parallelism_source(source())\n\n return (\n _with_parallel_aliases(init_settings),\n _with_parallel_aliases(env_settings),\n _with_parallel_aliases(dotenv_settings),\n _with_parallel_aliases(file_secret_settings),\n )\n\n # Run profile (config-first preset, see PROFILES above). \"default\"/\"fast\" = today's lean\n # defaults; \"thorough\" turns the built quality/trust machinery ON in one word. A profile only\n # fills fields the user did NOT set — every knob it touches stays individually overridable.\n profile: str = \"default\"\n\n # Bounds (review C/⚪): a non-positive node/seed budget or timeout is never valid. Upper bounds are\n # equally load-bearing — the UI /start path writes these straight into the engine's env, and an\n # unbounded `eval_parallel` (or legacy `max_parallel`; e.g. 100000 from a crafted preflight) would\n # fan out that many sandboxes → resource exhaustion. Reject at config time, not mid-run. Ceilings\n # are generous; tests top out near 50.\n n_seeds: int = Field(default=3, ge=1, le=1024)\n max_nodes: int = Field(default=8, ge=1, le=1_000_000)\n # CODEX AGENT: legacy evaluation-width input. `eval_parallel` below is canonical and wins when set;\n # keep this field for old env/config/snapshots and direct Engine call sites. A width > 1 admits\n # concurrent evals, whose resource reservations may be CPU-only or span one/more GPUs — there is no\n # one-node/one-GPU promise. `max_parallel=0` is launch-time AUTO (detected GPU count, at least 1).\n max_parallel: int = Field(default=1, ge=0, le=1024)\n # CODEX AGENT: legacy build-width input. `llm_parallel` below is canonical and also controls the\n # shared provider-call total when positive. A width > 1 uses fresh pooled researcher/developer pairs;\n # it says nothing about the later eval's CPU/GPU reservation. Startup `0` derives build fan-out from\n # resolved eval width; a live Strategist/operator zero settles to 1. Without role_factory it clamps to 1.\n parallel_build: int = Field(default=1, ge=0, le=64)\n # Hypothesis-card Kanban re-architecture (docs/23, Layer 2). Two INDEPENDENT concurrency axes,\n # decoupled from each other so cost (LLM work) can be tuned separately from throughput (GPU evals).\n # These are the CANONICAL names going forward; `max_parallel`/`parallel_build` above are kept as thin\n # back-compat aliases (env vars / snapshots / ~100 Engine(...) call sites depend on the exact names,\n # so they cannot be renamed — CLAUDE.md). Resolution (orchestrator.__init__): a NEW name, when set,\n # WINS over its legacy alias; None => fall back to the legacy field => byte-identical to today.\n # `eval_parallel`: concurrent EVALS (the GPU/experiment consumer). None -> max_parallel; 0 = AUTO\n # (one experiment per detected GPU). This is the in-Run eval-concurrency ceiling; the resource\n # scheduler bin-packs declared footprints separately. Sibling local GPU-owning Runs serialize\n # through the conservative host-pool lease; external execution needs its own admission boundary.\n # `llm_parallel`: total concurrent LLM provider calls + BUILD fan-out. None -> legacy\n # parallel_build semantics without a shared total; 0 = startup AUTO (= resolved eval_parallel\n # for build fan-out, historical research overlap unbounded). A positive canonical value enables\n # the named-lane broker the Strategist can reallocate at cadence.\n eval_parallel: int | None = Field(default=None, ge=0, le=1024)\n llm_parallel: int | None = Field(default=None, ge=0, le=64)\n # Training-log monitor (I-series watchdog family): a per-eval background observer that tails the live\n # training log while a (often multi-hour) declared stage runs in a worker thread. ON in the product\n # surface (Settings). Its alert event is fold-ignored and cannot directly change lifecycle/champion;\n # CODEX AGENT: with watchdog_reflection on, that alert is nevertheless advice in a later Researcher\n # prompt and can change future proposals. It only fires on the command-eval path with an LLM client;\n # bare-library EngineOptions stays OFF. See test_options_divergence.\n # `_interval_s` is the BASE tick cadence (the effective one adapts to the per-experiment budget).\n train_monitor: bool = True\n train_monitor_interval_s: float = Field(default=600.0, gt=0)\n # Phase 3 — INTERVENTION (opt-in, separate from observation): let the monitor tree-kill a training the\n # LLM judges 'broken' (diverged / silent CPU fallback / not learning) EARLY, instead of burning the\n # whole budget. Off by default — the observer only WATCHES unless this is on. A kill only fires on a\n # 'broken' verdict (a plateau is 'watch', never killed) with confidence >= the threshold; the node then\n # fails normally (reason='monitor_broken'), so replay reconstructs it from that one terminal event.\n train_monitor_kill: bool = False\n train_monitor_kill_confidence: float = Field(default=0.8, ge=0.0, le=1.0)\n # ASHA live-curve watchdog (sibling of the training monitor): reads the latest INTERMEDIATE value of\n # the objective metric off the live log (reusing the eval's OWN metric reader). Finished-endpoint rank\n # remains advisory. An opt-in KILL additionally requires an operator-declared metric.resource_key and\n # enough sibling observations at exactly that resource; absent comparable evidence cannot stop a run.\n # Records a fold-ignored `asha_rank` diagnostic + trace span; the library default is OFF (off == today).\n asha_live: bool = True\n # Opt-in INTERVENTION (separate from the advisory signal): let the watchdog tree-kill a node whose\n # intermediate metric stays below the SAME-RESOURCE bar past a short grace window. Off by default —\n # it only surfaces endpoint rank unless this is on. A kill fails normally (asha_underperforming).\n asha_live_kill: bool = False\n # The bar sits at `asha_live_quantile` along a WORST->BEST ordering of the finished siblings' finals:\n # 0.5 = the median (a node worse than the median peer flags); a SMALLER value is more conservative —\n # it lowers the bar toward the WORST finished peer, so only a node worse than nearly all peers flags\n # (0.0 = worse than the worst). `min_siblings` is the floor of finished peers required before it ranks\n # at all, so it never acts on too little evidence.\n asha_live_quantile: float = Field(default=0.5, ge=0.0, le=1.0)\n asha_live_min_siblings: int = Field(default=3, ge=1)\n timeout: float = Field(default=30.0, gt=0)\n # Hard ceiling for the Researcher's governed per-node `Idea.eval_timeout`. One hour preserves the\n # existing heavy/neural-net authoring example while staying well below the sandbox's defensive\n # 24-hour subprocess ceiling. This is operator-owned run config: agents may request less or more,\n # but the accepted action is always clamped here after the `agent_control.timeout` permission gate.\n max_eval_timeout: float = Field(default=3600.0, gt=0, le=24 * 3600.0)\n # Intra-node sweep: a sweep node runs a whole grid in one process, so it gets this multiple of\n # `timeout` as its wall-clock budget (solution.py path; RepoTasks use their per-profile timeout).\n sweep_timeout_mult: float = Field(default=8.0, ge=1)\n # Eval STALL watchdog CAP (seconds): a stage that is completely SILENT on stdout/stderr for this\n # long — while still alive and BELOW its wall-clock deadline — is tree-killed early with a STALLED\n # marker (a hung dataloader/deadlock dies in minutes instead of burning a multi-hour timeout). The\n # per-stage window is `min(this, the stage's own timeout)`, so a short scorer's deadline fires first.\n # `0` DISABLES the watchdog entirely (only the hard deadline applies) — for a legitimately quiet\n # non-Python stage (block-buffered stdout, a script that logs only to its own file). Threaded into\n # the eval and surfaced to the Developer so its code can emit periodic progress to stay alive.\n eval_stall_timeout_s: float = Field(default=1800.0, ge=0)\n # Sandbox tier (ADR-13): \"trusted_local\" (subprocess, no Docker) for the CLI;\n # \"untrusted\" (Docker --network none, shared-kernel runtime) for hosted/multi-tenant UI;\n # \"hostile\" (untrusted + a true-isolation OCI runtime, gVisor `runsc` by default / Kata) for\n # running untrusted code. See runtime/sandbox.py::make_sandbox for the tier selection.\n trust_mode: str = \"trusted_local\"\n # Image for the untrusted command-eval tier (RepoTask, Phase 4): the framework's deps\n # should be baked in (the container runs --network none, so a pip setup can't fetch).\n docker_image: str = \"python:3.12-slim\"\n # Resource caps for the untrusted/hostile Docker tier (passed to `docker run --memory/--cpus`).\n # The default 4g bound protects other tenants from an OOM, but a model-training MLE-bench eval\n # routinely needs more — raise it (e.g. \"16g\") for such runs. \"\" disables the cap (unbounded).\n # Ignored by the trusted_local (subprocess) tier.\n sandbox_memory: str = \"4g\"\n sandbox_cpus: str = \"\"\n # Best-effort host-OOM guard for the TRUSTED-LOCAL (subprocess) tier. When set (e.g. \"8g\"), each\n # eval child gets an RLIMIT_AS address-space cap so a runaway trainer hits MemoryError instead of\n # OOM-killing the whole host (and the engine). POSIX only; a no-op on Windows. Default \"\" = OFF:\n # RLIMIT_AS bounds VIRTUAL memory, and CUDA/torch reserve tens of GB of virtual without using it,\n # so a default cap would spuriously kill GPU evals — leave it off for those and use the Docker\n # tier's real --memory cgroup bound (sandbox_memory) instead. Ideal for CPU/numpy/sklearn runs.\n sandbox_memory_local: str = \"\"\n # Best-effort disk-fill guard for the TRUSTED-LOCAL tier (P1-5): an RLIMIT_FSIZE cap on the size of\n # any single file an eval child writes (e.g. \"2g\"), so a runaway that writes an unbounded file gets\n # SIGXFSZ instead of filling the host disk. POSIX only. Default \"\" = OFF (large model checkpoints\n # need big files); set it for tasks that write only small artifacts.\n sandbox_fsize_local: str = \"\"\n # Search policy (ADR-2): \"greedy\" | \"evolutionary\" | \"mcts\" | \"asha\".\n policy: str = \"greedy\"\n # Ablation-driven refinement (I7): every N improves, ablate the best to find the\n # highest-impact parameter and refine it. 0 = off. (GreedyTree only.)\n ablate_every: int = 0\n # A0a (MLE-STAR): when ablating, treat each generated pipeline *code block* (not just a\n # numeric param) as an ablation unit — neutralize a block, measure the metric delta, refine\n # the highest-impact block. Off => the classic numeric-param ablation. Config-first knob.\n ablate_code_blocks: bool = False\n # A0b: real merge/ensembling. \"mean\" = legacy mean-param merge; \"ensemble\" = the Developer\n # writes a code-recombination ensemble over the two parents' solutions (verified: agent-proposed\n # ensembling 37.9%->43.9%). \"auto\" (default) = ensemble whenever the Developer actually\n # generates code (LLM/agent backends; declared via `is_code_generating`), mean for the\n # templated/toy developers where a code ensemble is meaningless — the evidence says code\n # recombination is the strongest single merge (removing it costs ~9 pp), so it is the default\n # wherever it can work. Falls back to mean when the Developer can't ensemble.\n merge_mode: str = \"auto\"\n # A0d (AIRA): inject a dynamic complexity hint into the draft/improve prompt keyed on the\n # node's child count (few children -> keep minimal; many -> escalate to ensembling/HPO).\n complexity_cue: bool = False\n # I1 (CAAFE-style) LLM feature-engineering: instruct the proposer/developer to add\n # semantically-meaningful engineered features (ratios, interactions, aggregations) as code. The\n # existing cross-validation eval + best-selection is the CV GATE — a feature set that doesn't\n # improve CV is never selected (feature engineering is non-universal, so this gate is mandatory).\n # Tabular tasks (those exposing column data). Off by default.\n feature_engineering: bool = False\n # A5: surface the REMAINING eval-compute budget into the proposal prompt so the Researcher can\n # reason over how much compute is left (explore broad while flush, exploit/cheapen when low).\n # No-op unless a max_eval_seconds budget is set.\n budget_aware: bool = False\n # A4 (LATS-style): feed a summary of the most recent FAILED branches (operator + error reason)\n # back into the proposal prompt so the proposer reflects on and avoids repeating them. ON by\n # default: it is SELECTIVE by construction (injects only when recent failures exist — the\n # evidenced form of reflection; reflecting at every step shows no gains, arXiv:2506.12928).\n failure_reflection: bool = True\n # Feed the LIVE-WATCHDOG observations (train-monitor health verdicts + ASHA intermediate-rank flags)\n # of recent experiments back into the proposal prompt, so the Researcher avoids re-proposing a config\n # whose TRAINING was already observed to be weak — even when the watchdog kills are off (default) and\n # the node ran to completion (those diagnostics are fold-ignored, so failure_reflection never sees\n # them). ON by default and SELECTIVE like failure_reflection (injects only when a recent flag exists).\n watchdog_reflection: bool = True\n # C3 deep test-driven repair: hand the Developer the failure taxonomy + a structured \"reproduce\n # then fix\" directive on debug, not just the raw stderr tail. ON by default (product surface); the\n # conservative library default in EngineOptions stays off, per that module's contract.\n deep_repair: bool = True\n # === Inline crash repair ==============================================================\n # Hybrid in-node crash repair: when an LLM-generated node CRASHES at runtime (mechanical errors\n # — bad import, removed kwarg, typo), the agent triages it and may repair the code IN PLACE within\n # the same eval (cheap; does NOT consume max_nodes and does NOT add a node to the search tree).\n # Semantic failures still flow to a new idea/debug node via the policy. ON by default; set False\n # to restore the prior behavior (every crash waits for a budgeted inter-node debug node).\n inline_repair: bool = True\n # Max in-place repair attempts per node before it fails normally. Default 0 = UNLIMITED: the same\n # agent keeps fixing + re-evaluating IN THE SAME node until it produces a clean metric — no new\n # node just to retry. Runaway is prevented not by a count but by the anti-stuck guard\n # (`inline_repair_stuck_repeat`): when the SAME error signature repeats with no progress, or the\n # agent's crash-triage says \"abandon\", the node fails. Set a positive N to cap the retries instead.\n inline_repair_attempts: int = Field(default=0, ge=0)\n # Anti-stuck: abandon in-node repair when the SAME error recurs this many times in a row (no\n # progress). Keeps \"unlimited\" repair from looping forever on an unfixable error.\n inline_repair_stuck_repeat: int = Field(default=4, ge=2)\n # Which failure reasons (_failure_reason: crash|timeout|oom|setup|no_metric|drift) are eligible for\n # inline repair. Default: mechanical crashes, timeouts AND OOM-kills — a timeout/OOM means the code\n # was too slow / too memory-hungry for the budget (or the pod's cgroup limit), not that the idea is\n # wrong, so the agent repairs it by reducing compute/memory (fewer estimators/epochs/folds/seeds, a\n # lighter model, a smaller batch, subsampling) instead of letting the node die unrecovered. Env\n # override expects a JSON array.\n inline_repair_reasons: tuple[str, ...] = (\"crash\", \"timeout\", \"oom\")\n # Cost ceiling for in-node repair of a MULTI-STAGE eval: the max number of FULL pipeline re-runs\n # (i.e. re-trains from the first stage) the inline-repair loop may do before abandoning to the\n # inter-node debug operator. A late-stage failure (a broken `score`/eval script that didn't touch\n # `train`) REUSES the completed train checkpoint and re-runs only from the failed stage — that is\n # CHEAP and NOT counted here; only a repair that changes an EARLIER stage's code (train.py/loss.py/…)\n # forces a full re-train, which each costs the whole training time. Without this bound, a repair that\n # keeps changing training code could burn many full trains (the anti-stuck guard is error-signature\n # based, not cost based). 0 = unlimited (legacy behavior). Single-command evals ignore it.\n inline_repair_retrain_cap: int = Field(default=2, ge=0)\n # Environment self-prep: when a solution crashes purely because a KNOWN library isn't installed\n # (ModuleNotFoundError), the engine pip-installs it into the eval interpreter and re-runs — so a\n # torch/XGBoost/CatBoost idea (e.g. a GRU model) actually runs on a fresh box instead of being\n # thrown away as `idea_rejected`. Trusted_local tier only (the untrusted/hostile Docker tiers run\n # --network none). A typo'd or local helper module is NOT installed (that's a code bug to repair).\n auto_install_deps: bool = True\n # Per-package wall-clock budget for an auto-install (seconds). Generous: large wheels like torch\n # can take minutes to download/build.\n dep_install_timeout: float = Field(default=900.0, gt=0)\n # --- Agent governance: who may change a setting at runtime (per-setting allow-list of roles) ---\n # Roles: \"strategist\" (A7 meta-controller; run-wide), \"boss\" (run-chat operator-proxy; run-wide),\n # \"researcher\" (per-experiment proposer; PER-NODE — e.g. set a longer eval timeout for a neural-net\n # idea). A setting ABSENT from this map is LOCKED — no agent may change it, only a human via the\n # snapshot/UI. The sole conditional exception is Strategy-only `card_scoring`: Card mode grants\n # it implicitly without changing the default flag-off snapshot, while an explicit [] revokes it.\n # The gate is ENFORCED at every AGENT seam via `_agent_may` — the strategist's whole\n # control surface (policy/operators/novelty_stance/prefer_sweep/developer/fidelity/timeout/\n # eval_parallel/llm_parallel) is checked in `_apply_strategy`, so removing a role from a knob locks it,\n # not just greys out the UI pill. The boss/max_* grants document boss-relevant knobs, but a\n # `budget_extend` is a HUMAN control intent (the boss action-builder can only emit `add_nodes`), so\n # it is applied as-is — the human always wins via the UI/snapshot. Defaults are conservative: resource/\n # search-shape knobs the agents already reason about; provider infra (llm_model/base_url/api_key,\n # docker_image) stays locked. The UI shows S/B/R pills per setting. `timeout` granted to the\n # researcher IS the \"auto\" per-node mode — the researcher sizes each experiment's wall-clock\n # (Idea.eval_timeout); the config `timeout` is the fallback for nodes it doesn't size. Env override\n # expects a JSON object. (`fidelity`/`novelty_stance`/`prefer_sweep`/`developer` are governance keys\n # for the strategist's per-node dials — not 1:1 Settings fields, but gated the same way.) The default\n # lives in the module-level DEFAULT_AGENT_CONTROL so the Engine's direct-construction default matches.\n agent_control: dict[str, list[str]] = Field(default_factory=default_agent_control)\n # C1 (Agentless localization): for repo tasks, rank the source files most relevant to the most\n # recent failure (traceback + identifiers) and surface them in the proposal/repair prompt so the\n # Developer edits the right place. Off by default; repo tasks only.\n localize_faults: bool = False\n # A2 surrogate-guided proposal (BO-lite): fit a k-NN surrogate over (params->metric) and propose\n # by acquisition instead of random/hill-climb. Numeric-bounds tasks only; bootstraps via the\n # base Researcher. Off by default. `surrogate_explore` = UCB-style exploration weight.\n surrogate_proposer: bool = False\n surrogate_explore: float = 0.1\n # E2 researcher panel: generate K candidate ideas per proposal and keep the one ranked best by a\n # cheap empirical surrogate over the (params->metric) history (NOT an LLM-judge). 1 = off.\n researcher_panel: int = 1\n # A1 ASHA / successive-halving: reduction factor (keep top 1/eta per rung) and rung budget.\n asha_eta: int = Field(default=3, ge=2)\n # Rung-0 width for ASHA/BOHB (the wide base of cheap drafts). 0 = use n_seeds (default,\n # preserving prior behavior); set >0 to seed a wider/narrower base independent of n_seeds.\n asha_rung_nodes: int = Field(default=0, ge=0)\n # A6 proxy/predictive scoring: cheaply rank a candidate's potential from early-stage signals\n # and skip a full eval for the doomed bottom fraction. 0.0 = off (no candidate skipped).\n proxy_scoring: bool = False\n proxy_kill_fraction: float = Field(default=0.0, ge=0.0, le=0.9)\n # === Novelty / dedup ==================================================================\n # E1 novelty/dedup gate: reject near-duplicate proposals (normalized param-space distance below\n # `novelty_epsilon`) by deterministically nudging them off the duplicate — stops the search\n # wasting evals re-trying the same idea. Audit event `novelty_rejected`. Off by default.\n # Novelty/dedup MODE selector: how a fresh proposal is checked against already-tried experiments.\n # \"off\" — no explicit gate; the agentic Researcher's own read-the-history judgment is trusted.\n # \"algo\" — the deterministic gate: numeric param-distance (`novelty_epsilon`) + optional embedding\n # similarity (`novelty_semantic`). Cheap, no LLM call, but can't explain itself.\n # \"llm\" — an LLM adjudicates (reads the real experiments via tools) whether the idea is a\n # near-duplicate and, if so, asks the Researcher once more for something different. One\n # extra LLM call per proposal — highest quality, follows the \"let the LLM decide\" line.\n novelty_mode: str = \"llm\"\n # Legacy sub-toggles, honored by the \"algo\" mode (and back-compat: novelty_gate=True forces \"algo\"):\n novelty_gate: bool = False\n novelty_epsilon: float = 0.05\n # T5 semantic novelty (active whenever the deterministic gate runs — novelty_mode=algo,\n # novelty_gate=true which is the legacy alias for algo, OR the Strategist novelty stance is\n # \"explore\"; a no-op only under novelty_mode=llm/off with a non-explore stance): reject a proposal\n # whose idea TEXT embeds within\n # `novelty_semantic_threshold` cosine of an existing node's. OFF BY DEFAULT: novelty is the agentic\n # Researcher's call, made by READING the actual prior experiments (read_experiment /\n # find_analogous_across_runs / list_experiments), NOT an embedding-similarity auto-reject that can't\n # explain itself and mis-fires on paraphrases. Semantic/param search stays available as TOOLS in the\n # Researcher's context; the LLM decides whether an idea is a true duplicate.\n novelty_semantic: bool = False\n novelty_semantic_threshold: float = Field(default=0.92, ge=0.5, le=1.0)\n # T10: debug-lineage depth bound for every policy — how many error-feedback repairs a failing\n # lineage gets before it is abandoned. The old default (1) abandoned lineages after ONE repair;\n # multi-turn/deeper debugging is a verified lever (AIRA2 ReAct debug +5.5 percentile points).\n debug_depth: int = Field(default=2, ge=1)\n # P4 operator bandit (GreedyTree): replace the fixed merge/ablate cadences with a\n # deterministic UCB over per-operator yield (Δmetric per eval-second, folded from the run).\n # Off by default (the cadences are well-tested and the bandit has no direct published\n # ablation); `thorough` turns it on.\n operator_bandit: bool = False\n # P1 hypothesis ledger: ask the Researcher to state the one-line hypothesis each experiment tests,\n # register deep-research directions as hypotheses, and track them to a verdict on the board. ON by\n # default. CODEX AGENT: the board is shown to the Researcher and can be foresight-ordered, so it can\n # steer later proposals even though it never directly re-ranks already-evaluated nodes. Set False\n # to drop the prompt nudge + registration.\n track_hypotheses: bool = True\n # E4/M2/M3 reflection-memory -> priors (gradient-free cross-run meta-learning): at run end distill\n # the winner into `<memory_dir>/meta_notes.jsonl` AND structured lessons (incl. NEGATIVE results:\n # tested/abandoned hypotheses + failure themes) into `lessons.jsonl` with a task fingerprint; at run\n # start, inject exact-task notes + fingerprint-matched lessons from SIMILAR tasks into the proposal\n # prompt. ON by default — but a NO-OP until `memory_dir` is set (that's where cross-run memory lives).\n reflection_priors: bool = True\n # M6 comparative lessons (MARS \"comparative reflective memory\", doc 13 §7 item 2): distill\n # credit-assigned lessons from PAIRS of solutions — which SPECIFIC change made a child beat\n # (or regress from) its parent, and what fixed a failure — on top of the one-shot run-end\n # reflection. At most one extra LLM call per distillation (all pairs batched); offline a\n # deterministic param-diff credit stands in. Gated on reflection_priors + memory_dir like the\n # rest of cross-run memory — and since memory_dir has a default (~/.looplab/memory), this is\n # ACTIVE out of the box; clear memory_dir to disable cross-run memory wholesale.\n comparative_lessons: bool = True\n # M6 mid-run cadences (doc 13 §7 item 5 — the AgentRxiv live-share pattern): every N created\n # nodes, WRITE comparative lessons to the shared cross-run store during the run (lessons_every\n # — one batched LLM call per firing) and RE-READ the store so lessons distilled by CONCURRENT\n # runs reach this run's proposals (lessons_refresh_every; file re-read, no LLM call, skipped\n # when the store is unchanged). ON by default (every 4 nodes) and, like comparative_lessons\n # above, live out of the box because memory_dir defaults on; 0 = off — run-end write /\n # run-start read only, the pre-M6 behavior.\n lessons_every: int = Field(default=4, ge=0)\n lessons_refresh_every: int = Field(default=4, ge=0)\n # B3 output redaction: mask credentials (known key shapes + high-entropy tokens) in the\n # stdout/stderr tail before it is persisted to the event log / spans / UI — a leaked secret in a\n # print()/traceback must not land in the durable log. Off by default; recommend on for untrusted.\n redact_output: bool = False\n # B5 reward-hacking detector: a host-side monitor that flags suspicious wins (grader/answer-key\n # access, runtime writes to frozen files, suspiciously-perfect metrics) as a `reward_hack_suspected`\n # audit event in the Trust panel. Off by default. Whether a flag CHANGES selection is governed by\n # `trust_gate` below (default: audit-only, never changes selection).\n reward_hack_detect: bool = False\n # Trust enforcement (T2): what a reward-hack / data-leakage flag actually DOES to selection.\n # \"audit\" (default) = surface it in the Trust panel, never change selection (today's behavior);\n # \"gate\" = a flagged node can no longer be selected as BEST **and isn't bred/confirmed from**\n # (breed_excluded — see replay._apply_trust_gate / models.breedable_nodes), but stays\n # FEASIBLE so it still counts for diversity/audit — closes the \"a hacked/leaky node can\n # win OR seed its lineage\" hole;\n # \"block\" = additionally mark it fully INFEASIBLE (removed from feasible_nodes entirely).\n # Deliberately gates only high-precision CHEATING/LEAKAGE signals. Broad `critic:*` findings stay\n # advisory; CODEX AGENT: `critic:hardcoded_metric` is the one narrow high-precision critic exception\n # and gates because it requires a literal metric with no computed assignment anywhere.\n trust_gate: str = \"audit\"\n # I3 data-centric: static code-leakage scan of each evaluated solution (fit-before-split,\n # fit-on-test) surfaced into the Trust panel alongside reward-hack flags. Off by default.\n code_leakage_detect: bool = False\n # 4.4 sandbox instrumentation (needs reward_hack_detect): after each eval, flag a RUNTIME write\n # to a protected/frozen file (an answer key or grader tampered mid-run) — behavioral evidence a\n # static code scan misses. ON by default; only fires when the reward-hack detector is on.\n workdir_audit: bool = True\n # D8 decoupled research Verifier: check every Deep-Research memo's CLAIMS against their cited\n # evidence (node ids / urls) before recording — synthesis is the documented weak link (Kosmos\n # 57.9% accurate). Deterministic layer always; an LLM rubric pass when the DeepResearcher has a\n # client. The verdict cannot change this run's node/champion, but finalize uses aligned supported\n # verdicts as the evidence gate for persisted D8 cross-run claims; unavailable/misaligned stays\n # unverified. ON by default (no-op with no memo/claims).\n research_verify: bool = True\n # C4 independent critic: an execution-free critic of each solution (stub / hardcoded-metric /\n # params-ignored; on host-graded tasks the metric checks become a submission-output check)\n # surfaced in the Trust panel. Broad findings are advisory; `critic:hardcoded_metric` can gate under\n # trust_gate=gate/block. Off by default.\n critic_check: bool = False\n # A7 Strategist (NEW, user-requested): optional LLM/rule meta-controller that picks the search\n # policy/allocator + operator mix + fidelity (+ Developer backend) per situation. Config-first:\n # every choice the Strategist makes is also a direct knob. Defaults to \"agent\" so the agent adapts\n # the search policy/operators/fidelity per situation; set \"off\" for static config-driven search.\n # \"agent\" = a tool-using Strategist that READS the run/data/sibling-runs/KB/memory (B1-guarded)\n # before deciding, instead of the single-shot \"llm\" call over aggregate stats.\n strategist_backend: str = \"agent\" # \"off\" | \"rule\" | \"llm\" | \"agent\" — default AGENTIC: the\n # Strategist READS the run/data/sibling-runs/KB/memory with tools before deciding, not a single-shot\n # call over aggregate stats. \"llm\" = the old non-agentic single-shot; \"rule\"/\"off\" = no LLM.\n strategist_every: int = Field(default=3, ge=1) # consult cadence (created nodes)\n # PART V (F1): concept CLASSIFIER re-tag + consolidation cadence, DECOUPLED from strategist_every. The\n # LLM concept map is heavier and slower-moving than a strategy consult, so it refreshes on its own\n # (sparser) interval. Researcher-authored idea.concepts still fold immediately at node_created — this\n # only paces the classifier-evidence + consolidation refresh (and the concept-coverage pivot snapshot).\n concept_retag_every: int = Field(default=30, ge=1)\n # === Confirmation & holdout ===========================================================\n # Multi-seed confirmation (I12, ADR-15): confirm the top-k under N seeds before\n # finishing. 0 disables (default). Only meaningful when eval has variance.\n confirm_top_k: int = 0\n confirm_seeds: int = 0\n # D1 seed-holdout discipline: the FIRST seed the confirm phase uses. Search evals run with the\n # implicit seed 0 (LOOPLAB_EVAL_SEED unset -> \"0\"), so a base of 1 keeps every confirm split\n # DISJOINT from anything the search optimized against — the confirm metric is then a\n # generalization signal, not a re-measurement (AIRA: selecting on a signal the search saw\n # overfits by 9-13 pp). Set 0 to restore the legacy overlapping seeds 0..N-1.\n confirm_seed_base: int = Field(default=1, ge=0)\n # D1 holdout-gated promotion (B6, Arbor-style): for host-graded tasks, reserve this fraction of\n # the held-out labels as a FINAL holdout partition the search never sees — every search/confirm\n # eval is scored on the remaining rows only, and at finish the val-top-k are re-scored on the\n # holdout partition (free: the predictions already exist). 0.0 = off (legacy full-label scoring).\n holdout_fraction: float = Field(default=0.25, ge=0.0, le=0.9)\n # Champion selection prefers the HOLDOUT metric among the nodes that have one (the val-top-k).\n # Recorded in run_started so replay applies the same rule; old logs fold to False (legacy pick).\n # The per-node val-holdout `generalization_gap` folds into the Trust panel either way.\n holdout_select: bool = True\n # How many val-leaders get a holdout evaluation at finish (host-graded tasks).\n holdout_top_k: int = Field(default=3, ge=1)\n # R1-c / Part IV — calibrated §12-verifier tie-break in best-selection. When a fresh node's robust\n # metric EXACTLY TIES an already-eligible node, the engine runs the §12 verifier (grounded on each\n # node's realized result, `selection_criteria`) and records a soundness score per node. The tie-break\n # then applies across the WHOLE selection path: the mean pick breaks a robust_metric tie by the score,\n # and — when `holdout_select` is on — the holdout-slot ranking prefers the sounder node so it isn't\n # denied a holdout eval, and the final holdout override breaks a holdout_metric tie by it too. Strictly\n # a TIE-BREAK — it can NEVER move a node ahead of a strictly-better metric (§21.7 advisory-never-\n # overrides). Best-effort: verification is LAZY (only fires on an actual tie) and bounded per cadence,\n # so a large/late tie stays on deterministic metric+id order unless one atomic treatment scores every\n # member. Recorded in run_started so\n # replay applies the same rule; old logs fold to False (legacy pick). Opt-in (default off); needs a\n # reachable LLM client. Enable only after validating calibration offline with `verifier.calibrate`.\n # See trust/verifier.py + core/fitness.py.\n select_verifier: bool = False\n # R1-d (§21.19): widen the verifier tie-break from EXACT-metric to a conservative STATISTICAL tie.\n # The band is capped by both the metric leader's SE and the pooled SE-of-difference, so challenger\n # variance cannot widen it and a significant difference is never a tie (§21.7). Off -> exact only.\n verifier_ci_tie: bool = False\n # Verifier sample count for `select_verifier` (the §12 repeated-sampling expectation). 3 tames the\n # single-shot variance; 1 = single-shot (cheaper, noisier).\n select_verifier_samples: int = Field(default=3, ge=1, le=32)\n # Budget (I13): hard wall-clock ceiling; the run aborts cleanly when exceeded.\n max_seconds: float | None = None\n # Eval-compute budget (#2): hard ceiling on cumulative time spent INSIDE evals (training\n # runs), separate from wall-clock. Survives resume (summed from the event log). The real\n # guard against a silent multi-hour sweep when an eval is a minutes-hours training run.\n max_eval_seconds: float | None = None\n # Cross-run memory (I19, ADR-10): if set, the best result of each run is stored as\n # a case here, and the cases become retrievable knowledge for future runs.\n memory_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"memory\"))\n # HITL (I21, ADR-11): pause for human approval of the final best before finishing.\n require_approval: bool = False\n # Diversity archive (I22): niche bucket width in parameter space.\n archive_resolution: float = 1.0\n # Coverage read-model (narrowing signal): at the strategist cadence, compute a breadth summary\n # (themes / param-niches / theme entropy / dominant-theme fraction) from the folded run, record\n # it as a `coverage_snapshot` audit event, and surface it into the Strategist's decision context.\n # Deterministic, cheap, and purely additive context — no search-behavior change on its own. On by\n # default (like the rest of the always-on situational context). See search/coverage.py.\n coverage_context: bool = True\n # PART IV Phase 2a live steering (§21.11/§21.13). When on, the `concept_retag_every` cadence (NOT\n # `strategist_every` — the producer gates on `_should_consult_concepts`, which uses the seed boundary\n # + `concept_retag_every`, default 30) records a\n # concept-graph coverage + uncovered-region snapshot (deterministic heuristic tagger over the\n # task-type skeleton) and, on an `explore` stance, the Researcher's novelty hint names the exact\n # uncovered regions (\"0 coverage in {negatives/external-mining, distillation} — go there\") instead\n # of the vague \"broaden\". ON by default in the product Settings (ce4a379), EngineOptions off: it\n # only enriches an already-mode-gated explore hint, never forces exploration, and no-ops for a task\n # with no curated concept skeleton. The snapshot is auditable, but its prompt cue can change future\n # candidate generation; it does not directly rewrite best-metric selection. See search/concept_graph.py.\n # DESIGN NOTE (2026-07-17 critique): now ON unconditionally, but a toy / non-ML task yields EMPTY concept\n # tags while still paying the per-node LLM concept cost (~10 calls/node with the full Part IV/V bundle).\n # Consider AUTO-QUIESCING: if the heuristic tagger returns empty for the first N nodes (no concept signal),\n # skip the LLM concept work for the run — keeps it on for ML tasks, free on toy/degenerate ones.\n concept_pivot: bool = True\n # PART IV Phase 2b — D3 graded novelty into the LIVE novelty gate (§21.4/§21.13). When on and the\n # task has a curated concept skeleton, the novelty gate first grades a proposal over the concept\n # graph: a level-4 \"same direction, DIFFERENT implementation\" or a level-5 \"re-opens a\n # wrongly-abandoned FAILED direction\" proposal is ALLOWED through unchanged — the flat LLM/semantic\n # dedup gate can't tell \"this DCL tweak\" from \"the whole DCL branch\" and would wrongly reject a\n # legitimate variant or never re-open a sound-but-killed direction (the node_63 archetype). Levels\n # 1/2/3 (identical / near-dup / prior-run) still fall through to the existing gate. Deterministic\n # (heuristic tagger, no LLM), audit event `novelty_graded`, replay-safe. ON by default in the product\n # Settings (ce4a379), EngineOptions off; no-ops for a task with no skeleton. See search/graded_novelty.py.\n graded_novelty: bool = True\n # PART IV Phase 2b — D7 capability-expansion forced-jump DIRECTIVE (§21.8/§21.13, issue #7). When on\n # and the concept-graph cadence detects action-space LOCK-IN (the search has stayed inside one D5\n # branch for a long consecutive streak) on an `explore` stance, the Researcher's novelty hint\n # ESCALATES from \"broaden\" to \"expand the ACTION SPACE — build the missing infra (external mining,\n # a new data pipeline, a different eval), do NOT swap another {locked} lever.\" CODEX AGENT: the same\n # gate also stamps the proposal's authoritative operator as `expand`; that node competes normally\n # under SearchFitness and its separate operator yield is measured. It does not force a winner.\n # Reads the 2a concept-coverage snapshot, so it needs `concept_pivot` to record one. OPT-IN\n # (default off). See engine/proposal_cues.py + search/lock_in.py.\n capability_expansion: bool = False\n # PART IV cross-run Step 0 (§21.20.12/§21.20.13). Universal task-fingerprint tokenization: drop the\n # ASCII-only `[a-z0-9]` allowlist on goal keywords so a non-Latin goal (Russian, CJK, …) is NOT\n # silently dropped from its cross-run fingerprint (today such a goal keys only on kind/dir/metric and\n # never reaches SIMILAR-task priors/lessons/cases). Uses `[^\\W_]+`/`.casefold()` — same splitting as\n # before, any script. ON by default in the product Settings (ce4a379); the bare-library EngineOptions\n # default stays off, so a run pinned to it won't silently re-key a portfolio mid-flight. See engine/memory.py.\n fingerprint_universal: bool = True\n # PART IV cross-run Step 2 (§21.20). Fill graded-novelty's `prior_concepts` from the cross-run\n # ConceptCapsuleStore: at run end write a per-run concept capsule (the shipped `node_concepts` tags +\n # best outcome, keyed by task_fingerprint) to `memory_dir`; when a later SIMILAR run proposes an idea\n # whose concept was tried before, SURFACE the prior outcome as a `cross_run_prior` audit event (never\n # reject — D3 level 3). Audit-only, off the selection path; ON by default in the product Settings\n # (ce4a379), EngineOptions off. Needs a `memory_dir` to share capsules.\n # See engine/memory.py (ConceptCapsuleStore) + engine/novelty.py.\n # PREREQUISITES (CODEX — this flag is NOT standalone): the WRITE needs per-run concept tags\n # (`node_concepts`, produced by `concept_pivot`/F1 tagging) — no tags => no capsule; the SURFACE path\n # runs inside the graded-novelty precheck, so it also needs `graded_novelty` on. With only this flag +\n # `memory_dir` it persists/surfaces nothing. Enable `concept_pivot` + `graded_novelty` alongside it.\n # A non-Latin (Russian/CJK) portfolio should ALSO set `fingerprint_universal`, else the capsule's goal\n # tokens are dropped and its fingerprint over-matches on kind/dir/metric alone (the novelty direction\n # gate narrows this, but goal-word similarity is still lost).\n cross_run_concepts: bool = True\n # PART V (B): run-base + node-DELTA concept authoring. When on, once the first evaluated node has\n # authored concepts the engine seeds RunState.run_base_concepts from them (a one-shot EV_RUN_CONCEPTS,\n # idempotent because it only fires while the base is empty), and the Researcher's proposal hint tells it\n # to author only `concepts_added`/`concepts_removed` vs the run base + its parent — minimal per-node\n # annotations that inherit down the DAG (a swap propagates). Off -> every node authors its full concept\n # set as before. Product-on; the bare EngineOptions mirror defaults off (test_options_divergence).\n concept_run_base: bool = True\n # PART IV cross-run Step 5 advisory (§21.20.5). Fold the bounded cross-run CONTEXT PACK — evidence-\n # grounded claims with BOTH support and counter-evidence (Step 4) + a portfolio-coverage line (Step 3) —\n # into the Researcher's proposal prompt, exactly like the E4 cross-run prior note. Advisory ONLY: it is\n # prompt-grounding: it can change the Researcher's later candidates, but does not directly rewrite\n # best-metric selection (§21.7). Reads `memory_dir` (lessons.jsonl + concept_capsules.jsonl); \"\" when\n # empty. ON by explicit experimental product choice; no frozen A/B validation is claimed. The\n # bare-library EngineOptions default stays off (engine/options.py, frozen in\n # test_options_divergence). See engine/proposal_cues.py.\n cross_run_advisory: bool = True\n # PART IV cross-run §21.20.13 (full CR of the lean fuzzy claim merge). Switch the claim read-model to the\n # SCOPE+POLARITY-safe STRUCTURED claim key (engine/claim_key.py): claims from different tasks never\n # merge, opposite polarity (\"X helps\" vs \"X never helps\") is surfaced as a CONTRADICTION instead of being\n # collapsed, paraphrase/inflection variants group by exact structured key (no transitive over-merge), and\n # operator governance is scope-precise (a decision in task A cannot reach a same-worded claim in task B).\n # Affects the `cross_run_advisory` context pack only; ON by default in the product Settings (ce4a379);\n # the bare-library EngineOptions default stays off (engine/options.py). See engine/claims.py.\n cross_run_structured_claims: bool = True\n # PART IV cross-run §22.4 (AGENTIC taxonomy steward). At finalize, when an LLM client is available, let\n # the concept steward (engine/concept_steward.py) review the freshly-updated portfolio concept graph and\n # PROPOSE a curation — merge duplicate slugs / split conflated ones / purge noise. Proposals are LOGGED to\n # `concept_curation_log.jsonl` for operator ratification (via the serve/CLI governance surface); the LLM\n # never mutates fold state. The calls run synchronously during finalize, so they add latency and may add\n # paid inference; a caught steward failure does not prevent terminal completion or trigger a retry.\n # ON by explicit experimental product choice when `memory_dir` + an LLM backend are present; the\n # bare-library EngineOptions default stays off (engine/options.py). See engine/lessons.py.\n cross_run_curation: bool = True\n # Deprecated compatibility flag. Steward output is untrusted and finalize is proposal-only regardless\n # of this value; retained so old snapshots still validate and logs can disclose that auto was requested.\n cross_run_curation_auto: bool = False\n # Role backend (ADR-7/14): \"toy\" (offline optimizer) | \"llm\" (live model).\n backend: str = \"toy\"\n # Developer backend (ADR-7): \"default\" (templated/LLM from the task) or an external\n # CLI coding agent: \"opencode\" | \"aider\" | \"goose\" | \"continue\".\n developer_backend: str = \"default\"\n # C2 best-of-N: generate N candidate implementations per node and keep the best by an\n # execution-free reward (static validity + metric-print). 1 = off. In-house LLM developer only\n # (not external agents — ADR-7 cost rule). The top SWE-bench reliability lever for weak models.\n best_of_n: int = 1\n # D10 list-wise best-of-N selection: when the top static-scorers TIE, break the tie with a\n # comparative LLM selection over the candidates presented together (+~3 pts vs pointwise on\n # GAIA, arXiv:2506.12928). The static score stays the primary filter — the LLM is a weak\n # comparative prior, never the sole oracle. ON by default (no-op when best_of_n==1).\n best_of_n_listwise: bool = True\n # === Foresight / predict-before-execute ===============================================\n # FOREAGENT predict-before-execute (arXiv:2601.05930): use the LLM as an IMPLICIT WORLD MODEL to\n # predict — WITHOUT executing — which candidate / hypothesis will score best, primed with a\n # Verified Data Analysis Report (data profile / brief) + the accumulated experiment memory. Master\n # switch, ON by default; a no-op wherever there is nothing to rank (best_of_n==1 and\n # foresight_panel==1) or no LLM client. See `looplab/search/foresight.py`.\n foresight: bool = True\n # Hypothesis foresight breadth: generate K candidate ideas per proposal and keep the one PREDICTED\n # best pre-execution by the world model — it ranks the STRUCTURAL / text ideas the numeric k-NN\n # surrogate (researcher_panel) is blind to, primed with the data profile + memory (the synergy).\n # >1 enables it (LLM backend only; needs `foresight` on); 2 = on by default at modest cost, 1 = off.\n foresight_panel: int = 2\n # AGENTIC foresight: run the ranking (hypothesis-board prioritization + K-idea pick) as a TOOL-USING\n # loop that can pull actual experiment results / data facts before deciding, instead of a one-shot\n # prediction from a pre-baked report. ON by default (needs foresight + a client; falls back to the\n # one-shot predictor on any hiccup). A few extra LLM calls per proposal; set False for the cheaper\n # single-call ranker.\n foresight_agentic: bool = True\n # §1 foresight confidence gate: the minimum predicted confidence at which a predict-before-execute\n # pick is ACTED ON. Below it the ranker abstains — the K-idea panel falls back to the first\n # proposal, best-of-N falls through to the D10 tie-break — instead of committing a low-confidence\n # choice. 0.0 (default) = OFF (act on every pick, the historical behavior); raise toward ~0.5 to\n # make the world model defer when it isn't sure. Pairs with the foresight track record (§1) the\n # predictor is now primed with. Range 0.0-1.0.\n foresight_min_confidence: float = 0.0\n # PART IV Phase 2c — replace the world model's SELF-REPORTED confidence (measured Pearson≈0 with the\n # realized outcome, §21.12) with a CALIBRATED §12-verifier score. When on and a client is available,\n # after the K-idea ranker picks the predicted-best candidate the §12 verifier scores it (grounded +\n # repeated + criteria-decomposed: `foresight_criteria` — likely to improve the objective, and\n # sound/feasible) and THAT calibrated score becomes the confidence the `foresight_min_confidence` gate\n # and the telemetry use. Degrades to the self-reported confidence without a client or on any verifier\n # error (the telemetry records which source was used). ON by default in the product Settings (ce4a379),\n # EngineOptions off; a few extra LLM calls per acted-on proposal. See looplab/trust/verifier.py + search/foresight.py.\n # REVISIT (cost/quality, not yet resolved): a live classification run measured ~10 LLM calls & ~3.8 min/node\n # on deepseek-v4-flash with the full Part IV/V default bundle; the calibrated verifier is part of that per-node\n # tax. Kept ON deliberately — it raises decision quality (self-reported foresight confidence has ≈0 Pearson\n # with realized outcome), and lowering quality to save calls is the wrong trade. TODO: run a frozen quality/cost\n # A/B (verifier-on vs -off) to quantify the recall@objective gain per extra call before considering a default flip.\n foresight_verify: bool = True\n # Verifier sample count for `foresight_verify` (the §12 repeated-sampling expectation on a no-logprob\n # backend). 3 tames the single-shot variance §21.12 measured; 1 = single-shot (cheaper, noisier).\n # RESUME-critical: this value is persisted in config.snapshot.json, so a hard `le` schema bound would\n # make an old run started with a larger LOOPLAB_FORESIGHT_VERIFY_SAMPLES un-resumable (Settings(**snapshot)\n # would raise on `looplab resume` / finalize-recovery / serve replay). CLAMP into [1, MAX] via a validator\n # instead of rejecting — the runtime cap is preserved without breaking the snapshots/env-compat rule.\n foresight_verify_samples: int = 3\n\n @field_validator(\"foresight_verify_samples\")\n @classmethod\n def _clamp_foresight_verify_samples(cls, v: int) -> int:\n # Coerce a persisted/env value into [1, MAX] rather than reject (see field note above).\n try:\n v = int(v)\n except (TypeError, ValueError):\n return 3\n return max(1, min(v, MAX_FORESIGHT_VERIFY_SAMPLES))\n\n # T7 LLM response cache: serve an identical DETERMINISTIC (temperature 0) request from an\n # in-process content-addressed cache instead of re-hitting the model — cuts cost on\n # retry/panel/verify flows. NEVER caches sampling calls (temp>0: best-of-N / panel / novelty\n # re-propose must vary). Off by default (most role calls run at temperature>0 anyway).\n llm_cache: bool = False\n agent_cmd: str | None = None # override the agent's launcher (path / wrapper)\n # External-agent validation (ADR-7): wrap a CLI-agent Developer with a validator that\n # audits each output (no-op/syntax/crash/timeout), retries with feedback, and falls\n # back to the in-house LLM Developer. Off -> use the raw agent output unchecked.\n validate_agent: bool = True\n agent_max_retries: int = 1 # re-prompts of the agent on an invalid result\n # Patch-gated multi-file agent (ADR-7 Rule 3): run the CLI agent in a git worktree and\n # accept only edits whose paths match `agent_surface` (reject-not-strip out-of-surface\n # touches). Lets the agent create helper modules, not just solution.py. Degrades to\n # whole-file readback if git is unavailable.\n agent_patch_gate: bool = True\n agent_surface: list[str] = [\"*.py\"] # edit-surface allow-list (globs)\n # Trust policy for an agent-authored eval/metric adapter (RepoTask onboarding, Phase 3):\n # \"ratify_freeze\" waits for human approval, \"autonomous\" accepts the proposed spec without that\n # gate, and \"ratify_freeze_drift\" also enforces the configured independent drift cross-check.\n eval_trust_mode: str = \"ratify_freeze\"\n # RepoTask node seeding policy (run-wide fallback; a task/editable `seed_mode` overrides):\n # \"auto\" (default) copies git-tracked files when the editable is a git repo (so a tree bloated\n # with untracked artifacts isn't deep-copied per node), else a full copy; \"tracked\" forces\n # code-only; \"all\" forces a full recursive copy (legacy). Genesis authors per-task from the\n # user's words; this is the global default when unspecified.\n seed_mode: str = \"auto\"\n llm_model: str = \"qwen3:8b\"\n # === LLM / transport ==================================================================\n llm_base_url: str = \"http://localhost:11434/v1\" # Ollama OpenAI-compatible endpoint\n llm_temperature: float = 0.6\n # H3 per-role model presets (5090 recipe): optionally run the Researcher and Developer on\n # DIFFERENT models/endpoints (e.g. Developer=Qwen3-Coder-30B for code, a fast model for breadth).\n # Blank = use the shared llm_model/llm_base_url. Generalizes the role-backend swap to per-role.\n researcher_model: str | None = None\n developer_model: str | None = None\n researcher_base_url: str | None = None\n developer_base_url: str | None = None\n # Per-role sampling temperature (§4.1): the Researcher wants breadth (higher temp = more diverse\n # ideas), the Developer wants determinism (lower temp = fewer syntax slips), the Strategist a\n # steady hand. Each overrides the shared `llm_temperature` for that role only; None = use the\n # shared value (byte-identical to before). Flat + nullable, mirroring the per-role model fields.\n researcher_temperature: float | None = None\n developer_temperature: float | None = None\n strategist_temperature: float | None = None\n # Unified self-driving agent: one LLM identity that plays Researcher + Developer (+ Strategist)\n # across pipeline stages, choosing its own model/toolset per stage. ON by default — the agent\n # drives the loop (incl. crash triage: repair/abandon/reject_idea) and, via\n # `agent_drives_actions`, picks the next macro action within a pure legal-action gate. Set both\n # to False for the legacy byte-identical split-role behavior. (No-op unless backend=\"llm\".)\n unified_agent: bool = True\n agent_drives_actions: bool = True\n # Layer 3 Card queue ownership. False preserves the existing policy/unified-pilot action path.\n # True is pinned in run_started because changing the selector on resume would mix two search\n # treatments in one run. When both this and agent_drives_actions are true, Card selection wins.\n card_driven_selection: bool = False\n # Layer 5 bounded speculative Card buffer. Zero is a hard OFF switch and preserves the\n # historical alternating build/eval spine; positive values are pinned by run_started so a\n # resume cannot silently mix search treatments after a snapshot/default edit.\n speculation_depth: int = Field(default=0, ge=0, le=64)\n # Local evidence receipt produced by ``looplab speculation-gate``. This is deliberately absent\n # from the curated Settings UI: positive-depth execution is a calibrated rollout decision, not a\n # casual tuning knob. The Engine validates the receipt against the current scorer,\n # implementation, GPU inventory and raw paired-run evidence before it enables speculation.\n speculation_gate_receipt: str | None = Field(default=None, max_length=4096)\n # Per-stage model/endpoint overrides for the unified agent. Recognized keys (see\n # tasks.build_unified_agent): `propose` (researcher), `implement`/`repair` (developer),\n # `strategy`, `pilot`. Unlisted keys are ignored. Empty = the per-role researcher_*/developer_*\n # models, then the shared llm_model. Explicit map wins. No-op unless `unified_agent` is true.\n agent_stage_models: dict = {}\n agent_stage_base_urls: dict = {}\n llm_parser: str = \"tool_call\"\n # Reasoning/thinking toggle sent IN the request (providers differ — see `llm.reasoning_body`):\n # Qwen3 on vLLM/SGLang -> chat_template_kwargs.enable_thinking (bool)\n # OpenAI/Ollama-v1/DeepSeek -> reasoning_effort (low|medium|high|none)\n # `llm_reasoning`: \"\" = send nothing (server default — current behavior); off = actively disable;\n # on = enable at default depth; low|medium|high = enable at that effort.\n # Defaults to \"high\" so the agent reasons before proposing/repairing/triaging (better code, fewer\n # crashes); set \"\" to send nothing (server default) or \"off\" to disable. The model's chain-of-thought\n # is CAPTURED either way (reasoning_content field or inline <think>). NOTE: to get a SEPARATE\n # reasoning_content field from SGLang/vLLM, the server also needs `--reasoning-parser qwen3`.\n llm_reasoning: str = \"high\"\n llm_reasoning_style: str = \"auto\" # auto | qwen | effort | none — how to shape the request param\n llm_reasoning_extra: dict = {} # raw fields merged into the body (escape hatch, e.g. Anthropic thinking)\n # H1: drive structured calls with the endpoint's constrained decoding (vLLM/SGLang guided_json +\n # response_format json_schema) so weak models can't emit invalid JSON. Off for Ollama (default).\n llm_guided_json: bool = False\n # Stream every LLM request (SSE) and reassemble it, so the request `timeout` is an INTER-TOKEN\n # idle timeout rather than a whole-request deadline: a long-but-alive generation is never cut off\n # (tokens keep resetting the timer), while a genuinely STALLED endpoint (no token for `timeout` s)\n # is detected and retried on a fresh connection — the fix for silent multi-minute hangs. Set False\n # to use one blocking read (old per-op timeout semantics) if an endpoint streams badly.\n llm_stream: bool = True\n # LLM IDLE timeout (seconds). Stream mode: the INTER-TOKEN stall limit — a steady generation is\n # never cut off, only a silent endpoint. Non-stream: bounds the whole read. Raise for endpoints\n # with a long prefill on huge prompts; lower to fail fast on a flaky shared endpoint.\n llm_timeout: float = Field(default=180.0, gt=0)\n # First-byte (response-headers) window for STREAM attempts, seconds: an SSE response sends its\n # headers on admission, so no-headers ≈ a black-holed request — fail over to a fresh connection\n # fast instead of waiting the full idle timeout. Clamped to llm_timeout. Non-stream attempts are\n # NOT bounded by this (their headers arrive only after the whole generation). The default mirrors\n # `DEFAULT_HEADER_TIMEOUT_S` in looplab/core/llm.py is the single source (imported above).\n llm_header_timeout: float = Field(default=DEFAULT_HEADER_TIMEOUT_S, gt=0)\n # Honour HTTP(S)_PROXY / NO_PROXY / SSL_CERT_FILE env vars for LLM calls. Default False: the\n # transport connects DIRECTLY (many internal/OLAP endpoints are reachable only by bypassing an\n # ambient proxy — a picked-up proxy yields \"connection refused\"). Set true when the endpoint is\n # reachable ONLY via a corporate proxy or needs a custom CA bundle from the environment.\n llm_trust_env: bool = False\n # H4/C2: compact the growing agentic tool-call history once it exceeds this many chars (auto_summary\n # LLM-summarizes the stale middle; else middle-truncate). Sized to the model's real context window so\n # files read earlier STAY in context instead of being compacted away and re-read: ~1,000,000 chars ≈\n # 250k tokens (≈4 chars/token), matching the configured deepseek-v4-flash (≥200k tokens confirmed).\n # The old 120k-char (~30k-token) default was ~8× too aggressive and drove a read-thrash loop. 0 = off.\n context_budget_chars: int = 1_000_000\n # M5: the Researcher's always-on experiments digest budget (chars). 0 = AUTO — scales with the\n # run size (60 chars/node, bounded [1200, 6000]) instead of one flat cap for an 8-node toy run\n # and a 200-node benchmark run alike. Set a positive value to pin it.\n digest_char_cap: int = Field(default=0, ge=0)\n # D11 compression model slot (open_deep_research's four-slot pattern): a dedicated CHEAP model\n # for agent-history summarization (C2 auto-summary), so compression doesn't pay the main\n # model's price. Blank = use each loop's own model (legacy). `compressor_base_url` blank =\n # the shared llm_base_url.\n compressor_model: str | None = None\n compressor_base_url: str | None = None\n # === Agentic tool-loop ================================================================\n # Agentic tool-loop limits — apply to EVERY tool-using agent (the LLM Researcher, the unified\n # agent's pilot + crash-triage, Deep-Research, the run-chat Boss, the genesis repo-scout, and the\n # cross-run report synthesizer). The loop lets the model call read-only tools across turns before\n # emitting its structured result. Both default to UNLIMITED so the agent takes as many turns / as\n # long as it needs — it is never cut off mid-reasoning; set a positive value here (or per-run, or\n # in the UI settings) to bound latency/cost. These used to be hardcoded per call site (e.g. the\n # boss at max_turns=3 / 45s), which silently dropped a slow reasoning model to a no-op reply.\n agent_max_turns: int = 0 # max tool turns before the emit is forced (0 = unlimited)\n agent_time_budget_s: float = 0.0 # wall-clock ceiling across the loop's turns (0 = no cap)\n # G · Soft convergence SAFETY NET (not a research cap). Two thresholds on the agent's tool-turn count:\n # at `agent_emit_after` the loop NUDGES the agent once (\"you've investigated enough — emit now\"); at\n # `agent_emit_force` it FORCES the emit from what was gathered. Set GENEROUSLY — a focused researcher\n # may legitimately read many files/experiments before it can propose a grounded idea; these only catch\n # a genuine runaway that keeps issuing DIFFERENT calls forever (which stuck-detection, keyed on\n # repeats, misses). NB: the old 499-read runaway (node 63) was 79% REDUNDANT re-reads caused by a\n # broken read_file — fixing read_file (pagination) is the real cure; this is just insurance. 0 = off.\n agent_emit_after: int = 300\n agent_emit_force: int = 500\n # B1 · No-progress / stuck detection — the safety net that makes \"unlimited turns\" safe. The\n # turn/time ceilings above are only BACKSTOPS; this is what actually stops a runaway loop, on the\n # cheapest signal: when the model repeats the SAME tool call (or ping-pongs between two, or keeps\n # hitting the SAME error) with no progress, the loop forces the final emit and finishes. ON by\n # default; reading DIFFERENT files or running ONE long command never trips it. (OpenHands-style.)\n agent_stuck_detection: bool = True\n agent_stuck_repeat: int = 4 # identical calls in a row that count as \"stuck\" (>=2)\n agent_stuck_alternate: int = 4 # ping-pong cycles between two calls that count as \"stuck\" (>=2)\n # C1 · Self-plan (TodoWrite-style): expose an `update_plan` tool so a long-running agent keeps its\n # OWN working TODO and re-surfaces it every `agent_plan_reinject_every` turns — keeps the goal in\n # view across a long loop so the agent \"writes its own context\" (Claude Code style). ON by default.\n agent_self_plan: bool = True\n agent_plan_reinject_every: int = 5\n # C2 · Auto-summary (ON by default): when the tool-loop history grows long, LLM-summarize the\n # stale middle instead of only middle-truncating it. The trigger is `context_budget_chars` if set,\n # else a built-in high-water mark (context_budget.DEFAULT_SUMMARY_CHARS ≈ 120k chars) so short\n # loops are untouched. Falls back to truncation on any summarizer error; one extra model call per\n # over-budget turn.\n agent_auto_summary: bool = True\n # C4 · Repo-developer plan decomposition. A big feature attempted in ONE developer session made a\n # non-converging reasoning model loop indefinitely — it kept writing + exploring without ever\n # emitting `done` (10k+ LLM calls / hours; agentic stuck-detection can't catch VARIED exploration,\n # only literal repeats). Fix: the developer first proposes an ordered plan of ATOMIC steps; a\n # multi-step plan is executed step-by-step, each in a FRESH bounded session with only that step's\n # scope + the files accumulated so far, syntax-validated per write. Every session stays\n # small-context and convergent. ON by default for repo tasks (a 1-step plan == the old single pass).\n developer_plan_decompose: bool = True\n developer_plan_min_steps: int = 2 # a proposed plan with >= this many steps runs step-by-step\n developer_plan_max_steps: int = 8 # cap on plan length (a runaway planner can't spawn 100 steps)\n # Hard per-session backstop for the repo developer — the ONE write-heavy agent that can run away\n # even with stuck-detection on (varied writes/reads never trip the repeat/ping-pong signal). Bounds\n # the plan phase, EACH step, and the single-session fallback. Unlike the global agent_* limits\n # (0 = unlimited, meant for read-only agents), the developer always gets a FINITE ceiling so a\n # model that never emits `done` fails cleanly with the code it wrote so far, instead of looping.\n developer_session_max_turns: int = 500\n developer_session_time_budget_s: float = 1200.0 # 20 min wall-clock per developer session\n # Phase-handoff summaries. Each LLM phase in a node build (Researcher·propose → Developer·stages →\n # plan → implement) ends with ONE extra LLM call that distills its transcript — the repo structure\n # it mapped, files/data confirmed, decisions made — into a brief injected into the NEXT phase (even\n # across the role boundary). The next phase TRUSTS that instead of re-reading the same files, which\n # cuts the biggest source of tool-call thrash (stages/plan/implement each re-exploring the repo).\n # One summary call per phase in exchange for many fewer read calls downstream. ON by default.\n phase_handoff_summary: bool = True\n # Observability (ADR-17): capture each LLM call's full prompt + completion into the active\n # span (spans.jsonl) so the UI can show exactly what the model read and wrote per node.\n # Diagnostics only — `replay.fold` never reads spans. Default on for local single-user; set\n # LOOPLAB_TRACE_LLM_IO=0 to suppress (e.g. to keep spans.jsonl small or avoid storing prompts).\n trace_llm_io: bool = True\n # Run-introspection tools (ON by default): make the LLM Researcher (and DeepResearcher) a\n # tool-using agent that can read its OWN experiments (list/read/find-analogous/themes/code) and\n # the task data (schema/profile/asset) mid-loop, instead of seeing only best+parent. Advisory —\n # never changes best-selection. Off = the legacy single-shot Researcher (richer digest still added).\n researcher_tools: bool = True\n # Cross-run introspection: give the agentic Researcher / pilot read-only tools to look at\n # SIBLING runs (same task_id, same run-root) — list them, read an experiment / its code, and\n # find analogous configs across runs — so a run can build on what neighbouring runs already\n # learned instead of rediscovering it. Advisory; never changes best-selection. Needs the run's\n # own dir wired through (no-op for unit-built roles). Off = the legacy single-run view only.\n cross_run_tools: bool = True\n # Read-only access to EVERY run on this machine ACROSS ALL TASKS (not just same-task siblings):\n # the Developer/Researcher get list_all_runs + read_run_code + read_run_experiment so they can read\n # the code + result of ANY past experiment anywhere and reuse an approach. Broader than\n # `cross_run_tools` (same-task only); the agent decides when a foreign run is relevant. Advisory;\n # never changes best-selection. Needs the run's own dir wired through (no-op for unit-built roles).\n all_runs_tools: bool = True\n # PART V §22 — read-only CROSS-RUN KNOWLEDGE tool for the reasoning roles (Researcher, Strategist,\n # deep-research, and a Developer-scoped variant). Adds `cross_run_prior_attempts` / `cross_run_claims`\n # / `cross_run_atlas` over the §21.20 read-models (concept overview + claim assessments + atlas), read\n # from `memory_dir` (lessons.jsonl + concept_capsules.jsonl). Read-only is a storage-authority\n # boundary, not a behavioral no-op: retrieved content can steer later proposals, and a tool-using\n # reasoning loop can consume additional model calls. The tools never mutate cross-run truth (facts\n # are engine-written, verdicts operator-ratified, §22.4) or directly select a winner. ON by explicit\n # experimental product choice, EngineOptions off; stores only exist once Part-IV features have run.\n cross_run_read_tools: bool = True\n # Agentic retrieval (ADR-16): if set, the LLM Researcher gets grep/kb_search/read\n # tools over this directory of markdown notes and chooses when to use them.\n knowledge_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"knowledge\"))\n # T4 real embeddings: model id for an OpenAI-compatible `/embeddings` endpoint used for\n # kb_search / case retrieval. Blank (default) = the dependency-free lexical `hash_embed` (today's\n # behavior). Set e.g. \"nomic-embed-text\" (Ollama) or \"text-embedding-3-small\" for SEMANTIC\n # retrieval; a misconfigured/offline endpoint degrades back to hash_embed at call time (never\n # crashes a run). `embed_base_url` blank = reuse `llm_base_url`; the shared `llm_api_key` is used.\n embed_model: str | None = None\n embed_base_url: str | None = None\n # Harmonic memory (Memora, ICML'26 — idea import). When ON, cross-run cases and knowledge notes are\n # indexed by a short ABSTRACTION + cue ANCHORS instead of their raw text, near-duplicate memories are\n # CONSOLIDATED into one entry under a matching abstraction, and `kb_search`/case retrieval EXPAND\n # through the top hits' anchors to surface related-but-not-similar memories. ON by default. Set\n # `memora=false` to restore the pre-Memora raw-text index. Never a source of truth — abstractions\n # live only in the derived, rebuildable retrieval index (never the event log or canonical cases.jsonl).\n memora: bool = True\n # Write abstractions with the wired chat model (richer than the deterministic lexical fallback). ON\n # by default; results are CACHED by content hash (see `memora_cache`) so a re-built index doesn't\n # re-call the model on unchanged notes/cases, and any endpoint failure degrades to lexical at call\n # time — so an offline box just gets lexical abstractions, never a crash. Set false to force lexical.\n memora_llm: bool = True\n # === Cross-run memory / Memora ========================================================\n memora_anchors: int = 6 # max cue anchors kept per memory\n # Cosine similarity at/above which a new case is CONSOLIDATED into an existing one (same evolving\n # topic) rather than stored as a separate entry. Only used when `memora` is on.\n memora_consolidate_threshold: float = 0.86\n # Where the LLM-abstraction cache lives (JSON). Blank (default) derives it from `memory_dir`\n # (`<memory_dir>/memora_cache.json`) or else `knowledge_dir` (`<knowledge_dir>/.memora_cache.json`);\n # with neither set the cache is in-memory only (per process). No-op unless `memora_llm` is on.\n memora_cache: str | None = None\n # E3 literature-grounded ideation (network-OPTIONAL): give the agentic Researcher an arXiv search\n # tool to ground ideas in real techniques. Off by default (egress is unreliable on some boxes);\n # fails gracefully if the network is blocked.\n literature_search: bool = False\n # Deep-Research stage (network-OPTIONAL): give the DeepResearcher a general web search/fetch\n # tool (DuckDuckGo) on top of arXiv to read across results + the web before steering the next\n # batch. Off by default (egress is unreliable on some boxes); fails gracefully when blocked.\n web_search: bool = False\n # Cadence for the Deep-Research stage: run it automatically every N created nodes. Zero disables\n # only the cadence; manual `deep_research` and Strategist `request_research` triggers still fire.\n # Default 3: deep research analyzes the accumulating results and steers the next batch (its\n # directions become hints + open hypotheses); paired with `concurrent_research` it overlaps the\n # think with the GPU-bound eval, so the cadence costs LLM tokens but no wall-clock on the search.\n deep_research_every: int = 3\n # Overlap a DUE deep-research \"think\" with the GPU-bound eval instead of running it in its own\n # serial step (the agent is otherwise idle while a node trains). research() computes from a state\n # snapshot; CODEX AGENT: the joined background task records only allowlisted, order-tolerant\n # research/hint/hypothesis events as soon as the memo finishes. ON by default: the LLM is typically\n # remote (no GPU contention with eval), so overlapping hides latency behind long training.\n concurrent_research: bool = True\n # Concurrent-research REPEAT — don't idle a multi-DAY eval. The overlapped \"think\" fires ONCE per\n # window by default; ON here, it RE-RUNS on an adaptive time cadence for the WHOLE eval window\n # (a two-day training must not leave the reasoning agents idle after one memo). Self-paced: it\n # only records a memo whose CONTENT is new (identical re-runs are skipped, so the log/board don't\n # bloat) and backs off geometrically as the analysis converges, capped so it always re-checks.\n # The appended records never rewrite the current metric champion, but their hints/open hypotheses\n # deliberately steer later proposals and replay reconstructs that advice. The library default is\n # one-shot (== today, byte-identical); this product default turns the repetition on.\n concurrent_research_repeat: bool = True\n # Base cadence (seconds) between repeated concurrent-research passes. Research is EXPENSIVE\n # (multi-turn LLM + web/arXiv), so this is a FLOOR, not a ceiling: the effective cadence is\n # max(this, ~5% of the per-experiment time budget) — a two-day eval is re-researched about hourly,\n # a short eval not at all (the first tick outlasts it). Only matters when the repeat is on.\n concurrent_research_interval_s: float = Field(default=1800.0, gt=0)\n # Per-eval-window backstop on repeated-research LLM calls (0 = cadence-only, no cap). The adaptive\n # cadence + convergence backoff are the primary budget control; this bounds a pathological\n # never-converging window. Past it the loop stops calling the LLM (the health monitor still runs).\n concurrent_research_max_calls: int = Field(default=40, ge=0)\n # Concurrent hypothesis-board CONSOLIDATION during the eval window. Repeated research keeps ADDING\n # near-duplicate directions to the open board (each memo registers up to 5 as open hypotheses), so a\n # long eval's board bloats before the between-nodes merge runs. This dedups/merges it on the SAME\n # background loop so the board stays clean for the next proposal. In Card-driven mode the same merge\n # changes ownership/readiness, so it is deferred to the joined main-task cadence instead. Needs the\n # reflect client + `track_hypotheses`.\n # Only active while `concurrent_research` runs the overlap loop. Library default off (== today).\n concurrent_consolidate: bool = True\n # Cadence for the agent-authored run report: regenerate the conclusion-first narrative every N\n # created nodes (0 = off; it still regenerates on a manual `report_refresh` from the UI). The\n # deterministic report always renders from the node set regardless of this knob.\n report_every: int = 3\n # Agent Skills (I18, ADR-9): dir of SKILL.md the Researcher can list/load as tools.\n skills_dir: str | None = None\n # Prompt store (I18, ADR-8): dir of editable, hot-reloaded role prompt .md files.\n prompt_dir: str | None = None\n # API key as a reference, never serialized as a value (ADR-11). Local servers\n # ignore it; default to a placeholder.\n llm_api_key: SecretStr | None = None\n\n @model_validator(mode=\"before\")\n @classmethod\n def _apply_profile(cls, data):\n \"\"\"Expand `profile` into a bundle of setting defaults BEFORE validation.\n\n pydantic-settings hands this validator the fully-merged input from ALL explicit sources\n (init kwargs + LOOPLAB_* env + .env), with fields the user left unset simply ABSENT (they\n fall to field defaults afterward). So a profile value is applied only when its key is not\n already present — i.e. any explicit file/CLI/env setting wins over the profile, and the\n profile wins over the bare field default. This makes `thorough` a true convenience preset,\n never an override of what the user asked for.\"\"\"\n if not isinstance(data, dict):\n return data\n prof = data.get(\"profile\", \"default\")\n # env may deliver the key as \"profile\" (env_prefix stripped) — normalize to a str\n prof = str(prof).strip().lower() if prof is not None else \"default\"\n overrides = PROFILES.get(prof)\n if overrides is None:\n raise ValueError(f\"unknown profile {prof!r}; known: {sorted(PROFILES)}\")\n for key, val in overrides.items():\n if key not in data: # explicit source (file/CLI/env) always wins\n data[key] = val\n return data\n\n @model_validator(mode=\"after\")\n def _check_trust_gate(self):\n if self.trust_gate not in (\"audit\", \"gate\", \"block\"):\n raise ValueError(\n f\"trust_gate must be audit|gate|block, got {self.trust_gate!r}\")\n if self.merge_mode not in (\"auto\", \"mean\", \"ensemble\"):\n raise ValueError(\n f\"merge_mode must be auto|mean|ensemble, got {self.merge_mode!r}\")\n # novelty_mode drives the dedup/novelty gate; an out-of-set value (e.g. a mis-cased\n # LOOPLAB_NOVELTY_MODE=LLM, or \"on\") used to fall through _apply_novelty_gate silently as a\n # NO-OP — turning the gate off with no diagnostic. Fail loudly at config time like trust_gate/\n # merge_mode do. (Other enum-ish string fields — seed_mode/eval_trust_mode/strategist_backend —\n # share the pattern; left unvalidated here only because their full value sets are less settled.)\n if self.novelty_mode not in (\"off\", \"algo\", \"llm\"):\n raise ValueError(\n f\"novelty_mode must be off|algo|llm, got {self.novelty_mode!r}\")\n # The remaining enum-ish string fields (arch-review §5 P3): a typo used to be accepted at\n # construction and only fail-safe/later-loud downstream (e.g. a mis-cased strategist_backend\n # silently ran the default). Fail loudly here like the fields above.\n if self.strategist_backend not in (\"off\", \"rule\", \"llm\", \"agent\"):\n raise ValueError(\n f\"strategist_backend must be off|rule|llm|agent, got {self.strategist_backend!r}\")\n if self.eval_trust_mode not in (\"ratify_freeze\", \"autonomous\", \"ratify_freeze_drift\"):\n raise ValueError(\n \"eval_trust_mode must be ratify_freeze|autonomous|ratify_freeze_drift, \"\n f\"got {self.eval_trust_mode!r}\")\n if self.seed_mode not in (\"auto\", \"tracked\", \"all\"):\n raise ValueError(f\"seed_mode must be auto|tracked|all, got {self.seed_mode!r}\")\n return self\n\n def masked_snapshot(self) -> dict:\n # Snapshots are a JSON contract (they are written verbatim to config.snapshot.json), not a\n # Python-object dump. In particular tuple-valued schema fields such as\n # ``inline_repair_reasons`` must become JSON arrays before callers hash the runtime envelope;\n # otherwise the strict speculation-scope canonicalizer sees a different/non-JSON preimage\n # from the one that ``json.dumps`` eventually persists.\n d = self.model_dump(mode=\"json\")\n if self.llm_api_key is not None:\n d[\"llm_api_key\"] = \"***\"\n else:\n d[\"llm_api_key\"] = None\n return d\n\n\n# Pre-versioned snapshots are full Settings dumps, so absence is a reliable per-field version marker.\n# Keep their historical effective behavior when newer product defaults become active: re-entry must not\n# silently add paid calls, interventions, concurrency or a different selection policy to an old run.\nLEGACY_CONFIG_SNAPSHOT_DEFAULTS: dict[str, object] = {\n \"parallel_build\": 1,\n \"eval_parallel\": None,\n \"llm_parallel\": None,\n \"train_monitor\": False,\n \"train_monitor_interval_s\": 600.0,\n \"train_monitor_kill\": False,\n \"train_monitor_kill_confidence\": 0.8,\n \"asha_live\": False,\n \"asha_live_kill\": False,\n \"asha_live_quantile\": 0.5,\n \"asha_live_min_siblings\": 3,\n # Before this operator ceiling existed, the sandbox's 24-hour process limit was authoritative.\n \"max_eval_timeout\": 24 * 3600.0,\n \"watchdog_reflection\": False,\n \"card_driven_selection\": False,\n \"speculation_depth\": 0,\n \"speculation_gate_receipt\": None,\n \"concurrent_research_repeat\": False,\n \"concurrent_research_interval_s\": 1800.0,\n # Zero means unlimited; 40 was the first bounded default, and is latent while repeat is disabled.\n \"concurrent_research_max_calls\": 40,\n \"concurrent_consolidate\": False,\n}\n\n\ndef migrate_config_snapshot(data: dict) -> dict:\n \"\"\"Return an effective copy of one legacy/current snapshot without rewriting evidence bytes.\"\"\"\n if not isinstance(data, dict):\n raise TypeError(\"config snapshot must be a JSON object\")\n migrated = dict(data)\n for key, value in LEGACY_CONFIG_SNAPSHOT_DEFAULTS.items():\n migrated.setdefault(key, value)\n return migrated\n\n\ndef settings_from_snapshot(data: dict) -> Settings:\n \"\"\"Build re-entry Settings from a masked snapshot under its historical missing-field contract.\"\"\"\n migrated = migrate_config_snapshot(data)\n migrated.pop(\"llm_api_key\", None)\n return Settings(**migrated)\n"}, "daa0963bf01b6e2b84a9b00b3c71d7d817fd194d": {"t": 1785768773.6092494, "n": 97539, "h": "f94fe0708d2b34f2", "c": "\"\"\"Engine config: the schema (what settings exist). (I0, ADR-11)\n\nOne pydantic-settings class declaring every engine knob, its default, and its docs. The loader —\nhow a run file / CLI / env becomes a `Settings` — is `looplab.core.appconfig`, which also owns\nthe config-precedence order. A resolved, secret-masked snapshot is written next to each run for\nreproducibility. (No real secrets in P0, but the masking discipline is in place.)\n\"\"\"\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nfrom pydantic import Field, SecretStr, field_validator, model_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n# Single source of the LLM first-byte (response-headers) default — see core/llm.py.\nfrom looplab.core.llm import DEFAULT_HEADER_TIMEOUT_S\n\n# Default home for cross-run memory + the knowledge base (shared across every run). Both are ON by\n# default now (a user asked for it) so a fresh install accumulates + consults knowledge with no setup;\n# set the env var to \"\" to disable, or to a path to relocate. `~/.looplab/` keeps them out of any one\n# project + shared across projects.\n_LL_HOME = Path.home() / \".looplab\"\n\n# Autonomous roles that may be granted per-setting write access (see Settings.agent_control).\n# strategist = A7 meta-controller (run-wide); boss = run-chat operator-proxy (run-wide);\n# researcher = per-experiment proposer (per-node sizing, e.g. eval timeout for a heavy model).\nAGENT_ROLES = (\"strategist\", \"boss\", \"researcher\")\n\n# A foresight score is computed for every acted-on proposal. Keep this lower than the generic\n# verifier's emergency ceiling so a typo cannot multiply paid synchronous calls across a run.\nMAX_FORESIGHT_VERIFY_SAMPLES = 8\n\n\n# Layer-2 parallelism renamed two public settings without removing the durable legacy fields. Keep\n# the equivalence in one place: config-source precedence, governance migration, and live strategy\n# application must all agree which names describe the same authority/value.\nPARALLELISM_ALIASES: dict[str, str] = {\n \"max_parallel\": \"eval_parallel\",\n \"parallel_build\": \"llm_parallel\",\n}\n\n\ndef parallelism_aliases(setting: str) -> tuple[str, ...]:\n \"\"\"Return every spelling in ``setting``'s alias family, canonical name first.\"\"\"\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if setting in (legacy, canonical):\n return canonical, legacy\n return (setting,)\n\n\ndef canonicalize_parallelism_source(values: dict) -> dict:\n \"\"\"Promote legacy parallel keys *within one config-precedence source*.\n\n Applying this only after file/CLI/env were flattened would let a lower-priority canonical env\n value shadow a higher-priority legacy file value (or a file canonical value shadow a CLI legacy\n override), so callers with sub-layers normalize each layer independently.\n \"\"\"\n if not isinstance(values, dict):\n return values\n out = dict(values)\n for legacy, canonical in PARALLELISM_ALIASES.items():\n # CODEX AGENT: None is the durable legacy mode for llm_parallel, not a missing value. A normal\n # snapshot contains llm_parallel:null plus parallel_build:1; reloading it here promotes null to\n # 1 and unexpectedly enables the global one-call broker. Promote aliases only when the\n # canonical key is absent, and pin snapshot round-trip parity.\n if canonical not in out and out.get(legacy) is not None:\n # CODEX AGENT: canonicalize inside each precedence layer, never across the flattened\n # result. The alias migration must not invert CLI > file > env precedence.\n # CODEX AGENT: promoting a legacy-ONLY parallel_build layer (e.g. just\n # LOOPLAB_PARALLEL_BUILD=1 in env) into canonical llm_parallel ALSO flips on the finite\n # shared LLM broker: llm_parallel=1 -> orchestrator._startup_llm_total=1, serializing\n # every provider call in the run (research overlap, novelty, tag_nodes_llm, monitors) —\n # contradicting llm_broker.py's docstring, orchestrator's \"legacy-only preserves unbounded\n # overlap\" note, the broker-neutral live-control path, and the configuration.md\n # llm_parallel row (\"falls back ... without enabling a shared total\"). Promote only the\n # build width (keep broker opt-in tied to an explicitly-spelled canonical value), or fix\n # all four contracts and pin the env/file semantics with a test.\n out[canonical] = out[legacy]\n return out\n\n# Settings whose semantics are committed by ``run_started`` and restored from the folded event log\n# on every re-entry. Changing one only in ``config.snapshot.json`` would mix incompatible evidence\n# (a different holdout split) or make the live producer disagree with replay's selection policy.\n# The HTTP config editor and the engine both consume this one contract; keep deliberately mutable\n# controls such as ``trust_gate`` out (that field has its own durable change event).\nRUN_START_PINNED_FIELDS = frozenset({\n \"card_driven_selection\",\n \"speculation_depth\",\n \"holdout_fraction\",\n \"holdout_select\",\n \"select_verifier\",\n \"select_verifier_samples\",\n \"verifier_ci_tie\",\n})\n\n\ndef run_start_pinned_settings(state) -> dict:\n \"\"\"Return the effective Settings names committed by a folded run-start record.\n\n Pre-pin legacy logs have no recorded holdout fraction, so their snapshot remains authoritative\n for both holdout knobs. Verifier fields, however, have always been re-pinned from the legacy-safe\n fold defaults whenever a valid ``run_started`` identity exists.\n \"\"\"\n if not getattr(state, \"run_id\", \"\"):\n return {}\n values = {\n # Layer 3 changes which queue owns macro-action selection. Once a run starts, replay and\n # resume must keep the same owner even if the snapshot or ambient default changes later.\n \"card_driven_selection\": bool(getattr(state, \"card_driven_selection\", False)),\n # Layer 5 overlaps Card production with evaluation only when this pinned treatment is\n # positive. Old logs omit it and therefore retain the exact serial build/eval spine.\n \"speculation_depth\": int(getattr(state, \"speculation_depth\", 0)),\n \"select_verifier\": bool(getattr(state, \"select_verifier_tiebreak\", False)),\n \"select_verifier_samples\": int(getattr(state, \"select_verifier_samples\", 3)),\n \"verifier_ci_tie\": bool(getattr(state, \"verifier_ci_tie\", False)),\n }\n holdout_fraction = getattr(state, \"holdout_fraction\", None)\n if holdout_fraction is not None:\n values.update({\n \"holdout_fraction\": holdout_fraction,\n \"holdout_select\": bool(getattr(state, \"holdout_select\", False)),\n })\n if not values.keys() <= RUN_START_PINNED_FIELDS:\n raise RuntimeError(\"folded run-start pinned settings contract drifted\")\n return values\n\n# --- Run profiles (config-first presets) -------------------------------------------------------\n# A `profile` is a NAMED BUNDLE of setting defaults — nothing more. It only fills fields the user\n# did NOT set explicitly (via file / --set / LOOPLAB_* env / init-kwargs), so any explicit knob\n# always wins (see `_apply_profile`). Most engine intelligence lives behind individual flags. The\n# PART IV/V cross-run + concept-graph machinery (concept_pivot, graded_novelty, cross_run_concepts /\n# _advisory / _structured_claims / _curation / _read_tools, fingerprint_universal, foresight_verify)\n# now ships ON in the product `Settings` default. That is an explicit experimental product choice,\n# not a claim that a frozen A/B or workload-wide cost/quality gate has cleared: the bundle can steer\n# prompts and candidate generation, change graded-novelty admission / pre-execution choices, and add\n# paid LLM work when its client and memory prerequisites are present. The bare-library `EngineOptions`\n# default stays lean (see tests/test_options_divergence.py — product side is the aggressive one) so a\n# toy `Engine(...)` in a test doesn't fire cross-run LLM work unasked. The trust/confirm/ablate QUALITY\n# bundle still ships OFF and is turned on by `thorough`. Every value here is reachable by hand — the\n# profile is a convenience, never a hidden mode.\n# CODEX AGENT: keep this rationale behavioral and evidence-based; do not relabel read-only storage\n# access or proposal-only governance as a no-op for agent behavior, cost, or proposal admission.\n#\n# `thorough` deliberately touches only QUALITY/TRUST machinery, not spend: it does NOT raise\n# max_nodes / eval_parallel (those are cost knobs the user owns). It enables multi-seed confirmation\n# (demote seed-lucky leaders), the reward-hack / leakage / critic monitors AND flips them from\n# advisory to *gating* (`trust_gate=\"gate\"` — a\n# flagged win can no longer be selected as best), ablation-driven refinement, and the prompt cues\n# (complexity / budget / failure-reflection / watchdog-reflection). Agentic novelty and the experimental\n# Part IV/V bundle are already product defaults, not profile changes. Effect is large; marginal cost is a\n# few extra evals.\nPROFILES: dict[str, dict] = {\n \"default\": {}, # explicit no-op alias for \"ship-as-is\" (== omitting profile)\n \"fast\": {}, # today's lean defaults, named for symmetry with `thorough`\n \"thorough\": {\n \"confirm_top_k\": 3,\n \"confirm_seeds\": 3,\n # novelty_gate intentionally NOT enabled here: duplicate-detection is the agentic Researcher's\n # job (it reads past experiments + find_analogous_across_runs and DECIDES), not an algorithmic\n # param-distance / embedding auto-reject that mis-fired (it nudged a good fresh idea onto a\n # twice-dead lineage — live node 61). Search stays as tools; the LLM judges novelty.\n \"operator_bandit\": True, # P4: adaptive operator mix over folded yields (GreedyTree)\n \"reward_hack_detect\": True,\n \"code_leakage_detect\": True,\n \"critic_check\": True,\n \"trust_gate\": \"gate\", # detectors stop gating a flagged node from WINNING (not just audit)\n \"ablate_every\": 3,\n \"complexity_cue\": True,\n \"budget_aware\": True,\n \"failure_reflection\": True,\n \"watchdog_reflection\": True, # feed recent live-watchdog (train-monitor/ASHA) flags to proposals\n \"reflection_priors\": True, # no-op unless memory_dir is set (cross-run priors)\n },\n}\n\n\n# The shipped agent-governance matrix (who may change which setting at runtime). Defined as a module\n# constant so the Engine's direct-construction default matches this shipped default exactly (the\n# EngineOptions \"Engine() == shipped defaults\" invariant) instead of diverging to an all-locked map.\nDEFAULT_AGENT_CONTROL: dict[str, list[str]] = {\n \"timeout\": [\"researcher\", \"strategist\"],\n # Layer-2 canonical names are the presented/default governance surface. `_agent_may` falls back\n # to an old snapshot's legacy grant only when its canonical counterpart is absent, so removing\n # duplicate defaults preserves resume authority while an explicit canonical revocation still wins.\n \"eval_parallel\": [\"strategist\"],\n \"llm_parallel\": [\"strategist\"],\n # Strategy-only pseudo-setting: the closed per-lane allocation inside the canonical LLM total.\n # It is not a launch Settings field; operators pin it through `set_strategy` and the Strategist\n # may reallocate it only while this grant is present.\n \"llm_lane_limits\": [\"strategist\"],\n \"max_nodes\": [\"strategist\", \"boss\"],\n \"max_eval_seconds\": [\"strategist\", \"boss\"],\n \"policy\": [\"strategist\"],\n # The policy NAME and its PARAMS are gated independently in `_apply_strategy`; grant both by default\n # so the Strategist that may switch to (say) MCTS may also apply the `c`/`eta` it decided — else its\n # tuned params are silently dropped and the recorded `active_strategy` diverges from the live engine.\n \"policy_params\": [\"strategist\"],\n \"n_seeds\": [\"strategist\"],\n \"ablate_every\": [\"strategist\"],\n \"merge_mode\": [\"strategist\"],\n \"complexity_cue\": [\"strategist\"],\n \"ablate_code_blocks\": [\"strategist\"],\n \"prefer_sweep\": [\"strategist\"],\n \"novelty_stance\": [\"strategist\"],\n \"developer\": [\"strategist\"],\n \"fidelity\": [\"strategist\", \"researcher\"],\n}\n\n\ndef default_agent_control() -> dict[str, list[str]]:\n \"\"\"A FRESH deep-ish copy of the shipped governance matrix (each role list copied so a caller can\n mutate its own map without touching the module constant). The SINGLE constructor of the default,\n called by both `Settings.agent_control`'s default_factory AND the Engine's direct-construction\n default — so \"Engine() == shipped defaults\" can't drift if the copy depth ever needs to change\n (e.g. nested values requiring copy.deepcopy), instead of two verbatim copy expressions.\"\"\"\n return {k: list(v) for k, v in DEFAULT_AGENT_CONTROL.items()}\n\n\nclass Settings(BaseSettings):\n \"\"\"The engine settings schema (every knob a run accepts).\n\n Timeout family — six distinct knobs, each owned by a different subsystem:\n - `timeout`: per-eval wall-clock budget for ONE experiment's evaluation (engine/eval).\n - `max_eval_timeout`: hard ceiling for a governed per-node Researcher timeout override.\n - `llm_timeout`: LLM request idle timeout — inter-token stall limit in stream mode\n (core.llm OpenAICompatibleClient).\n - `llm_header_timeout`: LLM first-byte (response-headers) window for stream attempts (core.llm).\n - `agent_time_budget_s`: wall-clock ceiling across a tool-using agent loop's turns (agents).\n - `dep_install_timeout`: per-package budget for auto-installing a missing dep (env self-prep).\n\n Config-source precedence is owned by `looplab.core.appconfig` (the loader) — see its module\n docstring for the one canonical order.\n \"\"\"\n # Config precedence: see appconfig.py — the loader owns the full order. The `.env` is read\n # so `looplab run`/`looplab ui` pick up LOOPLAB_LLM_BASE_URL etc. without exporting them by hand\n # (keys MUST carry the LOOPLAB_ prefix, same as the env vars). utf-8-sig tolerates a BOM from\n # Windows editors. The test suite disables this (tests/conftest.py) so a dev's real .env in the\n # repo root can't leak into assertions.\n model_config = SettingsConfigDict(\n env_prefix=\"LOOPLAB_\",\n env_file=\".env\",\n env_file_encoding=\"utf-8-sig\",\n extra=\"ignore\",\n allow_inf_nan=False,\n )\n\n @classmethod\n def settings_customise_sources(\n cls, settings_cls, init_settings, env_settings, dotenv_settings,\n file_secret_settings):\n \"\"\"Preserve source precedence across canonical/legacy parallelism names.\n\n Pydantic ranks init > env > dotenv correctly, but it ranks field names rather than semantic\n alias families. Promoting a legacy value inside each source before the merge makes an old\n explicit snapshot/file value beat a lower-priority canonical env value. ``appconfig`` also\n promotes its file/typed/--set sub-layers before combining them into the init source.\n \"\"\"\n def _with_parallel_aliases(source):\n return lambda: canonicalize_parallelism_source(source())\n\n return (\n _with_parallel_aliases(init_settings),\n _with_parallel_aliases(env_settings),\n _with_parallel_aliases(dotenv_settings),\n _with_parallel_aliases(file_secret_settings),\n )\n\n # Run profile (config-first preset, see PROFILES above). \"default\"/\"fast\" = today's lean\n # defaults; \"thorough\" turns the built quality/trust machinery ON in one word. A profile only\n # fills fields the user did NOT set — every knob it touches stays individually overridable.\n profile: str = \"default\"\n\n # Bounds (review C/⚪): a non-positive node/seed budget or timeout is never valid. Upper bounds are\n # equally load-bearing — the UI /start path writes these straight into the engine's env, and an\n # unbounded `eval_parallel` (or legacy `max_parallel`; e.g. 100000 from a crafted preflight) would\n # fan out that many sandboxes → resource exhaustion. Reject at config time, not mid-run. Ceilings\n # are generous; tests top out near 50.\n n_seeds: int = Field(default=3, ge=1, le=1024)\n max_nodes: int = Field(default=8, ge=1, le=1_000_000)\n # CODEX AGENT: legacy evaluation-width input. `eval_parallel` below is canonical and wins when set;\n # keep this field for old env/config/snapshots and direct Engine call sites. A width > 1 admits\n # concurrent evals, whose resource reservations may be CPU-only or span one/more GPUs — there is no\n # one-node/one-GPU promise. `max_parallel=0` is launch-time AUTO (detected GPU count, at least 1).\n max_parallel: int = Field(default=1, ge=0, le=1024)\n # CODEX AGENT: legacy build-width input. `llm_parallel` below is canonical and also controls the\n # shared provider-call total when positive. A width > 1 uses fresh pooled researcher/developer pairs;\n # it says nothing about the later eval's CPU/GPU reservation. Startup `0` derives build fan-out from\n # resolved eval width; a live Strategist/operator zero settles to 1. Without role_factory it clamps to 1.\n parallel_build: int = Field(default=1, ge=0, le=64)\n # Hypothesis-card Kanban re-architecture (docs/23, Layer 2). Two INDEPENDENT concurrency axes,\n # decoupled from each other so cost (LLM work) can be tuned separately from throughput (GPU evals).\n # These are the CANONICAL names going forward; `max_parallel`/`parallel_build` above are kept as thin\n # back-compat aliases (env vars / snapshots / ~100 Engine(...) call sites depend on the exact names,\n # so they cannot be renamed — CLAUDE.md). Resolution (orchestrator.__init__): a NEW name, when set,\n # WINS over its legacy alias; None => fall back to the legacy field => byte-identical to today.\n # `eval_parallel`: concurrent EVALS (the GPU/experiment consumer). None -> max_parallel; 0 = AUTO\n # (one experiment per detected GPU). This is the in-Run eval-concurrency ceiling; the resource\n # scheduler bin-packs declared footprints separately. Sibling local GPU-owning Runs serialize\n # through the conservative host-pool lease; external execution needs its own admission boundary.\n # `llm_parallel`: total concurrent LLM provider calls + BUILD fan-out. None -> legacy\n # parallel_build semantics without a shared total; 0 = startup AUTO (= resolved eval_parallel\n # for build fan-out, historical research overlap unbounded). A positive canonical value enables\n # the named-lane broker the Strategist can reallocate at cadence.\n eval_parallel: int | None = Field(default=None, ge=0, le=1024)\n llm_parallel: int | None = Field(default=None, ge=0, le=64)\n # Training-log monitor (I-series watchdog family): a per-eval background observer that tails the live\n # training log while a (often multi-hour) declared stage runs in a worker thread. ON in the product\n # surface (Settings). Its alert event is fold-ignored and cannot directly change lifecycle/champion;\n # CODEX AGENT: with watchdog_reflection on, that alert is nevertheless advice in a later Researcher\n # prompt and can change future proposals. It only fires on the command-eval path with an LLM client;\n # bare-library EngineOptions stays OFF. See test_options_divergence.\n # `_interval_s` is the BASE tick cadence (the effective one adapts to the per-experiment budget).\n train_monitor: bool = True\n train_monitor_interval_s: float = Field(default=600.0, gt=0)\n # Phase 3 — INTERVENTION (opt-in, separate from observation): let the monitor tree-kill a training the\n # LLM judges 'broken' (diverged / silent CPU fallback / not learning) EARLY, instead of burning the\n # whole budget. Off by default — the observer only WATCHES unless this is on. A kill only fires on a\n # 'broken' verdict (a plateau is 'watch', never killed) with confidence >= the threshold; the node then\n # fails normally (reason='monitor_broken'), so replay reconstructs it from that one terminal event.\n train_monitor_kill: bool = False\n train_monitor_kill_confidence: float = Field(default=0.8, ge=0.0, le=1.0)\n # ASHA live-curve watchdog (sibling of the training monitor): reads the latest INTERMEDIATE value of\n # the objective metric off the live log (reusing the eval's OWN metric reader). Finished-endpoint rank\n # remains advisory. An opt-in KILL additionally requires an operator-declared metric.resource_key and\n # enough sibling observations at exactly that resource; absent comparable evidence cannot stop a run.\n # Records a fold-ignored `asha_rank` diagnostic + trace span; the library default is OFF (off == today).\n asha_live: bool = True\n # Opt-in INTERVENTION (separate from the advisory signal): let the watchdog tree-kill a node whose\n # intermediate metric stays below the SAME-RESOURCE bar past a short grace window. Off by default —\n # it only surfaces endpoint rank unless this is on. A kill fails normally (asha_underperforming).\n asha_live_kill: bool = False\n # The bar sits at `asha_live_quantile` along a WORST->BEST ordering of the finished siblings' finals:\n # 0.5 = the median (a node worse than the median peer flags); a SMALLER value is more conservative —\n # it lowers the bar toward the WORST finished peer, so only a node worse than nearly all peers flags\n # (0.0 = worse than the worst). `min_siblings` is the floor of finished peers required before it ranks\n # at all, so it never acts on too little evidence.\n asha_live_quantile: float = Field(default=0.5, ge=0.0, le=1.0)\n asha_live_min_siblings: int = Field(default=3, ge=1)\n timeout: float = Field(default=30.0, gt=0)\n # Hard ceiling for the Researcher's governed per-node `Idea.eval_timeout`. One hour preserves the\n # existing heavy/neural-net authoring example while staying well below the sandbox's defensive\n # 24-hour subprocess ceiling. This is operator-owned run config: agents may request less or more,\n # but the accepted action is always clamped here after the `agent_control.timeout` permission gate.\n max_eval_timeout: float = Field(default=3600.0, gt=0, le=24 * 3600.0)\n # Intra-node sweep: a sweep node runs a whole grid in one process, so it gets this multiple of\n # `timeout` as its wall-clock budget (solution.py path; RepoTasks use their per-profile timeout).\n sweep_timeout_mult: float = Field(default=8.0, ge=1)\n # Eval STALL watchdog CAP (seconds): a stage that is completely SILENT on stdout/stderr for this\n # long — while still alive and BELOW its wall-clock deadline — is tree-killed early with a STALLED\n # marker (a hung dataloader/deadlock dies in minutes instead of burning a multi-hour timeout). The\n # per-stage window is `min(this, the stage's own timeout)`, so a short scorer's deadline fires first.\n # `0` DISABLES the watchdog entirely (only the hard deadline applies) — for a legitimately quiet\n # non-Python stage (block-buffered stdout, a script that logs only to its own file). Threaded into\n # the eval and surfaced to the Developer so its code can emit periodic progress to stay alive.\n eval_stall_timeout_s: float = Field(default=1800.0, ge=0)\n # Sandbox tier (ADR-13): \"trusted_local\" (subprocess, no Docker) for the CLI;\n # \"untrusted\" (Docker --network none, shared-kernel runtime) for hosted/multi-tenant UI;\n # \"hostile\" (untrusted + a true-isolation OCI runtime, gVisor `runsc` by default / Kata) for\n # running untrusted code. See runtime/sandbox.py::make_sandbox for the tier selection.\n trust_mode: str = \"trusted_local\"\n # Image for the untrusted command-eval tier (RepoTask, Phase 4): the framework's deps\n # should be baked in (the container runs --network none, so a pip setup can't fetch).\n docker_image: str = \"python:3.12-slim\"\n # Resource caps for the untrusted/hostile Docker tier (passed to `docker run --memory/--cpus`).\n # The default 4g bound protects other tenants from an OOM, but a model-training MLE-bench eval\n # routinely needs more — raise it (e.g. \"16g\") for such runs. \"\" disables the cap (unbounded).\n # Ignored by the trusted_local (subprocess) tier.\n sandbox_memory: str = \"4g\"\n sandbox_cpus: str = \"\"\n # Best-effort host-OOM guard for the TRUSTED-LOCAL (subprocess) tier. When set (e.g. \"8g\"), each\n # eval child gets an RLIMIT_AS address-space cap so a runaway trainer hits MemoryError instead of\n # OOM-killing the whole host (and the engine). POSIX only; a no-op on Windows. Default \"\" = OFF:\n # RLIMIT_AS bounds VIRTUAL memory, and CUDA/torch reserve tens of GB of virtual without using it,\n # so a default cap would spuriously kill GPU evals — leave it off for those and use the Docker\n # tier's real --memory cgroup bound (sandbox_memory) instead. Ideal for CPU/numpy/sklearn runs.\n sandbox_memory_local: str = \"\"\n # Best-effort disk-fill guard for the TRUSTED-LOCAL tier (P1-5): an RLIMIT_FSIZE cap on the size of\n # any single file an eval child writes (e.g. \"2g\"), so a runaway that writes an unbounded file gets\n # SIGXFSZ instead of filling the host disk. POSIX only. Default \"\" = OFF (large model checkpoints\n # need big files); set it for tasks that write only small artifacts.\n sandbox_fsize_local: str = \"\"\n # Search policy (ADR-2): \"greedy\" | \"evolutionary\" | \"mcts\" | \"asha\".\n policy: str = \"greedy\"\n # Ablation-driven refinement (I7): every N improves, ablate the best to find the\n # highest-impact parameter and refine it. 0 = off. (GreedyTree only.)\n ablate_every: int = 0\n # A0a (MLE-STAR): when ablating, treat each generated pipeline *code block* (not just a\n # numeric param) as an ablation unit — neutralize a block, measure the metric delta, refine\n # the highest-impact block. Off => the classic numeric-param ablation. Config-first knob.\n ablate_code_blocks: bool = False\n # A0b: real merge/ensembling. \"mean\" = legacy mean-param merge; \"ensemble\" = the Developer\n # writes a code-recombination ensemble over the two parents' solutions (verified: agent-proposed\n # ensembling 37.9%->43.9%). \"auto\" (default) = ensemble whenever the Developer actually\n # generates code (LLM/agent backends; declared via `is_code_generating`), mean for the\n # templated/toy developers where a code ensemble is meaningless — the evidence says code\n # recombination is the strongest single merge (removing it costs ~9 pp), so it is the default\n # wherever it can work. Falls back to mean when the Developer can't ensemble.\n merge_mode: str = \"auto\"\n # A0d (AIRA): inject a dynamic complexity hint into the draft/improve prompt keyed on the\n # node's child count (few children -> keep minimal; many -> escalate to ensembling/HPO).\n complexity_cue: bool = False\n # I1 (CAAFE-style) LLM feature-engineering: instruct the proposer/developer to add\n # semantically-meaningful engineered features (ratios, interactions, aggregations) as code. The\n # existing cross-validation eval + best-selection is the CV GATE — a feature set that doesn't\n # improve CV is never selected (feature engineering is non-universal, so this gate is mandatory).\n # Tabular tasks (those exposing column data). Off by default.\n feature_engineering: bool = False\n # A5: surface the REMAINING eval-compute budget into the proposal prompt so the Researcher can\n # reason over how much compute is left (explore broad while flush, exploit/cheapen when low).\n # No-op unless a max_eval_seconds budget is set.\n budget_aware: bool = False\n # A4 (LATS-style): feed a summary of the most recent FAILED branches (operator + error reason)\n # back into the proposal prompt so the proposer reflects on and avoids repeating them. ON by\n # default: it is SELECTIVE by construction (injects only when recent failures exist — the\n # evidenced form of reflection; reflecting at every step shows no gains, arXiv:2506.12928).\n failure_reflection: bool = True\n # Feed the LIVE-WATCHDOG observations (train-monitor health verdicts + ASHA intermediate-rank flags)\n # of recent experiments back into the proposal prompt, so the Researcher avoids re-proposing a config\n # whose TRAINING was already observed to be weak — even when the watchdog kills are off (default) and\n # the node ran to completion (those diagnostics are fold-ignored, so failure_reflection never sees\n # them). ON by default and SELECTIVE like failure_reflection (injects only when a recent flag exists).\n watchdog_reflection: bool = True\n # C3 deep test-driven repair: hand the Developer the failure taxonomy + a structured \"reproduce\n # then fix\" directive on debug, not just the raw stderr tail. ON by default (product surface); the\n # conservative library default in EngineOptions stays off, per that module's contract.\n deep_repair: bool = True\n # === Inline crash repair ==============================================================\n # Hybrid in-node crash repair: when an LLM-generated node CRASHES at runtime (mechanical errors\n # — bad import, removed kwarg, typo), the agent triages it and may repair the code IN PLACE within\n # the same eval (cheap; does NOT consume max_nodes and does NOT add a node to the search tree).\n # Semantic failures still flow to a new idea/debug node via the policy. ON by default; set False\n # to restore the prior behavior (every crash waits for a budgeted inter-node debug node).\n inline_repair: bool = True\n # Max in-place repair attempts per node before it fails normally. Default 0 = UNLIMITED: the same\n # agent keeps fixing + re-evaluating IN THE SAME node until it produces a clean metric — no new\n # node just to retry. Runaway is prevented not by a count but by the anti-stuck guard\n # (`inline_repair_stuck_repeat`): when the SAME error signature repeats with no progress, or the\n # agent's crash-triage says \"abandon\", the node fails. Set a positive N to cap the retries instead.\n inline_repair_attempts: int = Field(default=0, ge=0)\n # Anti-stuck: abandon in-node repair when the SAME error recurs this many times in a row (no\n # progress). Keeps \"unlimited\" repair from looping forever on an unfixable error.\n inline_repair_stuck_repeat: int = Field(default=4, ge=2)\n # Which failure reasons (_failure_reason: crash|timeout|oom|setup|no_metric|drift) are eligible for\n # inline repair. Default: mechanical crashes, timeouts AND OOM-kills — a timeout/OOM means the code\n # was too slow / too memory-hungry for the budget (or the pod's cgroup limit), not that the idea is\n # wrong, so the agent repairs it by reducing compute/memory (fewer estimators/epochs/folds/seeds, a\n # lighter model, a smaller batch, subsampling) instead of letting the node die unrecovered. Env\n # override expects a JSON array.\n inline_repair_reasons: tuple[str, ...] = (\"crash\", \"timeout\", \"oom\")\n # Cost ceiling for in-node repair of a MULTI-STAGE eval: the max number of FULL pipeline re-runs\n # (i.e. re-trains from the first stage) the inline-repair loop may do before abandoning to the\n # inter-node debug operator. A late-stage failure (a broken `score`/eval script that didn't touch\n # `train`) REUSES the completed train checkpoint and re-runs only from the failed stage — that is\n # CHEAP and NOT counted here; only a repair that changes an EARLIER stage's code (train.py/loss.py/…)\n # forces a full re-train, which each costs the whole training time. Without this bound, a repair that\n # keeps changing training code could burn many full trains (the anti-stuck guard is error-signature\n # based, not cost based). 0 = unlimited (legacy behavior). Single-command evals ignore it.\n inline_repair_retrain_cap: int = Field(default=2, ge=0)\n # Environment self-prep: when a solution crashes purely because a KNOWN library isn't installed\n # (ModuleNotFoundError), the engine pip-installs it into the eval interpreter and re-runs — so a\n # torch/XGBoost/CatBoost idea (e.g. a GRU model) actually runs on a fresh box instead of being\n # thrown away as `idea_rejected`. Trusted_local tier only (the untrusted/hostile Docker tiers run\n # --network none). A typo'd or local helper module is NOT installed (that's a code bug to repair).\n auto_install_deps: bool = True\n # Per-package wall-clock budget for an auto-install (seconds). Generous: large wheels like torch\n # can take minutes to download/build.\n dep_install_timeout: float = Field(default=900.0, gt=0)\n # --- Agent governance: who may change a setting at runtime (per-setting allow-list of roles) ---\n # Roles: \"strategist\" (A7 meta-controller; run-wide), \"boss\" (run-chat operator-proxy; run-wide),\n # \"researcher\" (per-experiment proposer; PER-NODE — e.g. set a longer eval timeout for a neural-net\n # idea). A setting ABSENT from this map is LOCKED — no agent may change it, only a human via the\n # snapshot/UI. The sole conditional exception is Strategy-only `card_scoring`: Card mode grants\n # it implicitly without changing the default flag-off snapshot, while an explicit [] revokes it.\n # The gate is ENFORCED at every AGENT seam via `_agent_may` — the strategist's whole\n # control surface (policy/operators/novelty_stance/prefer_sweep/developer/fidelity/timeout/\n # eval_parallel/llm_parallel) is checked in `_apply_strategy`, so removing a role from a knob locks it,\n # not just greys out the UI pill. The boss/max_* grants document boss-relevant knobs, but a\n # `budget_extend` is a HUMAN control intent (the boss action-builder can only emit `add_nodes`), so\n # it is applied as-is — the human always wins via the UI/snapshot. Defaults are conservative: resource/\n # search-shape knobs the agents already reason about; provider infra (llm_model/base_url/api_key,\n # docker_image) stays locked. The UI shows S/B/R pills per setting. `timeout` granted to the\n # researcher IS the \"auto\" per-node mode — the researcher sizes each experiment's wall-clock\n # (Idea.eval_timeout); the config `timeout` is the fallback for nodes it doesn't size. Env override\n # expects a JSON object. (`fidelity`/`novelty_stance`/`prefer_sweep`/`developer` are governance keys\n # for the strategist's per-node dials — not 1:1 Settings fields, but gated the same way.) The default\n # lives in the module-level DEFAULT_AGENT_CONTROL so the Engine's direct-construction default matches.\n agent_control: dict[str, list[str]] = Field(default_factory=default_agent_control)\n # C1 (Agentless localization): for repo tasks, rank the source files most relevant to the most\n # recent failure (traceback + identifiers) and surface them in the proposal/repair prompt so the\n # Developer edits the right place. Off by default; repo tasks only.\n localize_faults: bool = False\n # A2 surrogate-guided proposal (BO-lite): fit a k-NN surrogate over (params->metric) and propose\n # by acquisition instead of random/hill-climb. Numeric-bounds tasks only; bootstraps via the\n # base Researcher. Off by default. `surrogate_explore` = UCB-style exploration weight.\n surrogate_proposer: bool = False\n surrogate_explore: float = 0.1\n # E2 researcher panel: generate K candidate ideas per proposal and keep the one ranked best by a\n # cheap empirical surrogate over the (params->metric) history (NOT an LLM-judge). 1 = off.\n researcher_panel: int = 1\n # A1 ASHA / successive-halving: reduction factor (keep top 1/eta per rung) and rung budget.\n asha_eta: int = Field(default=3, ge=2)\n # Rung-0 width for ASHA/BOHB (the wide base of cheap drafts). 0 = use n_seeds (default,\n # preserving prior behavior); set >0 to seed a wider/narrower base independent of n_seeds.\n asha_rung_nodes: int = Field(default=0, ge=0)\n # A6 proxy/predictive scoring: cheaply rank a candidate's potential from early-stage signals\n # and skip a full eval for the doomed bottom fraction. 0.0 = off (no candidate skipped).\n proxy_scoring: bool = False\n proxy_kill_fraction: float = Field(default=0.0, ge=0.0, le=0.9)\n # === Novelty / dedup ==================================================================\n # E1 novelty/dedup gate: reject near-duplicate proposals (normalized param-space distance below\n # `novelty_epsilon`) by deterministically nudging them off the duplicate — stops the search\n # wasting evals re-trying the same idea. Audit event `novelty_rejected`. Off by default.\n # Novelty/dedup MODE selector: how a fresh proposal is checked against already-tried experiments.\n # \"off\" — no explicit gate; the agentic Researcher's own read-the-history judgment is trusted.\n # \"algo\" — the deterministic gate: numeric param-distance (`novelty_epsilon`) + optional embedding\n # similarity (`novelty_semantic`). Cheap, no LLM call, but can't explain itself.\n # \"llm\" — an LLM adjudicates (reads the real experiments via tools) whether the idea is a\n # near-duplicate and, if so, asks the Researcher once more for something different. One\n # extra LLM call per proposal — highest quality, follows the \"let the LLM decide\" line.\n novelty_mode: str = \"llm\"\n # Legacy sub-toggles, honored by the \"algo\" mode (and back-compat: novelty_gate=True forces \"algo\"):\n novelty_gate: bool = False\n novelty_epsilon: float = 0.05\n # T5 semantic novelty (active whenever the deterministic gate runs — novelty_mode=algo,\n # novelty_gate=true which is the legacy alias for algo, OR the Strategist novelty stance is\n # \"explore\"; a no-op only under novelty_mode=llm/off with a non-explore stance): reject a proposal\n # whose idea TEXT embeds within\n # `novelty_semantic_threshold` cosine of an existing node's. OFF BY DEFAULT: novelty is the agentic\n # Researcher's call, made by READING the actual prior experiments (read_experiment /\n # find_analogous_across_runs / list_experiments), NOT an embedding-similarity auto-reject that can't\n # explain itself and mis-fires on paraphrases. Semantic/param search stays available as TOOLS in the\n # Researcher's context; the LLM decides whether an idea is a true duplicate.\n novelty_semantic: bool = False\n novelty_semantic_threshold: float = Field(default=0.92, ge=0.5, le=1.0)\n # T10: debug-lineage depth bound for every policy — how many error-feedback repairs a failing\n # lineage gets before it is abandoned. The old default (1) abandoned lineages after ONE repair;\n # multi-turn/deeper debugging is a verified lever (AIRA2 ReAct debug +5.5 percentile points).\n debug_depth: int = Field(default=2, ge=1)\n # P4 operator bandit (GreedyTree): replace the fixed merge/ablate cadences with a\n # deterministic UCB over per-operator yield (Δmetric per eval-second, folded from the run).\n # Off by default (the cadences are well-tested and the bandit has no direct published\n # ablation); `thorough` turns it on.\n operator_bandit: bool = False\n # P1 hypothesis ledger: ask the Researcher to state the one-line hypothesis each experiment tests,\n # register deep-research directions as hypotheses, and track them to a verdict on the board. ON by\n # default. CODEX AGENT: the board is shown to the Researcher and can be foresight-ordered, so it can\n # steer later proposals even though it never directly re-ranks already-evaluated nodes. Set False\n # to drop the prompt nudge + registration.\n track_hypotheses: bool = True\n # E4/M2/M3 reflection-memory -> priors (gradient-free cross-run meta-learning): at run end distill\n # the winner into `<memory_dir>/meta_notes.jsonl` AND structured lessons (incl. NEGATIVE results:\n # tested/abandoned hypotheses + failure themes) into `lessons.jsonl` with a task fingerprint; at run\n # start, inject exact-task notes + fingerprint-matched lessons from SIMILAR tasks into the proposal\n # prompt. ON by default — but a NO-OP until `memory_dir` is set (that's where cross-run memory lives).\n reflection_priors: bool = True\n # M6 comparative lessons (MARS \"comparative reflective memory\", doc 13 §7 item 2): distill\n # credit-assigned lessons from PAIRS of solutions — which SPECIFIC change made a child beat\n # (or regress from) its parent, and what fixed a failure — on top of the one-shot run-end\n # reflection. At most one extra LLM call per distillation (all pairs batched); offline a\n # deterministic param-diff credit stands in. Gated on reflection_priors + memory_dir like the\n # rest of cross-run memory — and since memory_dir has a default (~/.looplab/memory), this is\n # ACTIVE out of the box; clear memory_dir to disable cross-run memory wholesale.\n comparative_lessons: bool = True\n # M6 mid-run cadences (doc 13 §7 item 5 — the AgentRxiv live-share pattern): every N created\n # nodes, WRITE comparative lessons to the shared cross-run store during the run (lessons_every\n # — one batched LLM call per firing) and RE-READ the store so lessons distilled by CONCURRENT\n # runs reach this run's proposals (lessons_refresh_every; file re-read, no LLM call, skipped\n # when the store is unchanged). ON by default (every 4 nodes) and, like comparative_lessons\n # above, live out of the box because memory_dir defaults on; 0 = off — run-end write /\n # run-start read only, the pre-M6 behavior.\n lessons_every: int = Field(default=4, ge=0)\n lessons_refresh_every: int = Field(default=4, ge=0)\n # B3 output redaction: mask credentials (known key shapes + high-entropy tokens) in the\n # stdout/stderr tail before it is persisted to the event log / spans / UI — a leaked secret in a\n # print()/traceback must not land in the durable log. Off by default; recommend on for untrusted.\n redact_output: bool = False\n # B5 reward-hacking detector: a host-side monitor that flags suspicious wins (grader/answer-key\n # access, runtime writes to frozen files, suspiciously-perfect metrics) as a `reward_hack_suspected`\n # audit event in the Trust panel. Off by default. Whether a flag CHANGES selection is governed by\n # `trust_gate` below (default: audit-only, never changes selection).\n reward_hack_detect: bool = False\n # Trust enforcement (T2): what a reward-hack / data-leakage flag actually DOES to selection.\n # \"audit\" (default) = surface it in the Trust panel, never change selection (today's behavior);\n # \"gate\" = a flagged node can no longer be selected as BEST **and isn't bred/confirmed from**\n # (breed_excluded — see replay._apply_trust_gate / models.breedable_nodes), but stays\n # FEASIBLE so it still counts for diversity/audit — closes the \"a hacked/leaky node can\n # win OR seed its lineage\" hole;\n # \"block\" = additionally mark it fully INFEASIBLE (removed from feasible_nodes entirely).\n # Deliberately gates only high-precision CHEATING/LEAKAGE signals. Broad `critic:*` findings stay\n # advisory; CODEX AGENT: `critic:hardcoded_metric` is the one narrow high-precision critic exception\n # and gates because it requires a literal metric with no computed assignment anywhere.\n trust_gate: str = \"audit\"\n # I3 data-centric: static code-leakage scan of each evaluated solution (fit-before-split,\n # fit-on-test) surfaced into the Trust panel alongside reward-hack flags. Off by default.\n code_leakage_detect: bool = False\n # 4.4 sandbox instrumentation (needs reward_hack_detect): after each eval, flag a RUNTIME write\n # to a protected/frozen file (an answer key or grader tampered mid-run) — behavioral evidence a\n # static code scan misses. ON by default; only fires when the reward-hack detector is on.\n workdir_audit: bool = True\n # D8 decoupled research Verifier: check every Deep-Research memo's CLAIMS against their cited\n # evidence (node ids / urls) before recording — synthesis is the documented weak link (Kosmos\n # 57.9% accurate). Deterministic layer always; an LLM rubric pass when the DeepResearcher has a\n # client. The verdict cannot change this run's node/champion, but finalize uses aligned supported\n # verdicts as the evidence gate for persisted D8 cross-run claims; unavailable/misaligned stays\n # unverified. ON by default (no-op with no memo/claims).\n research_verify: bool = True\n # C4 independent critic: an execution-free critic of each solution (stub / hardcoded-metric /\n # params-ignored; on host-graded tasks the metric checks become a submission-output check)\n # surfaced in the Trust panel. Broad findings are advisory; `critic:hardcoded_metric` can gate under\n # trust_gate=gate/block. Off by default.\n critic_check: bool = False\n # A7 Strategist (NEW, user-requested): optional LLM/rule meta-controller that picks the search\n # policy/allocator + operator mix + fidelity (+ Developer backend) per situation. Config-first:\n # every choice the Strategist makes is also a direct knob. Defaults to \"agent\" so the agent adapts\n # the search policy/operators/fidelity per situation; set \"off\" for static config-driven search.\n # \"agent\" = a tool-using Strategist that READS the run/data/sibling-runs/KB/memory (B1-guarded)\n # before deciding, instead of the single-shot \"llm\" call over aggregate stats.\n strategist_backend: str = \"agent\" # \"off\" | \"rule\" | \"llm\" | \"agent\" — default AGENTIC: the\n # Strategist READS the run/data/sibling-runs/KB/memory with tools before deciding, not a single-shot\n # call over aggregate stats. \"llm\" = the old non-agentic single-shot; \"rule\"/\"off\" = no LLM.\n strategist_every: int = Field(default=3, ge=1) # consult cadence (created nodes)\n # PART V (F1): concept CLASSIFIER re-tag + consolidation cadence, DECOUPLED from strategist_every. The\n # LLM concept map is heavier and slower-moving than a strategy consult, so it refreshes on its own\n # (sparser) interval. Researcher-authored idea.concepts still fold immediately at node_created — this\n # only paces the classifier-evidence + consolidation refresh (and the concept-coverage pivot snapshot).\n concept_retag_every: int = Field(default=30, ge=1)\n # === Confirmation & holdout ===========================================================\n # Multi-seed confirmation (I12, ADR-15): confirm the top-k under N seeds before\n # finishing. 0 disables (default). Only meaningful when eval has variance.\n confirm_top_k: int = 0\n confirm_seeds: int = 0\n # D1 seed-holdout discipline: the FIRST seed the confirm phase uses. Search evals run with the\n # implicit seed 0 (LOOPLAB_EVAL_SEED unset -> \"0\"), so a base of 1 keeps every confirm split\n # DISJOINT from anything the search optimized against — the confirm metric is then a\n # generalization signal, not a re-measurement (AIRA: selecting on a signal the search saw\n # overfits by 9-13 pp). Set 0 to restore the legacy overlapping seeds 0..N-1.\n confirm_seed_base: int = Field(default=1, ge=0)\n # D1 holdout-gated promotion (B6, Arbor-style): for host-graded tasks, reserve this fraction of\n # the held-out labels as a FINAL holdout partition the search never sees — every search/confirm\n # eval is scored on the remaining rows only, and at finish the val-top-k are re-scored on the\n # holdout partition (free: the predictions already exist). 0.0 = off (legacy full-label scoring).\n holdout_fraction: float = Field(default=0.25, ge=0.0, le=0.9)\n # Champion selection prefers the HOLDOUT metric among the nodes that have one (the val-top-k).\n # Recorded in run_started so replay applies the same rule; old logs fold to False (legacy pick).\n # The per-node val-holdout `generalization_gap` folds into the Trust panel either way.\n holdout_select: bool = True\n # How many val-leaders get a holdout evaluation at finish (host-graded tasks).\n holdout_top_k: int = Field(default=3, ge=1)\n # R1-c / Part IV — calibrated §12-verifier tie-break in best-selection. When a fresh node's robust\n # metric EXACTLY TIES an already-eligible node, the engine runs the §12 verifier (grounded on each\n # node's realized result, `selection_criteria`) and records a soundness score per node. The tie-break\n # then applies across the WHOLE selection path: the mean pick breaks a robust_metric tie by the score,\n # and — when `holdout_select` is on — the holdout-slot ranking prefers the sounder node so it isn't\n # denied a holdout eval, and the final holdout override breaks a holdout_metric tie by it too. Strictly\n # a TIE-BREAK — it can NEVER move a node ahead of a strictly-better metric (§21.7 advisory-never-\n # overrides). Best-effort: verification is LAZY (only fires on an actual tie) and bounded per cadence,\n # so a large/late tie stays on deterministic metric+id order unless one atomic treatment scores every\n # member. Recorded in run_started so\n # replay applies the same rule; old logs fold to False (legacy pick). Opt-in (default off); needs a\n # reachable LLM client. Enable only after validating calibration offline with `verifier.calibrate`.\n # See trust/verifier.py + core/fitness.py.\n select_verifier: bool = False\n # R1-d (§21.19): widen the verifier tie-break from EXACT-metric to a conservative STATISTICAL tie.\n # The band is capped by both the metric leader's SE and the pooled SE-of-difference, so challenger\n # variance cannot widen it and a significant difference is never a tie (§21.7). Off -> exact only.\n verifier_ci_tie: bool = False\n # Verifier sample count for `select_verifier` (the §12 repeated-sampling expectation). 3 tames the\n # single-shot variance; 1 = single-shot (cheaper, noisier).\n select_verifier_samples: int = Field(default=3, ge=1, le=32)\n # Budget (I13): hard wall-clock ceiling; the run aborts cleanly when exceeded.\n max_seconds: float | None = None\n # Eval-compute budget (#2): hard ceiling on cumulative time spent INSIDE evals (training\n # runs), separate from wall-clock. Survives resume (summed from the event log). The real\n # guard against a silent multi-hour sweep when an eval is a minutes-hours training run.\n max_eval_seconds: float | None = None\n # Cross-run memory (I19, ADR-10): if set, the best result of each run is stored as\n # a case here, and the cases become retrievable knowledge for future runs.\n memory_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"memory\"))\n # HITL (I21, ADR-11): pause for human approval of the final best before finishing.\n require_approval: bool = False\n # Diversity archive (I22): niche bucket width in parameter space.\n archive_resolution: float = 1.0\n # Coverage read-model (narrowing signal): at the strategist cadence, compute a breadth summary\n # (themes / param-niches / theme entropy / dominant-theme fraction) from the folded run, record\n # it as a `coverage_snapshot` audit event, and surface it into the Strategist's decision context.\n # Deterministic, cheap, and purely additive context — no search-behavior change on its own. On by\n # default (like the rest of the always-on situational context). See search/coverage.py.\n coverage_context: bool = True\n # PART IV Phase 2a live steering (§21.11/§21.13). When on, the `concept_retag_every` cadence (NOT\n # `strategist_every` — the producer gates on `_should_consult_concepts`, which uses the seed boundary\n # + `concept_retag_every`, default 30) records a\n # concept-graph coverage + uncovered-region snapshot (deterministic heuristic tagger over the\n # task-type skeleton) and, on an `explore` stance, the Researcher's novelty hint names the exact\n # uncovered regions (\"0 coverage in {negatives/external-mining, distillation} — go there\") instead\n # of the vague \"broaden\". ON by default in the product Settings (ce4a379), EngineOptions off: it\n # only enriches an already-mode-gated explore hint, never forces exploration, and no-ops for a task\n # with no curated concept skeleton. The snapshot is auditable, but its prompt cue can change future\n # candidate generation; it does not directly rewrite best-metric selection. See search/concept_graph.py.\n # DESIGN NOTE (2026-07-17 critique): now ON unconditionally, but a toy / non-ML task yields EMPTY concept\n # tags while still paying the per-node LLM concept cost (~10 calls/node with the full Part IV/V bundle).\n # Consider AUTO-QUIESCING: if the heuristic tagger returns empty for the first N nodes (no concept signal),\n # skip the LLM concept work for the run — keeps it on for ML tasks, free on toy/degenerate ones.\n concept_pivot: bool = True\n # PART IV Phase 2b — D3 graded novelty into the LIVE novelty gate (§21.4/§21.13). When on and the\n # task has a curated concept skeleton, the novelty gate first grades a proposal over the concept\n # graph: a level-4 \"same direction, DIFFERENT implementation\" or a level-5 \"re-opens a\n # wrongly-abandoned FAILED direction\" proposal is ALLOWED through unchanged — the flat LLM/semantic\n # dedup gate can't tell \"this DCL tweak\" from \"the whole DCL branch\" and would wrongly reject a\n # legitimate variant or never re-open a sound-but-killed direction (the node_63 archetype). Levels\n # 1/2/3 (identical / near-dup / prior-run) still fall through to the existing gate. Deterministic\n # (heuristic tagger, no LLM), audit event `novelty_graded`, replay-safe. ON by default in the product\n # Settings (ce4a379), EngineOptions off; no-ops for a task with no skeleton. See search/graded_novelty.py.\n graded_novelty: bool = True\n # PART IV Phase 2b — D7 capability-expansion forced-jump DIRECTIVE (§21.8/§21.13, issue #7). When on\n # and the concept-graph cadence detects action-space LOCK-IN (the search has stayed inside one D5\n # branch for a long consecutive streak) on an `explore` stance, the Researcher's novelty hint\n # ESCALATES from \"broaden\" to \"expand the ACTION SPACE — build the missing infra (external mining,\n # a new data pipeline, a different eval), do NOT swap another {locked} lever.\" CODEX AGENT: the same\n # gate also stamps the proposal's authoritative operator as `expand`; that node competes normally\n # under SearchFitness and its separate operator yield is measured. It does not force a winner.\n # Reads the 2a concept-coverage snapshot, so it needs `concept_pivot` to record one. OPT-IN\n # (default off). See engine/proposal_cues.py + search/lock_in.py.\n capability_expansion: bool = False\n # PART IV cross-run Step 0 (§21.20.12/§21.20.13). Universal task-fingerprint tokenization: drop the\n # ASCII-only `[a-z0-9]` allowlist on goal keywords so a non-Latin goal (Russian, CJK, …) is NOT\n # silently dropped from its cross-run fingerprint (today such a goal keys only on kind/dir/metric and\n # never reaches SIMILAR-task priors/lessons/cases). Uses `[^\\W_]+`/`.casefold()` — same splitting as\n # before, any script. ON by default in the product Settings (ce4a379); the bare-library EngineOptions\n # default stays off, so a run pinned to it won't silently re-key a portfolio mid-flight. See engine/memory.py.\n fingerprint_universal: bool = True\n # PART IV cross-run Step 2 (§21.20). Fill graded-novelty's `prior_concepts` from the cross-run\n # ConceptCapsuleStore: at run end write a per-run concept capsule (the shipped `node_concepts` tags +\n # best outcome, keyed by task_fingerprint) to `memory_dir`; when a later SIMILAR run proposes an idea\n # whose concept was tried before, SURFACE the prior outcome as a `cross_run_prior` audit event (never\n # reject — D3 level 3). Audit-only, off the selection path; ON by default in the product Settings\n # (ce4a379), EngineOptions off. Needs a `memory_dir` to share capsules.\n # See engine/memory.py (ConceptCapsuleStore) + engine/novelty.py.\n # PREREQUISITES (CODEX — this flag is NOT standalone): the WRITE needs per-run concept tags\n # (`node_concepts`, produced by `concept_pivot`/F1 tagging) — no tags => no capsule; the SURFACE path\n # runs inside the graded-novelty precheck, so it also needs `graded_novelty` on. With only this flag +\n # `memory_dir` it persists/surfaces nothing. Enable `concept_pivot` + `graded_novelty` alongside it.\n # A non-Latin (Russian/CJK) portfolio should ALSO set `fingerprint_universal`, else the capsule's goal\n # tokens are dropped and its fingerprint over-matches on kind/dir/metric alone (the novelty direction\n # gate narrows this, but goal-word similarity is still lost).\n cross_run_concepts: bool = True\n # PART V (B): run-base + node-DELTA concept authoring. When on, once the first evaluated node has\n # authored concepts the engine seeds RunState.run_base_concepts from them (a one-shot EV_RUN_CONCEPTS,\n # idempotent because it only fires while the base is empty), and the Researcher's proposal hint tells it\n # to author only `concepts_added`/`concepts_removed` vs the run base + its parent — minimal per-node\n # annotations that inherit down the DAG (a swap propagates). Off -> every node authors its full concept\n # set as before. Product-on; the bare EngineOptions mirror defaults off (test_options_divergence).\n concept_run_base: bool = True\n # PART IV cross-run Step 5 advisory (§21.20.5). Fold the bounded cross-run CONTEXT PACK — evidence-\n # grounded claims with BOTH support and counter-evidence (Step 4) + a portfolio-coverage line (Step 3) —\n # into the Researcher's proposal prompt, exactly like the E4 cross-run prior note. Advisory ONLY: it is\n # prompt-grounding: it can change the Researcher's later candidates, but does not directly rewrite\n # best-metric selection (§21.7). Reads `memory_dir` (lessons.jsonl + concept_capsules.jsonl); \"\" when\n # empty. ON by explicit experimental product choice; no frozen A/B validation is claimed. The\n # bare-library EngineOptions default stays off (engine/options.py, frozen in\n # test_options_divergence). See engine/proposal_cues.py.\n cross_run_advisory: bool = True\n # PART IV cross-run §21.20.13 (full CR of the lean fuzzy claim merge). Switch the claim read-model to the\n # SCOPE+POLARITY-safe STRUCTURED claim key (engine/claim_key.py): claims from different tasks never\n # merge, opposite polarity (\"X helps\" vs \"X never helps\") is surfaced as a CONTRADICTION instead of being\n # collapsed, paraphrase/inflection variants group by exact structured key (no transitive over-merge), and\n # operator governance is scope-precise (a decision in task A cannot reach a same-worded claim in task B).\n # Affects the `cross_run_advisory` context pack only; ON by default in the product Settings (ce4a379);\n # the bare-library EngineOptions default stays off (engine/options.py). See engine/claims.py.\n cross_run_structured_claims: bool = True\n # PART IV cross-run §22.4 (AGENTIC taxonomy steward). At finalize, when an LLM client is available, let\n # the concept steward (engine/concept_steward.py) review the freshly-updated portfolio concept graph and\n # PROPOSE a curation — merge duplicate slugs / split conflated ones / purge noise. Proposals are LOGGED to\n # `concept_curation_log.jsonl` for operator ratification (via the serve/CLI governance surface); the LLM\n # never mutates fold state. The calls run synchronously during finalize, so they add latency and may add\n # paid inference; a caught steward failure does not prevent terminal completion or trigger a retry.\n # ON by explicit experimental product choice when `memory_dir` + an LLM backend are present; the\n # bare-library EngineOptions default stays off (engine/options.py). See engine/lessons.py.\n cross_run_curation: bool = True\n # Deprecated compatibility flag. Steward output is untrusted and finalize is proposal-only regardless\n # of this value; retained so old snapshots still validate and logs can disclose that auto was requested.\n cross_run_curation_auto: bool = False\n # Role backend (ADR-7/14): \"toy\" (offline optimizer) | \"llm\" (live model).\n backend: str = \"toy\"\n # Developer backend (ADR-7): \"default\" (templated/LLM from the task) or an external\n # CLI coding agent: \"opencode\" | \"aider\" | \"goose\" | \"continue\".\n developer_backend: str = \"default\"\n # C2 best-of-N: generate N candidate implementations per node and keep the best by an\n # execution-free reward (static validity + metric-print). 1 = off. In-house LLM developer only\n # (not external agents — ADR-7 cost rule). The top SWE-bench reliability lever for weak models.\n best_of_n: int = 1\n # D10 list-wise best-of-N selection: when the top static-scorers TIE, break the tie with a\n # comparative LLM selection over the candidates presented together (+~3 pts vs pointwise on\n # GAIA, arXiv:2506.12928). The static score stays the primary filter — the LLM is a weak\n # comparative prior, never the sole oracle. ON by default (no-op when best_of_n==1).\n best_of_n_listwise: bool = True\n # === Foresight / predict-before-execute ===============================================\n # FOREAGENT predict-before-execute (arXiv:2601.05930): use the LLM as an IMPLICIT WORLD MODEL to\n # predict — WITHOUT executing — which candidate / hypothesis will score best, primed with a\n # Verified Data Analysis Report (data profile / brief) + the accumulated experiment memory. Master\n # switch, ON by default; a no-op wherever there is nothing to rank (best_of_n==1 and\n # foresight_panel==1) or no LLM client. See `looplab/search/foresight.py`.\n foresight: bool = True\n # Hypothesis foresight breadth: generate K candidate ideas per proposal and keep the one PREDICTED\n # best pre-execution by the world model — it ranks the STRUCTURAL / text ideas the numeric k-NN\n # surrogate (researcher_panel) is blind to, primed with the data profile + memory (the synergy).\n # >1 enables it (LLM backend only; needs `foresight` on); 2 = on by default at modest cost, 1 = off.\n foresight_panel: int = 2\n # AGENTIC foresight: run the ranking (hypothesis-board prioritization + K-idea pick) as a TOOL-USING\n # loop that can pull actual experiment results / data facts before deciding, instead of a one-shot\n # prediction from a pre-baked report. ON by default (needs foresight + a client; falls back to the\n # one-shot predictor on any hiccup). A few extra LLM calls per proposal; set False for the cheaper\n # single-call ranker.\n foresight_agentic: bool = True\n # §1 foresight confidence gate: the minimum predicted confidence at which a predict-before-execute\n # pick is ACTED ON. Below it the ranker abstains — the K-idea panel falls back to the first\n # proposal, best-of-N falls through to the D10 tie-break — instead of committing a low-confidence\n # choice. 0.0 (default) = OFF (act on every pick, the historical behavior); raise toward ~0.5 to\n # make the world model defer when it isn't sure. Pairs with the foresight track record (§1) the\n # predictor is now primed with. Range 0.0-1.0.\n foresight_min_confidence: float = 0.0\n # PART IV Phase 2c — replace the world model's SELF-REPORTED confidence (measured Pearson≈0 with the\n # realized outcome, §21.12) with a CALIBRATED §12-verifier score. When on and a client is available,\n # after the K-idea ranker picks the predicted-best candidate the §12 verifier scores it (grounded +\n # repeated + criteria-decomposed: `foresight_criteria` — likely to improve the objective, and\n # sound/feasible) and THAT calibrated score becomes the confidence the `foresight_min_confidence` gate\n # and the telemetry use. Degrades to the self-reported confidence without a client or on any verifier\n # error (the telemetry records which source was used). ON by default in the product Settings (ce4a379),\n # EngineOptions off; a few extra LLM calls per acted-on proposal. See looplab/trust/verifier.py + search/foresight.py.\n # REVISIT (cost/quality, not yet resolved): a live classification run measured ~10 LLM calls & ~3.8 min/node\n # on deepseek-v4-flash with the full Part IV/V default bundle; the calibrated verifier is part of that per-node\n # tax. Kept ON deliberately — it raises decision quality (self-reported foresight confidence has ≈0 Pearson\n # with realized outcome), and lowering quality to save calls is the wrong trade. TODO: run a frozen quality/cost\n # A/B (verifier-on vs -off) to quantify the recall@objective gain per extra call before considering a default flip.\n foresight_verify: bool = True\n # Verifier sample count for `foresight_verify` (the §12 repeated-sampling expectation on a no-logprob\n # backend). 3 tames the single-shot variance §21.12 measured; 1 = single-shot (cheaper, noisier).\n # RESUME-critical: this value is persisted in config.snapshot.json, so a hard `le` schema bound would\n # make an old run started with a larger LOOPLAB_FORESIGHT_VERIFY_SAMPLES un-resumable (Settings(**snapshot)\n # would raise on `looplab resume` / finalize-recovery / serve replay). CLAMP into [1, MAX] via a validator\n # instead of rejecting — the runtime cap is preserved without breaking the snapshots/env-compat rule.\n foresight_verify_samples: int = 3\n\n @field_validator(\"foresight_verify_samples\")\n @classmethod\n def _clamp_foresight_verify_samples(cls, v: int) -> int:\n # Coerce a persisted/env value into [1, MAX] rather than reject (see field note above).\n try:\n v = int(v)\n except (TypeError, ValueError):\n return 3\n return max(1, min(v, MAX_FORESIGHT_VERIFY_SAMPLES))\n\n # T7 LLM response cache: serve an identical DETERMINISTIC (temperature 0) request from an\n # in-process content-addressed cache instead of re-hitting the model — cuts cost on\n # retry/panel/verify flows. NEVER caches sampling calls (temp>0: best-of-N / panel / novelty\n # re-propose must vary). Off by default (most role calls run at temperature>0 anyway).\n llm_cache: bool = False\n agent_cmd: str | None = None # override the agent's launcher (path / wrapper)\n # External-agent validation (ADR-7): wrap a CLI-agent Developer with a validator that\n # audits each output (no-op/syntax/crash/timeout), retries with feedback, and falls\n # back to the in-house LLM Developer. Off -> use the raw agent output unchecked.\n validate_agent: bool = True\n agent_max_retries: int = 1 # re-prompts of the agent on an invalid result\n # Patch-gated multi-file agent (ADR-7 Rule 3): run the CLI agent in a git worktree and\n # accept only edits whose paths match `agent_surface` (reject-not-strip out-of-surface\n # touches). Lets the agent create helper modules, not just solution.py. Degrades to\n # whole-file readback if git is unavailable.\n agent_patch_gate: bool = True\n agent_surface: list[str] = [\"*.py\"] # edit-surface allow-list (globs)\n # Trust policy for an agent-authored eval/metric adapter (RepoTask onboarding, Phase 3):\n # \"ratify_freeze\" waits for human approval, \"autonomous\" accepts the proposed spec without that\n # gate, and \"ratify_freeze_drift\" also enforces the configured independent drift cross-check.\n eval_trust_mode: str = \"ratify_freeze\"\n # RepoTask node seeding policy (run-wide fallback; a task/editable `seed_mode` overrides):\n # \"auto\" (default) copies git-tracked files when the editable is a git repo (so a tree bloated\n # with untracked artifacts isn't deep-copied per node), else a full copy; \"tracked\" forces\n # code-only; \"all\" forces a full recursive copy (legacy). Genesis authors per-task from the\n # user's words; this is the global default when unspecified.\n seed_mode: str = \"auto\"\n llm_model: str = \"qwen3:8b\"\n # === LLM / transport ==================================================================\n llm_base_url: str = \"http://localhost:11434/v1\" # Ollama OpenAI-compatible endpoint\n llm_temperature: float = 0.6\n # H3 per-role model presets (5090 recipe): optionally run the Researcher and Developer on\n # DIFFERENT models/endpoints (e.g. Developer=Qwen3-Coder-30B for code, a fast model for breadth).\n # Blank = use the shared llm_model/llm_base_url. Generalizes the role-backend swap to per-role.\n researcher_model: str | None = None\n developer_model: str | None = None\n researcher_base_url: str | None = None\n developer_base_url: str | None = None\n # Per-role sampling temperature (§4.1): the Researcher wants breadth (higher temp = more diverse\n # ideas), the Developer wants determinism (lower temp = fewer syntax slips), the Strategist a\n # steady hand. Each overrides the shared `llm_temperature` for that role only; None = use the\n # shared value (byte-identical to before). Flat + nullable, mirroring the per-role model fields.\n researcher_temperature: float | None = None\n developer_temperature: float | None = None\n strategist_temperature: float | None = None\n # Unified self-driving agent: one LLM identity that plays Researcher + Developer (+ Strategist)\n # across pipeline stages, choosing its own model/toolset per stage. ON by default — the agent\n # drives the loop (incl. crash triage: repair/abandon/reject_idea) and, via\n # `agent_drives_actions`, picks the next macro action within a pure legal-action gate. Set both\n # to False for the legacy byte-identical split-role behavior. (No-op unless backend=\"llm\".)\n unified_agent: bool = True\n agent_drives_actions: bool = True\n # Layer 3 Card queue ownership. False preserves the existing policy/unified-pilot action path.\n # True is pinned in run_started because changing the selector on resume would mix two search\n # treatments in one run. When both this and agent_drives_actions are true, Card selection wins.\n card_driven_selection: bool = False\n # Layer 5 bounded speculative Card buffer. Zero is a hard OFF switch and preserves the\n # historical alternating build/eval spine; positive values are pinned by run_started so a\n # resume cannot silently mix search treatments after a snapshot/default edit.\n speculation_depth: int = Field(default=0, ge=0, le=64)\n # Local evidence receipt produced by ``looplab speculation-gate``. This is deliberately absent\n # from the curated Settings UI: positive-depth execution is a calibrated rollout decision, not a\n # casual tuning knob. The Engine validates the receipt against the current scorer,\n # implementation, GPU inventory and raw paired-run evidence before it enables speculation.\n speculation_gate_receipt: str | None = Field(default=None, max_length=4096)\n # Per-stage model/endpoint overrides for the unified agent. Recognized keys (see\n # tasks.build_unified_agent): `propose` (researcher), `implement`/`repair` (developer),\n # `strategy`, `pilot`. Unlisted keys are ignored. Empty = the per-role researcher_*/developer_*\n # models, then the shared llm_model. Explicit map wins. No-op unless `unified_agent` is true.\n agent_stage_models: dict = {}\n agent_stage_base_urls: dict = {}\n llm_parser: str = \"tool_call\"\n # Reasoning/thinking toggle sent IN the request (providers differ — see `llm.reasoning_body`):\n # Qwen3 on vLLM/SGLang -> chat_template_kwargs.enable_thinking (bool)\n # OpenAI/Ollama-v1/DeepSeek -> reasoning_effort (low|medium|high|none)\n # `llm_reasoning`: \"\" = send nothing (server default — current behavior); off = actively disable;\n # on = enable at default depth; low|medium|high = enable at that effort.\n # Defaults to \"high\" so the agent reasons before proposing/repairing/triaging (better code, fewer\n # crashes); set \"\" to send nothing (server default) or \"off\" to disable. The model's chain-of-thought\n # is CAPTURED either way (reasoning_content field or inline <think>). NOTE: to get a SEPARATE\n # reasoning_content field from SGLang/vLLM, the server also needs `--reasoning-parser qwen3`.\n llm_reasoning: str = \"high\"\n llm_reasoning_style: str = \"auto\" # auto | qwen | effort | none — how to shape the request param\n llm_reasoning_extra: dict = {} # raw fields merged into the body (escape hatch, e.g. Anthropic thinking)\n # H1: drive structured calls with the endpoint's constrained decoding (vLLM/SGLang guided_json +\n # response_format json_schema) so weak models can't emit invalid JSON. Off for Ollama (default).\n llm_guided_json: bool = False\n # Stream every LLM request (SSE) and reassemble it, so the request `timeout` is an INTER-TOKEN\n # idle timeout rather than a whole-request deadline: a long-but-alive generation is never cut off\n # (tokens keep resetting the timer), while a genuinely STALLED endpoint (no token for `timeout` s)\n # is detected and retried on a fresh connection — the fix for silent multi-minute hangs. Set False\n # to use one blocking read (old per-op timeout semantics) if an endpoint streams badly.\n llm_stream: bool = True\n # LLM IDLE timeout (seconds). Stream mode: the INTER-TOKEN stall limit — a steady generation is\n # never cut off, only a silent endpoint. Non-stream: bounds the whole read. Raise for endpoints\n # with a long prefill on huge prompts; lower to fail fast on a flaky shared endpoint.\n llm_timeout: float = Field(default=180.0, gt=0)\n # First-byte (response-headers) window for STREAM attempts, seconds: an SSE response sends its\n # headers on admission, so no-headers ≈ a black-holed request — fail over to a fresh connection\n # fast instead of waiting the full idle timeout. Clamped to llm_timeout. Non-stream attempts are\n # NOT bounded by this (their headers arrive only after the whole generation). The default mirrors\n # `DEFAULT_HEADER_TIMEOUT_S` in looplab/core/llm.py is the single source (imported above).\n llm_header_timeout: float = Field(default=DEFAULT_HEADER_TIMEOUT_S, gt=0)\n # Honour HTTP(S)_PROXY / NO_PROXY / SSL_CERT_FILE env vars for LLM calls. Default False: the\n # transport connects DIRECTLY (many internal/OLAP endpoints are reachable only by bypassing an\n # ambient proxy — a picked-up proxy yields \"connection refused\"). Set true when the endpoint is\n # reachable ONLY via a corporate proxy or needs a custom CA bundle from the environment.\n llm_trust_env: bool = False\n # H4/C2: compact the growing agentic tool-call history once it exceeds this many chars (auto_summary\n # LLM-summarizes the stale middle; else middle-truncate). Sized to the model's real context window so\n # files read earlier STAY in context instead of being compacted away and re-read: ~1,000,000 chars ≈\n # 250k tokens (≈4 chars/token), matching the configured deepseek-v4-flash (≥200k tokens confirmed).\n # The old 120k-char (~30k-token) default was ~8× too aggressive and drove a read-thrash loop. 0 = off.\n context_budget_chars: int = 1_000_000\n # M5: the Researcher's always-on experiments digest budget (chars). 0 = AUTO — scales with the\n # run size (60 chars/node, bounded [1200, 6000]) instead of one flat cap for an 8-node toy run\n # and a 200-node benchmark run alike. Set a positive value to pin it.\n digest_char_cap: int = Field(default=0, ge=0)\n # D11 compression model slot (open_deep_research's four-slot pattern): a dedicated CHEAP model\n # for agent-history summarization (C2 auto-summary), so compression doesn't pay the main\n # model's price. Blank = use each loop's own model (legacy). `compressor_base_url` blank =\n # the shared llm_base_url.\n compressor_model: str | None = None\n compressor_base_url: str | None = None\n # === Agentic tool-loop ================================================================\n # Agentic tool-loop limits — apply to EVERY tool-using agent (the LLM Researcher, the unified\n # agent's pilot + crash-triage, Deep-Research, the run-chat Boss, the genesis repo-scout, and the\n # cross-run report synthesizer). The loop lets the model call read-only tools across turns before\n # emitting its structured result. Both default to UNLIMITED so the agent takes as many turns / as\n # long as it needs — it is never cut off mid-reasoning; set a positive value here (or per-run, or\n # in the UI settings) to bound latency/cost. These used to be hardcoded per call site (e.g. the\n # boss at max_turns=3 / 45s), which silently dropped a slow reasoning model to a no-op reply.\n agent_max_turns: int = 0 # max tool turns before the emit is forced (0 = unlimited)\n agent_time_budget_s: float = 0.0 # wall-clock ceiling across the loop's turns (0 = no cap)\n # G · Soft convergence SAFETY NET (not a research cap). Two thresholds on the agent's tool-turn count:\n # at `agent_emit_after` the loop NUDGES the agent once (\"you've investigated enough — emit now\"); at\n # `agent_emit_force` it FORCES the emit from what was gathered. Set GENEROUSLY — a focused researcher\n # may legitimately read many files/experiments before it can propose a grounded idea; these only catch\n # a genuine runaway that keeps issuing DIFFERENT calls forever (which stuck-detection, keyed on\n # repeats, misses). NB: the old 499-read runaway (node 63) was 79% REDUNDANT re-reads caused by a\n # broken read_file — fixing read_file (pagination) is the real cure; this is just insurance. 0 = off.\n agent_emit_after: int = 300\n agent_emit_force: int = 500\n # B1 · No-progress / stuck detection — the safety net that makes \"unlimited turns\" safe. The\n # turn/time ceilings above are only BACKSTOPS; this is what actually stops a runaway loop, on the\n # cheapest signal: when the model repeats the SAME tool call (or ping-pongs between two, or keeps\n # hitting the SAME error) with no progress, the loop forces the final emit and finishes. ON by\n # default; reading DIFFERENT files or running ONE long command never trips it. (OpenHands-style.)\n agent_stuck_detection: bool = True\n agent_stuck_repeat: int = 4 # identical calls in a row that count as \"stuck\" (>=2)\n agent_stuck_alternate: int = 4 # ping-pong cycles between two calls that count as \"stuck\" (>=2)\n # C1 · Self-plan (TodoWrite-style): expose an `update_plan` tool so a long-running agent keeps its\n # OWN working TODO and re-surfaces it every `agent_plan_reinject_every` turns — keeps the goal in\n # view across a long loop so the agent \"writes its own context\" (Claude Code style). ON by default.\n agent_self_plan: bool = True\n agent_plan_reinject_every: int = 5\n # C2 · Auto-summary (ON by default): when the tool-loop history grows long, LLM-summarize the\n # stale middle instead of only middle-truncating it. The trigger is `context_budget_chars` if set,\n # else a built-in high-water mark (context_budget.DEFAULT_SUMMARY_CHARS ≈ 120k chars) so short\n # loops are untouched. Falls back to truncation on any summarizer error; one extra model call per\n # over-budget turn.\n agent_auto_summary: bool = True\n # C4 · Repo-developer plan decomposition. A big feature attempted in ONE developer session made a\n # non-converging reasoning model loop indefinitely — it kept writing + exploring without ever\n # emitting `done` (10k+ LLM calls / hours; agentic stuck-detection can't catch VARIED exploration,\n # only literal repeats). Fix: the developer first proposes an ordered plan of ATOMIC steps; a\n # multi-step plan is executed step-by-step, each in a FRESH bounded session with only that step's\n # scope + the files accumulated so far, syntax-validated per write. Every session stays\n # small-context and convergent. ON by default for repo tasks (a 1-step plan == the old single pass).\n developer_plan_decompose: bool = True\n developer_plan_min_steps: int = 2 # a proposed plan with >= this many steps runs step-by-step\n developer_plan_max_steps: int = 8 # cap on plan length (a runaway planner can't spawn 100 steps)\n # Hard per-session backstop for the repo developer — the ONE write-heavy agent that can run away\n # even with stuck-detection on (varied writes/reads never trip the repeat/ping-pong signal). Bounds\n # the plan phase, EACH step, and the single-session fallback. Unlike the global agent_* limits\n # (0 = unlimited, meant for read-only agents), the developer always gets a FINITE ceiling so a\n # model that never emits `done` fails cleanly with the code it wrote so far, instead of looping.\n developer_session_max_turns: int = 500\n developer_session_time_budget_s: float = 1200.0 # 20 min wall-clock per developer session\n # Phase-handoff summaries. Each LLM phase in a node build (Researcher·propose → Developer·stages →\n # plan → implement) ends with ONE extra LLM call that distills its transcript — the repo structure\n # it mapped, files/data confirmed, decisions made — into a brief injected into the NEXT phase (even\n # across the role boundary). The next phase TRUSTS that instead of re-reading the same files, which\n # cuts the biggest source of tool-call thrash (stages/plan/implement each re-exploring the repo).\n # One summary call per phase in exchange for many fewer read calls downstream. ON by default.\n phase_handoff_summary: bool = True\n # Observability (ADR-17): capture each LLM call's full prompt + completion into the active\n # span (spans.jsonl) so the UI can show exactly what the model read and wrote per node.\n # Diagnostics only — `replay.fold` never reads spans. Default on for local single-user; set\n # LOOPLAB_TRACE_LLM_IO=0 to suppress (e.g. to keep spans.jsonl small or avoid storing prompts).\n trace_llm_io: bool = True\n # Run-introspection tools (ON by default): make the LLM Researcher (and DeepResearcher) a\n # tool-using agent that can read its OWN experiments (list/read/find-analogous/themes/code) and\n # the task data (schema/profile/asset) mid-loop, instead of seeing only best+parent. Advisory —\n # never changes best-selection. Off = the legacy single-shot Researcher (richer digest still added).\n researcher_tools: bool = True\n # Cross-run introspection: give the agentic Researcher / pilot read-only tools to look at\n # SIBLING runs (same task_id, same run-root) — list them, read an experiment / its code, and\n # find analogous configs across runs — so a run can build on what neighbouring runs already\n # learned instead of rediscovering it. Advisory; never changes best-selection. Needs the run's\n # own dir wired through (no-op for unit-built roles). Off = the legacy single-run view only.\n cross_run_tools: bool = True\n # Read-only access to EVERY run on this machine ACROSS ALL TASKS (not just same-task siblings):\n # the Developer/Researcher get list_all_runs + read_run_code + read_run_experiment so they can read\n # the code + result of ANY past experiment anywhere and reuse an approach. Broader than\n # `cross_run_tools` (same-task only); the agent decides when a foreign run is relevant. Advisory;\n # never changes best-selection. Needs the run's own dir wired through (no-op for unit-built roles).\n all_runs_tools: bool = True\n # PART V §22 — read-only CROSS-RUN KNOWLEDGE tool for the reasoning roles (Researcher, Strategist,\n # deep-research, and a Developer-scoped variant). Adds `cross_run_prior_attempts` / `cross_run_claims`\n # / `cross_run_atlas` over the §21.20 read-models (concept overview + claim assessments + atlas), read\n # from `memory_dir` (lessons.jsonl + concept_capsules.jsonl). Read-only is a storage-authority\n # boundary, not a behavioral no-op: retrieved content can steer later proposals, and a tool-using\n # reasoning loop can consume additional model calls. The tools never mutate cross-run truth (facts\n # are engine-written, verdicts operator-ratified, §22.4) or directly select a winner. ON by explicit\n # experimental product choice, EngineOptions off; stores only exist once Part-IV features have run.\n cross_run_read_tools: bool = True\n # Agentic retrieval (ADR-16): if set, the LLM Researcher gets grep/kb_search/read\n # tools over this directory of markdown notes and chooses when to use them.\n knowledge_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"knowledge\"))\n # T4 real embeddings: model id for an OpenAI-compatible `/embeddings` endpoint used for\n # kb_search / case retrieval. Blank (default) = the dependency-free lexical `hash_embed` (today's\n # behavior). Set e.g. \"nomic-embed-text\" (Ollama) or \"text-embedding-3-small\" for SEMANTIC\n # retrieval; a misconfigured/offline endpoint degrades back to hash_embed at call time (never\n # crashes a run). `embed_base_url` blank = reuse `llm_base_url`; the shared `llm_api_key` is used.\n embed_model: str | None = None\n embed_base_url: str | None = None\n # Harmonic memory (Memora, ICML'26 — idea import). When ON, cross-run cases and knowledge notes are\n # indexed by a short ABSTRACTION + cue ANCHORS instead of their raw text, near-duplicate memories are\n # CONSOLIDATED into one entry under a matching abstraction, and `kb_search`/case retrieval EXPAND\n # through the top hits' anchors to surface related-but-not-similar memories. ON by default. Set\n # `memora=false` to restore the pre-Memora raw-text index. Never a source of truth — abstractions\n # live only in the derived, rebuildable retrieval index (never the event log or canonical cases.jsonl).\n memora: bool = True\n # Write abstractions with the wired chat model (richer than the deterministic lexical fallback). ON\n # by default; results are CACHED by content hash (see `memora_cache`) so a re-built index doesn't\n # re-call the model on unchanged notes/cases, and any endpoint failure degrades to lexical at call\n # time — so an offline box just gets lexical abstractions, never a crash. Set false to force lexical.\n memora_llm: bool = True\n # === Cross-run memory / Memora ========================================================\n memora_anchors: int = 6 # max cue anchors kept per memory\n # Cosine similarity at/above which a new case is CONSOLIDATED into an existing one (same evolving\n # topic) rather than stored as a separate entry. Only used when `memora` is on.\n memora_consolidate_threshold: float = 0.86\n # Where the LLM-abstraction cache lives (JSON). Blank (default) derives it from `memory_dir`\n # (`<memory_dir>/memora_cache.json`) or else `knowledge_dir` (`<knowledge_dir>/.memora_cache.json`);\n # with neither set the cache is in-memory only (per process). No-op unless `memora_llm` is on.\n memora_cache: str | None = None\n # E3 literature-grounded ideation (network-OPTIONAL): give the agentic Researcher an arXiv search\n # tool to ground ideas in real techniques. Off by default (egress is unreliable on some boxes);\n # fails gracefully if the network is blocked.\n literature_search: bool = False\n # Deep-Research stage (network-OPTIONAL): give the DeepResearcher a general web search/fetch\n # tool (DuckDuckGo) on top of arXiv to read across results + the web before steering the next\n # batch. Off by default (egress is unreliable on some boxes); fails gracefully when blocked.\n web_search: bool = False\n # Cadence for the Deep-Research stage: run it automatically every N created nodes. Zero disables\n # only the cadence; manual `deep_research` and Strategist `request_research` triggers still fire.\n # Default 3: deep research analyzes the accumulating results and steers the next batch (its\n # directions become hints + open hypotheses); paired with `concurrent_research` it overlaps the\n # think with the GPU-bound eval, so the cadence costs LLM tokens but no wall-clock on the search.\n deep_research_every: int = 3\n # Overlap a DUE deep-research \"think\" with the GPU-bound eval instead of running it in its own\n # serial step (the agent is otherwise idle while a node trains). research() computes from a state\n # snapshot; CODEX AGENT: the joined background task records only allowlisted, order-tolerant\n # research/hint/hypothesis events as soon as the memo finishes. ON by default: the LLM is typically\n # remote (no GPU contention with eval), so overlapping hides latency behind long training.\n concurrent_research: bool = True\n # Concurrent-research REPEAT — don't idle a multi-DAY eval. The overlapped \"think\" fires ONCE per\n # window by default; ON here, it RE-RUNS on an adaptive time cadence for the WHOLE eval window\n # (a two-day training must not leave the reasoning agents idle after one memo). Self-paced: it\n # only records a memo whose CONTENT is new (identical re-runs are skipped, so the log/board don't\n # bloat) and backs off geometrically as the analysis converges, capped so it always re-checks.\n # The appended records never rewrite the current metric champion, but their hints/open hypotheses\n # deliberately steer later proposals and replay reconstructs that advice. The library default is\n # one-shot (== today, byte-identical); this product default turns the repetition on.\n concurrent_research_repeat: bool = True\n # Base cadence (seconds) between repeated concurrent-research passes. Research is EXPENSIVE\n # (multi-turn LLM + web/arXiv), so this is a FLOOR, not a ceiling: the effective cadence is\n # max(this, ~5% of the per-experiment time budget) — a two-day eval is re-researched about hourly,\n # a short eval not at all (the first tick outlasts it). Only matters when the repeat is on.\n concurrent_research_interval_s: float = Field(default=1800.0, gt=0)\n # Per-eval-window backstop on repeated-research LLM calls (0 = cadence-only, no cap). The adaptive\n # cadence + convergence backoff are the primary budget control; this bounds a pathological\n # never-converging window. Past it the loop stops calling the LLM (the health monitor still runs).\n concurrent_research_max_calls: int = Field(default=40, ge=0)\n # Concurrent hypothesis-board CONSOLIDATION during the eval window. Repeated research keeps ADDING\n # near-duplicate directions to the open board (each memo registers up to 5 as open hypotheses), so a\n # long eval's board bloats before the between-nodes merge runs. This dedups/merges it on the SAME\n # background loop so the board stays clean for the next proposal. In Card-driven mode the same merge\n # changes ownership/readiness, so it is deferred to the joined main-task cadence instead. Needs the\n # reflect client + `track_hypotheses`.\n # Only active while `concurrent_research` runs the overlap loop. Library default off (== today).\n concurrent_consolidate: bool = True\n # Cadence for the agent-authored run report: regenerate the conclusion-first narrative every N\n # created nodes (0 = off; it still regenerates on a manual `report_refresh` from the UI). The\n # deterministic report always renders from the node set regardless of this knob.\n report_every: int = 3\n # Agent Skills (I18, ADR-9): dir of SKILL.md the Researcher can list/load as tools.\n skills_dir: str | None = None\n # Prompt store (I18, ADR-8): dir of editable, hot-reloaded role prompt .md files.\n prompt_dir: str | None = None\n # API key as a reference, never serialized as a value (ADR-11). Local servers\n # ignore it; default to a placeholder.\n llm_api_key: SecretStr | None = None\n\n @model_validator(mode=\"before\")\n @classmethod\n def _apply_profile(cls, data):\n \"\"\"Expand `profile` into a bundle of setting defaults BEFORE validation.\n\n pydantic-settings hands this validator the fully-merged input from ALL explicit sources\n (init kwargs + LOOPLAB_* env + .env), with fields the user left unset simply ABSENT (they\n fall to field defaults afterward). So a profile value is applied only when its key is not\n already present — i.e. any explicit file/CLI/env setting wins over the profile, and the\n profile wins over the bare field default. This makes `thorough` a true convenience preset,\n never an override of what the user asked for.\"\"\"\n if not isinstance(data, dict):\n return data\n prof = data.get(\"profile\", \"default\")\n # env may deliver the key as \"profile\" (env_prefix stripped) — normalize to a str\n prof = str(prof).strip().lower() if prof is not None else \"default\"\n overrides = PROFILES.get(prof)\n if overrides is None:\n raise ValueError(f\"unknown profile {prof!r}; known: {sorted(PROFILES)}\")\n for key, val in overrides.items():\n if key not in data: # explicit source (file/CLI/env) always wins\n data[key] = val\n return data\n\n @model_validator(mode=\"after\")\n def _check_trust_gate(self):\n if self.trust_gate not in (\"audit\", \"gate\", \"block\"):\n raise ValueError(\n f\"trust_gate must be audit|gate|block, got {self.trust_gate!r}\")\n if self.merge_mode not in (\"auto\", \"mean\", \"ensemble\"):\n raise ValueError(\n f\"merge_mode must be auto|mean|ensemble, got {self.merge_mode!r}\")\n # novelty_mode drives the dedup/novelty gate; an out-of-set value (e.g. a mis-cased\n # LOOPLAB_NOVELTY_MODE=LLM, or \"on\") used to fall through _apply_novelty_gate silently as a\n # NO-OP — turning the gate off with no diagnostic. Fail loudly at config time like trust_gate/\n # merge_mode do. (Other enum-ish string fields — seed_mode/eval_trust_mode/strategist_backend —\n # share the pattern; left unvalidated here only because their full value sets are less settled.)\n if self.novelty_mode not in (\"off\", \"algo\", \"llm\"):\n raise ValueError(\n f\"novelty_mode must be off|algo|llm, got {self.novelty_mode!r}\")\n # The remaining enum-ish string fields (arch-review §5 P3): a typo used to be accepted at\n # construction and only fail-safe/later-loud downstream (e.g. a mis-cased strategist_backend\n # silently ran the default). Fail loudly here like the fields above.\n if self.strategist_backend not in (\"off\", \"rule\", \"llm\", \"agent\"):\n raise ValueError(\n f\"strategist_backend must be off|rule|llm|agent, got {self.strategist_backend!r}\")\n if self.eval_trust_mode not in (\"ratify_freeze\", \"autonomous\", \"ratify_freeze_drift\"):\n raise ValueError(\n \"eval_trust_mode must be ratify_freeze|autonomous|ratify_freeze_drift, \"\n f\"got {self.eval_trust_mode!r}\")\n if self.seed_mode not in (\"auto\", \"tracked\", \"all\"):\n raise ValueError(f\"seed_mode must be auto|tracked|all, got {self.seed_mode!r}\")\n return self\n\n def masked_snapshot(self) -> dict:\n # Snapshots are a JSON contract (they are written verbatim to config.snapshot.json), not a\n # Python-object dump. In particular tuple-valued schema fields such as\n # ``inline_repair_reasons`` must become JSON arrays before callers hash the runtime envelope;\n # otherwise the strict speculation-scope canonicalizer sees a different/non-JSON preimage\n # from the one that ``json.dumps`` eventually persists.\n d = self.model_dump(mode=\"json\")\n if self.llm_api_key is not None:\n d[\"llm_api_key\"] = \"***\"\n else:\n d[\"llm_api_key\"] = None\n return d\n\n\n# Pre-versioned snapshots are full Settings dumps, so absence is a reliable per-field version marker.\n# Keep their historical effective behavior when newer product defaults become active: re-entry must not\n# silently add paid calls, interventions, concurrency or a different selection policy to an old run.\nLEGACY_CONFIG_SNAPSHOT_DEFAULTS: dict[str, object] = {\n \"parallel_build\": 1,\n \"eval_parallel\": None,\n \"llm_parallel\": None,\n \"train_monitor\": False,\n \"train_monitor_interval_s\": 600.0,\n \"train_monitor_kill\": False,\n \"train_monitor_kill_confidence\": 0.8,\n \"asha_live\": False,\n \"asha_live_kill\": False,\n \"asha_live_quantile\": 0.5,\n \"asha_live_min_siblings\": 3,\n # Before this operator ceiling existed, the sandbox's 24-hour process limit was authoritative.\n \"max_eval_timeout\": 24 * 3600.0,\n \"watchdog_reflection\": False,\n \"card_driven_selection\": False,\n \"speculation_depth\": 0,\n \"speculation_gate_receipt\": None,\n \"concurrent_research_repeat\": False,\n \"concurrent_research_interval_s\": 1800.0,\n # Zero means unlimited; 40 was the first bounded default, and is latent while repeat is disabled.\n \"concurrent_research_max_calls\": 40,\n \"concurrent_consolidate\": False,\n}\n\n\ndef migrate_config_snapshot(data: dict) -> dict:\n \"\"\"Return an effective copy of one legacy/current snapshot without rewriting evidence bytes.\"\"\"\n if not isinstance(data, dict):\n raise TypeError(\"config snapshot must be a JSON object\")\n migrated = dict(data)\n for key, value in LEGACY_CONFIG_SNAPSHOT_DEFAULTS.items():\n migrated.setdefault(key, value)\n return migrated\n\n\ndef settings_from_snapshot(data: dict) -> Settings:\n \"\"\"Build re-entry Settings from a masked snapshot under its historical missing-field contract.\"\"\"\n migrated = migrate_config_snapshot(data)\n migrated.pop(\"llm_api_key\", None)\n return Settings(**migrated)\n"}, "8bb3509744be12b952a9cf72212beb669bb2e953": {"t": 1785768776.413241, "n": 96761, "h": "2a28d5b7f43fc782", "c": "\"\"\"Engine config: the schema (what settings exist). (I0, ADR-11)\n\nOne pydantic-settings class declaring every engine knob, its default, and its docs. The loader —\nhow a run file / CLI / env becomes a `Settings` — is `looplab.core.appconfig`, which also owns\nthe config-precedence order. A resolved, secret-masked snapshot is written next to each run for\nreproducibility. (No real secrets in P0, but the masking discipline is in place.)\n\"\"\"\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nfrom pydantic import Field, SecretStr, field_validator, model_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n# Single source of the LLM first-byte (response-headers) default — see core/llm.py.\nfrom looplab.core.llm import DEFAULT_HEADER_TIMEOUT_S\n\n# Default home for cross-run memory + the knowledge base (shared across every run). Both are ON by\n# default now (a user asked for it) so a fresh install accumulates + consults knowledge with no setup;\n# set the env var to \"\" to disable, or to a path to relocate. `~/.looplab/` keeps them out of any one\n# project + shared across projects.\n_LL_HOME = Path.home() / \".looplab\"\n\n# Autonomous roles that may be granted per-setting write access (see Settings.agent_control).\n# strategist = A7 meta-controller (run-wide); boss = run-chat operator-proxy (run-wide);\n# researcher = per-experiment proposer (per-node sizing, e.g. eval timeout for a heavy model).\nAGENT_ROLES = (\"strategist\", \"boss\", \"researcher\")\n\n# A foresight score is computed for every acted-on proposal. Keep this lower than the generic\n# verifier's emergency ceiling so a typo cannot multiply paid synchronous calls across a run.\nMAX_FORESIGHT_VERIFY_SAMPLES = 8\n\n\n# Layer-2 parallelism renamed two public settings without removing the durable legacy fields. Keep\n# the equivalence in one place: config-source precedence, governance migration, and live strategy\n# application must all agree which names describe the same authority/value.\nPARALLELISM_ALIASES: dict[str, str] = {\n \"max_parallel\": \"eval_parallel\",\n \"parallel_build\": \"llm_parallel\",\n}\n\n\ndef parallelism_aliases(setting: str) -> tuple[str, ...]:\n \"\"\"Return every spelling in ``setting``'s alias family, canonical name first.\"\"\"\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if setting in (legacy, canonical):\n return canonical, legacy\n return (setting,)\n\n\ndef canonicalize_parallelism_source(values: dict) -> dict:\n \"\"\"Promote legacy parallel keys *within one config-precedence source*.\n\n Applying this only after file/CLI/env were flattened would let a lower-priority canonical env\n value shadow a higher-priority legacy file value (or a file canonical value shadow a CLI legacy\n override), so callers with sub-layers normalize each layer independently.\n \"\"\"\n if not isinstance(values, dict):\n return values\n out = dict(values)\n for legacy, canonical in PARALLELISM_ALIASES.items():\n # CODEX AGENT: None is the durable legacy mode for llm_parallel, not a missing value. A normal\n # snapshot contains llm_parallel:null plus parallel_build:1; reloading it here promotes null to\n # 1 and unexpectedly enables the global one-call broker. Promote aliases only when the\n # canonical key is absent, and pin snapshot round-trip parity.\n if canonical not in out and out.get(legacy) is not None:\n # CODEX AGENT: canonicalize inside each precedence layer, never across the flattened\n # result. The alias migration must not invert CLI > file > env precedence.\n # CODEX AGENT: promoting a legacy-ONLY parallel_build layer (e.g. just\n # LOOPLAB_PARALLEL_BUILD=1 in env) into canonical llm_parallel ALSO flips on the finite\n # shared LLM broker: llm_parallel=1 -> orchestrator._startup_llm_total=1, serializing\n # every provider call in the run (research overlap, novelty, tag_nodes_llm, monitors) —\n # contradicting llm_broker.py's docstring, orchestrator's \"legacy-only preserves unbounded\n # overlap\" note, the broker-neutral live-control path, and the configuration.md\n # llm_parallel row (\"falls back ... without enabling a shared total\"). Promote only the\n # build width (keep broker opt-in tied to an explicitly-spelled canonical value), or fix\n # all four contracts and pin the env/file semantics with a test.\n out[canonical] = out[legacy]\n return out\n\n# Settings whose semantics are committed by ``run_started`` and restored from the folded event log\n# on every re-entry. Changing one only in ``config.snapshot.json`` would mix incompatible evidence\n# (a different holdout split) or make the live producer disagree with replay's selection policy.\n# The HTTP config editor and the engine both consume this one contract; keep deliberately mutable\n# controls such as ``trust_gate`` out (that field has its own durable change event).\nRUN_START_PINNED_FIELDS = frozenset({\n \"card_driven_selection\",\n \"speculation_depth\",\n \"holdout_fraction\",\n \"holdout_select\",\n \"select_verifier\",\n \"select_verifier_samples\",\n \"verifier_ci_tie\",\n})\n\n\ndef run_start_pinned_settings(state) -> dict:\n \"\"\"Return the effective Settings names committed by a folded run-start record.\n\n Pre-pin legacy logs have no recorded holdout fraction, so their snapshot remains authoritative\n for both holdout knobs. Verifier fields, however, have always been re-pinned from the legacy-safe\n fold defaults whenever a valid ``run_started`` identity exists.\n \"\"\"\n if not getattr(state, \"run_id\", \"\"):\n return {}\n values = {\n # Layer 3 changes which queue owns macro-action selection. Once a run starts, replay and\n # resume must keep the same owner even if the snapshot or ambient default changes later.\n \"card_driven_selection\": bool(getattr(state, \"card_driven_selection\", False)),\n # Layer 5 overlaps Card production with evaluation only when this pinned treatment is\n # positive. Old logs omit it and therefore retain the exact serial build/eval spine.\n \"speculation_depth\": int(getattr(state, \"speculation_depth\", 0)),\n \"select_verifier\": bool(getattr(state, \"select_verifier_tiebreak\", False)),\n \"select_verifier_samples\": int(getattr(state, \"select_verifier_samples\", 3)),\n \"verifier_ci_tie\": bool(getattr(state, \"verifier_ci_tie\", False)),\n }\n holdout_fraction = getattr(state, \"holdout_fraction\", None)\n if holdout_fraction is not None:\n values.update({\n \"holdout_fraction\": holdout_fraction,\n \"holdout_select\": bool(getattr(state, \"holdout_select\", False)),\n })\n if not values.keys() <= RUN_START_PINNED_FIELDS:\n raise RuntimeError(\"folded run-start pinned settings contract drifted\")\n return values\n\n# --- Run profiles (config-first presets) -------------------------------------------------------\n# A `profile` is a NAMED BUNDLE of setting defaults — nothing more. It only fills fields the user\n# did NOT set explicitly (via file / --set / LOOPLAB_* env / init-kwargs), so any explicit knob\n# always wins (see `_apply_profile`). Most engine intelligence lives behind individual flags. The\n# PART IV/V cross-run + concept-graph machinery (concept_pivot, graded_novelty, cross_run_concepts /\n# _advisory / _structured_claims / _curation / _read_tools, fingerprint_universal, foresight_verify)\n# now ships ON in the product `Settings` default. That is an explicit experimental product choice,\n# not a claim that a frozen A/B or workload-wide cost/quality gate has cleared: the bundle can steer\n# prompts and candidate generation, change graded-novelty admission / pre-execution choices, and add\n# paid LLM work when its client and memory prerequisites are present. The bare-library `EngineOptions`\n# default stays lean (see tests/test_options_divergence.py — product side is the aggressive one) so a\n# toy `Engine(...)` in a test doesn't fire cross-run LLM work unasked. The trust/confirm/ablate QUALITY\n# bundle still ships OFF and is turned on by `thorough`. Every value here is reachable by hand — the\n# profile is a convenience, never a hidden mode.\n# CODEX AGENT: keep this rationale behavioral and evidence-based; do not relabel read-only storage\n# access or proposal-only governance as a no-op for agent behavior, cost, or proposal admission.\n#\n# `thorough` deliberately touches only QUALITY/TRUST machinery, not spend: it does NOT raise\n# max_nodes / eval_parallel (those are cost knobs the user owns). It enables multi-seed confirmation\n# (demote seed-lucky leaders), the reward-hack / leakage / critic monitors AND flips them from\n# advisory to *gating* (`trust_gate=\"gate\"` — a\n# flagged win can no longer be selected as best), ablation-driven refinement, and the prompt cues\n# (complexity / budget / failure-reflection / watchdog-reflection). Agentic novelty and the experimental\n# Part IV/V bundle are already product defaults, not profile changes. Effect is large; marginal cost is a\n# few extra evals.\nPROFILES: dict[str, dict] = {\n \"default\": {}, # explicit no-op alias for \"ship-as-is\" (== omitting profile)\n \"fast\": {}, # today's lean defaults, named for symmetry with `thorough`\n \"thorough\": {\n \"confirm_top_k\": 3,\n \"confirm_seeds\": 3,\n # novelty_gate intentionally NOT enabled here: duplicate-detection is the agentic Researcher's\n # job (it reads past experiments + find_analogous_across_runs and DECIDES), not an algorithmic\n # param-distance / embedding auto-reject that mis-fired (it nudged a good fresh idea onto a\n # twice-dead lineage — live node 61). Search stays as tools; the LLM judges novelty.\n \"operator_bandit\": True, # P4: adaptive operator mix over folded yields (GreedyTree)\n \"reward_hack_detect\": True,\n \"code_leakage_detect\": True,\n \"critic_check\": True,\n \"trust_gate\": \"gate\", # detectors stop gating a flagged node from WINNING (not just audit)\n \"ablate_every\": 3,\n \"complexity_cue\": True,\n \"budget_aware\": True,\n \"failure_reflection\": True,\n \"watchdog_reflection\": True, # feed recent live-watchdog (train-monitor/ASHA) flags to proposals\n \"reflection_priors\": True, # no-op unless memory_dir is set (cross-run priors)\n },\n}\n\n\n# The shipped agent-governance matrix (who may change which setting at runtime). Defined as a module\n# constant so the Engine's direct-construction default matches this shipped default exactly (the\n# EngineOptions \"Engine() == shipped defaults\" invariant) instead of diverging to an all-locked map.\nDEFAULT_AGENT_CONTROL: dict[str, list[str]] = {\n \"timeout\": [\"researcher\", \"strategist\"],\n # Layer-2 canonical names are the presented/default governance surface. `_agent_may` falls back\n # to an old snapshot's legacy grant only when its canonical counterpart is absent, so removing\n # duplicate defaults preserves resume authority while an explicit canonical revocation still wins.\n \"eval_parallel\": [\"strategist\"],\n \"llm_parallel\": [\"strategist\"],\n # Strategy-only pseudo-setting: the closed per-lane allocation inside the canonical LLM total.\n # It is not a launch Settings field; operators pin it through `set_strategy` and the Strategist\n # may reallocate it only while this grant is present.\n \"llm_lane_limits\": [\"strategist\"],\n \"max_nodes\": [\"strategist\", \"boss\"],\n \"max_eval_seconds\": [\"strategist\", \"boss\"],\n \"policy\": [\"strategist\"],\n # The policy NAME and its PARAMS are gated independently in `_apply_strategy`; grant both by default\n # so the Strategist that may switch to (say) MCTS may also apply the `c`/`eta` it decided — else its\n # tuned params are silently dropped and the recorded `active_strategy` diverges from the live engine.\n \"policy_params\": [\"strategist\"],\n \"n_seeds\": [\"strategist\"],\n \"ablate_every\": [\"strategist\"],\n \"merge_mode\": [\"strategist\"],\n \"complexity_cue\": [\"strategist\"],\n \"ablate_code_blocks\": [\"strategist\"],\n \"prefer_sweep\": [\"strategist\"],\n \"novelty_stance\": [\"strategist\"],\n \"developer\": [\"strategist\"],\n \"fidelity\": [\"strategist\", \"researcher\"],\n}\n\n\ndef default_agent_control() -> dict[str, list[str]]:\n \"\"\"A FRESH deep-ish copy of the shipped governance matrix (each role list copied so a caller can\n mutate its own map without touching the module constant). The SINGLE constructor of the default,\n called by both `Settings.agent_control`'s default_factory AND the Engine's direct-construction\n default — so \"Engine() == shipped defaults\" can't drift if the copy depth ever needs to change\n (e.g. nested values requiring copy.deepcopy), instead of two verbatim copy expressions.\"\"\"\n return {k: list(v) for k, v in DEFAULT_AGENT_CONTROL.items()}\n\n\nclass Settings(BaseSettings):\n \"\"\"The engine settings schema (every knob a run accepts).\n\n Timeout family — six distinct knobs, each owned by a different subsystem:\n - `timeout`: per-eval wall-clock budget for ONE experiment's evaluation (engine/eval).\n - `max_eval_timeout`: hard ceiling for a governed per-node Researcher timeout override.\n - `llm_timeout`: LLM request idle timeout — inter-token stall limit in stream mode\n (core.llm OpenAICompatibleClient).\n - `llm_header_timeout`: LLM first-byte (response-headers) window for stream attempts (core.llm).\n - `agent_time_budget_s`: wall-clock ceiling across a tool-using agent loop's turns (agents).\n - `dep_install_timeout`: per-package budget for auto-installing a missing dep (env self-prep).\n\n Config-source precedence is owned by `looplab.core.appconfig` (the loader) — see its module\n docstring for the one canonical order.\n \"\"\"\n # Config precedence: see appconfig.py — the loader owns the full order. The `.env` is read\n # so `looplab run`/`looplab ui` pick up LOOPLAB_LLM_BASE_URL etc. without exporting them by hand\n # (keys MUST carry the LOOPLAB_ prefix, same as the env vars). utf-8-sig tolerates a BOM from\n # Windows editors. The test suite disables this (tests/conftest.py) so a dev's real .env in the\n # repo root can't leak into assertions.\n model_config = SettingsConfigDict(\n env_prefix=\"LOOPLAB_\",\n env_file=\".env\",\n env_file_encoding=\"utf-8-sig\",\n extra=\"ignore\",\n allow_inf_nan=False,\n )\n\n @classmethod\n def settings_customise_sources(\n cls, settings_cls, init_settings, env_settings, dotenv_settings,\n file_secret_settings):\n \"\"\"Preserve source precedence across canonical/legacy parallelism names.\n\n Pydantic ranks init > env > dotenv correctly, but it ranks field names rather than semantic\n alias families. Promoting a legacy value inside each source before the merge makes an old\n explicit snapshot/file value beat a lower-priority canonical env value. ``appconfig`` also\n promotes its file/typed/--set sub-layers before combining them into the init source.\n \"\"\"\n def _with_parallel_aliases(source):\n return lambda: canonicalize_parallelism_source(source())\n\n return (\n _with_parallel_aliases(init_settings),\n _with_parallel_aliases(env_settings),\n _with_parallel_aliases(dotenv_settings),\n _with_parallel_aliases(file_secret_settings),\n )\n\n # Run profile (config-first preset, see PROFILES above). \"default\"/\"fast\" = today's lean\n # defaults; \"thorough\" turns the built quality/trust machinery ON in one word. A profile only\n # fills fields the user did NOT set — every knob it touches stays individually overridable.\n profile: str = \"default\"\n\n # Bounds (review C/⚪): a non-positive node/seed budget or timeout is never valid. Upper bounds are\n # equally load-bearing — the UI /start path writes these straight into the engine's env, and an\n # unbounded `eval_parallel` (or legacy `max_parallel`; e.g. 100000 from a crafted preflight) would\n # fan out that many sandboxes → resource exhaustion. Reject at config time, not mid-run. Ceilings\n # are generous; tests top out near 50.\n n_seeds: int = Field(default=3, ge=1, le=1024)\n max_nodes: int = Field(default=8, ge=1, le=1_000_000)\n # CODEX AGENT: legacy evaluation-width input. `eval_parallel` below is canonical and wins when set;\n # keep this field for old env/config/snapshots and direct Engine call sites. A width > 1 admits\n # concurrent evals, whose resource reservations may be CPU-only or span one/more GPUs — there is no\n # one-node/one-GPU promise. `max_parallel=0` is launch-time AUTO (detected GPU count, at least 1).\n max_parallel: int = Field(default=1, ge=0, le=1024)\n # CODEX AGENT: legacy build-width input. `llm_parallel` below is canonical and also controls the\n # shared provider-call total when positive. A width > 1 uses fresh pooled researcher/developer pairs;\n # it says nothing about the later eval's CPU/GPU reservation. Startup `0` derives build fan-out from\n # resolved eval width; a live Strategist/operator zero settles to 1. Without role_factory it clamps to 1.\n parallel_build: int = Field(default=1, ge=0, le=64)\n # Hypothesis-card Kanban re-architecture (docs/23, Layer 2). Two INDEPENDENT concurrency axes,\n # decoupled from each other so cost (LLM work) can be tuned separately from throughput (GPU evals).\n # These are the CANONICAL names going forward; `max_parallel`/`parallel_build` above are kept as thin\n # back-compat aliases (env vars / snapshots / ~100 Engine(...) call sites depend on the exact names,\n # so they cannot be renamed — CLAUDE.md). Resolution (orchestrator.__init__): a NEW name, when set,\n # WINS over its legacy alias; None => fall back to the legacy field => byte-identical to today.\n # `eval_parallel`: concurrent EVALS (the GPU/experiment consumer). None -> max_parallel; 0 = AUTO\n # (one experiment per detected GPU). This is the in-Run eval-concurrency ceiling; the resource\n # scheduler bin-packs declared footprints separately. Sibling local GPU-owning Runs serialize\n # through the conservative host-pool lease; external execution needs its own admission boundary.\n # `llm_parallel`: total concurrent LLM provider calls + BUILD fan-out. None -> legacy\n # parallel_build semantics without a shared total; 0 = startup AUTO (= resolved eval_parallel\n # for build fan-out, historical research overlap unbounded). A positive canonical value enables\n # the named-lane broker the Strategist can reallocate at cadence.\n eval_parallel: int | None = Field(default=None, ge=0, le=1024)\n llm_parallel: int | None = Field(default=None, ge=0, le=64)\n # Training-log monitor (I-series watchdog family): a per-eval background observer that tails the live\n # training log while a (often multi-hour) declared stage runs in a worker thread. ON in the product\n # surface (Settings). Its alert event is fold-ignored and cannot directly change lifecycle/champion;\n # CODEX AGENT: with watchdog_reflection on, that alert is nevertheless advice in a later Researcher\n # prompt and can change future proposals. It only fires on the command-eval path with an LLM client;\n # bare-library EngineOptions stays OFF. See test_options_divergence.\n # `_interval_s` is the BASE tick cadence (the effective one adapts to the per-experiment budget).\n train_monitor: bool = True\n train_monitor_interval_s: float = Field(default=600.0, gt=0)\n # Phase 3 — INTERVENTION (opt-in, separate from observation): let the monitor tree-kill a training the\n # LLM judges 'broken' (diverged / silent CPU fallback / not learning) EARLY, instead of burning the\n # whole budget. Off by default — the observer only WATCHES unless this is on. A kill only fires on a\n # 'broken' verdict (a plateau is 'watch', never killed) with confidence >= the threshold; the node then\n # fails normally (reason='monitor_broken'), so replay reconstructs it from that one terminal event.\n train_monitor_kill: bool = False\n train_monitor_kill_confidence: float = Field(default=0.8, ge=0.0, le=1.0)\n # ASHA live-curve watchdog (sibling of the training monitor): reads the latest INTERMEDIATE value of\n # the objective metric off the live log (reusing the eval's OWN metric reader). Finished-endpoint rank\n # remains advisory. An opt-in KILL additionally requires an operator-declared metric.resource_key and\n # enough sibling observations at exactly that resource; absent comparable evidence cannot stop a run.\n # Records a fold-ignored `asha_rank` diagnostic + trace span; the library default is OFF (off == today).\n asha_live: bool = True\n # Opt-in INTERVENTION (separate from the advisory signal): let the watchdog tree-kill a node whose\n # intermediate metric stays below the SAME-RESOURCE bar past a short grace window. Off by default —\n # it only surfaces endpoint rank unless this is on. A kill fails normally (asha_underperforming).\n asha_live_kill: bool = False\n # The bar sits at `asha_live_quantile` along a WORST->BEST ordering of the finished siblings' finals:\n # 0.5 = the median (a node worse than the median peer flags); a SMALLER value is more conservative —\n # it lowers the bar toward the WORST finished peer, so only a node worse than nearly all peers flags\n # (0.0 = worse than the worst). `min_siblings` is the floor of finished peers required before it ranks\n # at all, so it never acts on too little evidence.\n asha_live_quantile: float = Field(default=0.5, ge=0.0, le=1.0)\n asha_live_min_siblings: int = Field(default=3, ge=1)\n timeout: float = Field(default=30.0, gt=0)\n # Hard ceiling for the Researcher's governed per-node `Idea.eval_timeout`. One hour preserves the\n # existing heavy/neural-net authoring example while staying well below the sandbox's defensive\n # 24-hour subprocess ceiling. This is operator-owned run config: agents may request less or more,\n # but the accepted action is always clamped here after the `agent_control.timeout` permission gate.\n max_eval_timeout: float = Field(default=3600.0, gt=0, le=24 * 3600.0)\n # Intra-node sweep: a sweep node runs a whole grid in one process, so it gets this multiple of\n # `timeout` as its wall-clock budget (solution.py path; RepoTasks use their per-profile timeout).\n sweep_timeout_mult: float = Field(default=8.0, ge=1)\n # Sandbox tier (ADR-13): \"trusted_local\" (subprocess, no Docker) for the CLI;\n # \"untrusted\" (Docker --network none, shared-kernel runtime) for hosted/multi-tenant UI;\n # \"hostile\" (untrusted + a true-isolation OCI runtime, gVisor `runsc` by default / Kata) for\n # running untrusted code. See runtime/sandbox.py::make_sandbox for the tier selection.\n trust_mode: str = \"trusted_local\"\n # Image for the untrusted command-eval tier (RepoTask, Phase 4): the framework's deps\n # should be baked in (the container runs --network none, so a pip setup can't fetch).\n docker_image: str = \"python:3.12-slim\"\n # Resource caps for the untrusted/hostile Docker tier (passed to `docker run --memory/--cpus`).\n # The default 4g bound protects other tenants from an OOM, but a model-training MLE-bench eval\n # routinely needs more — raise it (e.g. \"16g\") for such runs. \"\" disables the cap (unbounded).\n # Ignored by the trusted_local (subprocess) tier.\n sandbox_memory: str = \"4g\"\n sandbox_cpus: str = \"\"\n # Best-effort host-OOM guard for the TRUSTED-LOCAL (subprocess) tier. When set (e.g. \"8g\"), each\n # eval child gets an RLIMIT_AS address-space cap so a runaway trainer hits MemoryError instead of\n # OOM-killing the whole host (and the engine). POSIX only; a no-op on Windows. Default \"\" = OFF:\n # RLIMIT_AS bounds VIRTUAL memory, and CUDA/torch reserve tens of GB of virtual without using it,\n # so a default cap would spuriously kill GPU evals — leave it off for those and use the Docker\n # tier's real --memory cgroup bound (sandbox_memory) instead. Ideal for CPU/numpy/sklearn runs.\n sandbox_memory_local: str = \"\"\n # Best-effort disk-fill guard for the TRUSTED-LOCAL tier (P1-5): an RLIMIT_FSIZE cap on the size of\n # any single file an eval child writes (e.g. \"2g\"), so a runaway that writes an unbounded file gets\n # SIGXFSZ instead of filling the host disk. POSIX only. Default \"\" = OFF (large model checkpoints\n # need big files); set it for tasks that write only small artifacts.\n sandbox_fsize_local: str = \"\"\n # Search policy (ADR-2): \"greedy\" | \"evolutionary\" | \"mcts\" | \"asha\".\n policy: str = \"greedy\"\n # Ablation-driven refinement (I7): every N improves, ablate the best to find the\n # highest-impact parameter and refine it. 0 = off. (GreedyTree only.)\n ablate_every: int = 0\n # A0a (MLE-STAR): when ablating, treat each generated pipeline *code block* (not just a\n # numeric param) as an ablation unit — neutralize a block, measure the metric delta, refine\n # the highest-impact block. Off => the classic numeric-param ablation. Config-first knob.\n ablate_code_blocks: bool = False\n # A0b: real merge/ensembling. \"mean\" = legacy mean-param merge; \"ensemble\" = the Developer\n # writes a code-recombination ensemble over the two parents' solutions (verified: agent-proposed\n # ensembling 37.9%->43.9%). \"auto\" (default) = ensemble whenever the Developer actually\n # generates code (LLM/agent backends; declared via `is_code_generating`), mean for the\n # templated/toy developers where a code ensemble is meaningless — the evidence says code\n # recombination is the strongest single merge (removing it costs ~9 pp), so it is the default\n # wherever it can work. Falls back to mean when the Developer can't ensemble.\n merge_mode: str = \"auto\"\n # A0d (AIRA): inject a dynamic complexity hint into the draft/improve prompt keyed on the\n # node's child count (few children -> keep minimal; many -> escalate to ensembling/HPO).\n complexity_cue: bool = False\n # I1 (CAAFE-style) LLM feature-engineering: instruct the proposer/developer to add\n # semantically-meaningful engineered features (ratios, interactions, aggregations) as code. The\n # existing cross-validation eval + best-selection is the CV GATE — a feature set that doesn't\n # improve CV is never selected (feature engineering is non-universal, so this gate is mandatory).\n # Tabular tasks (those exposing column data). Off by default.\n feature_engineering: bool = False\n # A5: surface the REMAINING eval-compute budget into the proposal prompt so the Researcher can\n # reason over how much compute is left (explore broad while flush, exploit/cheapen when low).\n # No-op unless a max_eval_seconds budget is set.\n budget_aware: bool = False\n # A4 (LATS-style): feed a summary of the most recent FAILED branches (operator + error reason)\n # back into the proposal prompt so the proposer reflects on and avoids repeating them. ON by\n # default: it is SELECTIVE by construction (injects only when recent failures exist — the\n # evidenced form of reflection; reflecting at every step shows no gains, arXiv:2506.12928).\n failure_reflection: bool = True\n # Feed the LIVE-WATCHDOG observations (train-monitor health verdicts + ASHA intermediate-rank flags)\n # of recent experiments back into the proposal prompt, so the Researcher avoids re-proposing a config\n # whose TRAINING was already observed to be weak — even when the watchdog kills are off (default) and\n # the node ran to completion (those diagnostics are fold-ignored, so failure_reflection never sees\n # them). ON by default and SELECTIVE like failure_reflection (injects only when a recent flag exists).\n watchdog_reflection: bool = True\n # C3 deep test-driven repair: hand the Developer the failure taxonomy + a structured \"reproduce\n # then fix\" directive on debug, not just the raw stderr tail. ON by default (product surface); the\n # conservative library default in EngineOptions stays off, per that module's contract.\n deep_repair: bool = True\n # === Inline crash repair ==============================================================\n # Hybrid in-node crash repair: when an LLM-generated node CRASHES at runtime (mechanical errors\n # — bad import, removed kwarg, typo), the agent triages it and may repair the code IN PLACE within\n # the same eval (cheap; does NOT consume max_nodes and does NOT add a node to the search tree).\n # Semantic failures still flow to a new idea/debug node via the policy. ON by default; set False\n # to restore the prior behavior (every crash waits for a budgeted inter-node debug node).\n inline_repair: bool = True\n # Max in-place repair attempts per node before it fails normally. Default 0 = UNLIMITED: the same\n # agent keeps fixing + re-evaluating IN THE SAME node until it produces a clean metric — no new\n # node just to retry. Runaway is prevented not by a count but by the anti-stuck guard\n # (`inline_repair_stuck_repeat`): when the SAME error signature repeats with no progress, or the\n # agent's crash-triage says \"abandon\", the node fails. Set a positive N to cap the retries instead.\n inline_repair_attempts: int = Field(default=0, ge=0)\n # Anti-stuck: abandon in-node repair when the SAME error recurs this many times in a row (no\n # progress). Keeps \"unlimited\" repair from looping forever on an unfixable error.\n inline_repair_stuck_repeat: int = Field(default=4, ge=2)\n # Which failure reasons (_failure_reason: crash|timeout|oom|setup|no_metric|drift) are eligible for\n # inline repair. Default: mechanical crashes, timeouts AND OOM-kills — a timeout/OOM means the code\n # was too slow / too memory-hungry for the budget (or the pod's cgroup limit), not that the idea is\n # wrong, so the agent repairs it by reducing compute/memory (fewer estimators/epochs/folds/seeds, a\n # lighter model, a smaller batch, subsampling) instead of letting the node die unrecovered. Env\n # override expects a JSON array.\n inline_repair_reasons: tuple[str, ...] = (\"crash\", \"timeout\", \"oom\")\n # Cost ceiling for in-node repair of a MULTI-STAGE eval: the max number of FULL pipeline re-runs\n # (i.e. re-trains from the first stage) the inline-repair loop may do before abandoning to the\n # inter-node debug operator. A late-stage failure (a broken `score`/eval script that didn't touch\n # `train`) REUSES the completed train checkpoint and re-runs only from the failed stage — that is\n # CHEAP and NOT counted here; only a repair that changes an EARLIER stage's code (train.py/loss.py/…)\n # forces a full re-train, which each costs the whole training time. Without this bound, a repair that\n # keeps changing training code could burn many full trains (the anti-stuck guard is error-signature\n # based, not cost based). 0 = unlimited (legacy behavior). Single-command evals ignore it.\n inline_repair_retrain_cap: int = Field(default=2, ge=0)\n # Environment self-prep: when a solution crashes purely because a KNOWN library isn't installed\n # (ModuleNotFoundError), the engine pip-installs it into the eval interpreter and re-runs — so a\n # torch/XGBoost/CatBoost idea (e.g. a GRU model) actually runs on a fresh box instead of being\n # thrown away as `idea_rejected`. Trusted_local tier only (the untrusted/hostile Docker tiers run\n # --network none). A typo'd or local helper module is NOT installed (that's a code bug to repair).\n auto_install_deps: bool = True\n # Per-package wall-clock budget for an auto-install (seconds). Generous: large wheels like torch\n # can take minutes to download/build.\n dep_install_timeout: float = Field(default=900.0, gt=0)\n # --- Agent governance: who may change a setting at runtime (per-setting allow-list of roles) ---\n # Roles: \"strategist\" (A7 meta-controller; run-wide), \"boss\" (run-chat operator-proxy; run-wide),\n # \"researcher\" (per-experiment proposer; PER-NODE — e.g. set a longer eval timeout for a neural-net\n # idea). A setting ABSENT from this map is LOCKED — no agent may change it, only a human via the\n # snapshot/UI. The sole conditional exception is Strategy-only `card_scoring`: Card mode grants\n # it implicitly without changing the default flag-off snapshot, while an explicit [] revokes it.\n # The gate is ENFORCED at every AGENT seam via `_agent_may` — the strategist's whole\n # control surface (policy/operators/novelty_stance/prefer_sweep/developer/fidelity/timeout/\n # eval_parallel/llm_parallel) is checked in `_apply_strategy`, so removing a role from a knob locks it,\n # not just greys out the UI pill. The boss/max_* grants document boss-relevant knobs, but a\n # `budget_extend` is a HUMAN control intent (the boss action-builder can only emit `add_nodes`), so\n # it is applied as-is — the human always wins via the UI/snapshot. Defaults are conservative: resource/\n # search-shape knobs the agents already reason about; provider infra (llm_model/base_url/api_key,\n # docker_image) stays locked. The UI shows S/B/R pills per setting. `timeout` granted to the\n # researcher IS the \"auto\" per-node mode — the researcher sizes each experiment's wall-clock\n # (Idea.eval_timeout); the config `timeout` is the fallback for nodes it doesn't size. Env override\n # expects a JSON object. (`fidelity`/`novelty_stance`/`prefer_sweep`/`developer` are governance keys\n # for the strategist's per-node dials — not 1:1 Settings fields, but gated the same way.) The default\n # lives in the module-level DEFAULT_AGENT_CONTROL so the Engine's direct-construction default matches.\n agent_control: dict[str, list[str]] = Field(default_factory=default_agent_control)\n # C1 (Agentless localization): for repo tasks, rank the source files most relevant to the most\n # recent failure (traceback + identifiers) and surface them in the proposal/repair prompt so the\n # Developer edits the right place. Off by default; repo tasks only.\n localize_faults: bool = False\n # A2 surrogate-guided proposal (BO-lite): fit a k-NN surrogate over (params->metric) and propose\n # by acquisition instead of random/hill-climb. Numeric-bounds tasks only; bootstraps via the\n # base Researcher. Off by default. `surrogate_explore` = UCB-style exploration weight.\n surrogate_proposer: bool = False\n surrogate_explore: float = 0.1\n # E2 researcher panel: generate K candidate ideas per proposal and keep the one ranked best by a\n # cheap empirical surrogate over the (params->metric) history (NOT an LLM-judge). 1 = off.\n researcher_panel: int = 1\n # A1 ASHA / successive-halving: reduction factor (keep top 1/eta per rung) and rung budget.\n asha_eta: int = Field(default=3, ge=2)\n # Rung-0 width for ASHA/BOHB (the wide base of cheap drafts). 0 = use n_seeds (default,\n # preserving prior behavior); set >0 to seed a wider/narrower base independent of n_seeds.\n asha_rung_nodes: int = Field(default=0, ge=0)\n # A6 proxy/predictive scoring: cheaply rank a candidate's potential from early-stage signals\n # and skip a full eval for the doomed bottom fraction. 0.0 = off (no candidate skipped).\n proxy_scoring: bool = False\n proxy_kill_fraction: float = Field(default=0.0, ge=0.0, le=0.9)\n # === Novelty / dedup ==================================================================\n # E1 novelty/dedup gate: reject near-duplicate proposals (normalized param-space distance below\n # `novelty_epsilon`) by deterministically nudging them off the duplicate — stops the search\n # wasting evals re-trying the same idea. Audit event `novelty_rejected`. Off by default.\n # Novelty/dedup MODE selector: how a fresh proposal is checked against already-tried experiments.\n # \"off\" — no explicit gate; the agentic Researcher's own read-the-history judgment is trusted.\n # \"algo\" — the deterministic gate: numeric param-distance (`novelty_epsilon`) + optional embedding\n # similarity (`novelty_semantic`). Cheap, no LLM call, but can't explain itself.\n # \"llm\" — an LLM adjudicates (reads the real experiments via tools) whether the idea is a\n # near-duplicate and, if so, asks the Researcher once more for something different. One\n # extra LLM call per proposal — highest quality, follows the \"let the LLM decide\" line.\n novelty_mode: str = \"llm\"\n # Legacy sub-toggles, honored by the \"algo\" mode (and back-compat: novelty_gate=True forces \"algo\"):\n novelty_gate: bool = False\n novelty_epsilon: float = 0.05\n # T5 semantic novelty (active whenever the deterministic gate runs — novelty_mode=algo,\n # novelty_gate=true which is the legacy alias for algo, OR the Strategist novelty stance is\n # \"explore\"; a no-op only under novelty_mode=llm/off with a non-explore stance): reject a proposal\n # whose idea TEXT embeds within\n # `novelty_semantic_threshold` cosine of an existing node's. OFF BY DEFAULT: novelty is the agentic\n # Researcher's call, made by READING the actual prior experiments (read_experiment /\n # find_analogous_across_runs / list_experiments), NOT an embedding-similarity auto-reject that can't\n # explain itself and mis-fires on paraphrases. Semantic/param search stays available as TOOLS in the\n # Researcher's context; the LLM decides whether an idea is a true duplicate.\n novelty_semantic: bool = False\n novelty_semantic_threshold: float = Field(default=0.92, ge=0.5, le=1.0)\n # T10: debug-lineage depth bound for every policy — how many error-feedback repairs a failing\n # lineage gets before it is abandoned. The old default (1) abandoned lineages after ONE repair;\n # multi-turn/deeper debugging is a verified lever (AIRA2 ReAct debug +5.5 percentile points).\n debug_depth: int = Field(default=2, ge=1)\n # P4 operator bandit (GreedyTree): replace the fixed merge/ablate cadences with a\n # deterministic UCB over per-operator yield (Δmetric per eval-second, folded from the run).\n # Off by default (the cadences are well-tested and the bandit has no direct published\n # ablation); `thorough` turns it on.\n operator_bandit: bool = False\n # P1 hypothesis ledger: ask the Researcher to state the one-line hypothesis each experiment tests,\n # register deep-research directions as hypotheses, and track them to a verdict on the board. ON by\n # default. CODEX AGENT: the board is shown to the Researcher and can be foresight-ordered, so it can\n # steer later proposals even though it never directly re-ranks already-evaluated nodes. Set False\n # to drop the prompt nudge + registration.\n track_hypotheses: bool = True\n # E4/M2/M3 reflection-memory -> priors (gradient-free cross-run meta-learning): at run end distill\n # the winner into `<memory_dir>/meta_notes.jsonl` AND structured lessons (incl. NEGATIVE results:\n # tested/abandoned hypotheses + failure themes) into `lessons.jsonl` with a task fingerprint; at run\n # start, inject exact-task notes + fingerprint-matched lessons from SIMILAR tasks into the proposal\n # prompt. ON by default — but a NO-OP until `memory_dir` is set (that's where cross-run memory lives).\n reflection_priors: bool = True\n # M6 comparative lessons (MARS \"comparative reflective memory\", doc 13 §7 item 2): distill\n # credit-assigned lessons from PAIRS of solutions — which SPECIFIC change made a child beat\n # (or regress from) its parent, and what fixed a failure — on top of the one-shot run-end\n # reflection. At most one extra LLM call per distillation (all pairs batched); offline a\n # deterministic param-diff credit stands in. Gated on reflection_priors + memory_dir like the\n # rest of cross-run memory — and since memory_dir has a default (~/.looplab/memory), this is\n # ACTIVE out of the box; clear memory_dir to disable cross-run memory wholesale.\n comparative_lessons: bool = True\n # M6 mid-run cadences (doc 13 §7 item 5 — the AgentRxiv live-share pattern): every N created\n # nodes, WRITE comparative lessons to the shared cross-run store during the run (lessons_every\n # — one batched LLM call per firing) and RE-READ the store so lessons distilled by CONCURRENT\n # runs reach this run's proposals (lessons_refresh_every; file re-read, no LLM call, skipped\n # when the store is unchanged). ON by default (every 4 nodes) and, like comparative_lessons\n # above, live out of the box because memory_dir defaults on; 0 = off — run-end write /\n # run-start read only, the pre-M6 behavior.\n lessons_every: int = Field(default=4, ge=0)\n lessons_refresh_every: int = Field(default=4, ge=0)\n # B3 output redaction: mask credentials (known key shapes + high-entropy tokens) in the\n # stdout/stderr tail before it is persisted to the event log / spans / UI — a leaked secret in a\n # print()/traceback must not land in the durable log. Off by default; recommend on for untrusted.\n redact_output: bool = False\n # B5 reward-hacking detector: a host-side monitor that flags suspicious wins (grader/answer-key\n # access, runtime writes to frozen files, suspiciously-perfect metrics) as a `reward_hack_suspected`\n # audit event in the Trust panel. Off by default. Whether a flag CHANGES selection is governed by\n # `trust_gate` below (default: audit-only, never changes selection).\n reward_hack_detect: bool = False\n # Trust enforcement (T2): what a reward-hack / data-leakage flag actually DOES to selection.\n # \"audit\" (default) = surface it in the Trust panel, never change selection (today's behavior);\n # \"gate\" = a flagged node can no longer be selected as BEST **and isn't bred/confirmed from**\n # (breed_excluded — see replay._apply_trust_gate / models.breedable_nodes), but stays\n # FEASIBLE so it still counts for diversity/audit — closes the \"a hacked/leaky node can\n # win OR seed its lineage\" hole;\n # \"block\" = additionally mark it fully INFEASIBLE (removed from feasible_nodes entirely).\n # Deliberately gates only high-precision CHEATING/LEAKAGE signals. Broad `critic:*` findings stay\n # advisory; CODEX AGENT: `critic:hardcoded_metric` is the one narrow high-precision critic exception\n # and gates because it requires a literal metric with no computed assignment anywhere.\n trust_gate: str = \"audit\"\n # I3 data-centric: static code-leakage scan of each evaluated solution (fit-before-split,\n # fit-on-test) surfaced into the Trust panel alongside reward-hack flags. Off by default.\n code_leakage_detect: bool = False\n # 4.4 sandbox instrumentation (needs reward_hack_detect): after each eval, flag a RUNTIME write\n # to a protected/frozen file (an answer key or grader tampered mid-run) — behavioral evidence a\n # static code scan misses. ON by default; only fires when the reward-hack detector is on.\n workdir_audit: bool = True\n # D8 decoupled research Verifier: check every Deep-Research memo's CLAIMS against their cited\n # evidence (node ids / urls) before recording — synthesis is the documented weak link (Kosmos\n # 57.9% accurate). Deterministic layer always; an LLM rubric pass when the DeepResearcher has a\n # client. The verdict cannot change this run's node/champion, but finalize uses aligned supported\n # verdicts as the evidence gate for persisted D8 cross-run claims; unavailable/misaligned stays\n # unverified. ON by default (no-op with no memo/claims).\n research_verify: bool = True\n # C4 independent critic: an execution-free critic of each solution (stub / hardcoded-metric /\n # params-ignored; on host-graded tasks the metric checks become a submission-output check)\n # surfaced in the Trust panel. Broad findings are advisory; `critic:hardcoded_metric` can gate under\n # trust_gate=gate/block. Off by default.\n critic_check: bool = False\n # A7 Strategist (NEW, user-requested): optional LLM/rule meta-controller that picks the search\n # policy/allocator + operator mix + fidelity (+ Developer backend) per situation. Config-first:\n # every choice the Strategist makes is also a direct knob. Defaults to \"agent\" so the agent adapts\n # the search policy/operators/fidelity per situation; set \"off\" for static config-driven search.\n # \"agent\" = a tool-using Strategist that READS the run/data/sibling-runs/KB/memory (B1-guarded)\n # before deciding, instead of the single-shot \"llm\" call over aggregate stats.\n strategist_backend: str = \"agent\" # \"off\" | \"rule\" | \"llm\" | \"agent\" — default AGENTIC: the\n # Strategist READS the run/data/sibling-runs/KB/memory with tools before deciding, not a single-shot\n # call over aggregate stats. \"llm\" = the old non-agentic single-shot; \"rule\"/\"off\" = no LLM.\n strategist_every: int = Field(default=3, ge=1) # consult cadence (created nodes)\n # PART V (F1): concept CLASSIFIER re-tag + consolidation cadence, DECOUPLED from strategist_every. The\n # LLM concept map is heavier and slower-moving than a strategy consult, so it refreshes on its own\n # (sparser) interval. Researcher-authored idea.concepts still fold immediately at node_created — this\n # only paces the classifier-evidence + consolidation refresh (and the concept-coverage pivot snapshot).\n concept_retag_every: int = Field(default=30, ge=1)\n # === Confirmation & holdout ===========================================================\n # Multi-seed confirmation (I12, ADR-15): confirm the top-k under N seeds before\n # finishing. 0 disables (default). Only meaningful when eval has variance.\n confirm_top_k: int = 0\n confirm_seeds: int = 0\n # D1 seed-holdout discipline: the FIRST seed the confirm phase uses. Search evals run with the\n # implicit seed 0 (LOOPLAB_EVAL_SEED unset -> \"0\"), so a base of 1 keeps every confirm split\n # DISJOINT from anything the search optimized against — the confirm metric is then a\n # generalization signal, not a re-measurement (AIRA: selecting on a signal the search saw\n # overfits by 9-13 pp). Set 0 to restore the legacy overlapping seeds 0..N-1.\n confirm_seed_base: int = Field(default=1, ge=0)\n # D1 holdout-gated promotion (B6, Arbor-style): for host-graded tasks, reserve this fraction of\n # the held-out labels as a FINAL holdout partition the search never sees — every search/confirm\n # eval is scored on the remaining rows only, and at finish the val-top-k are re-scored on the\n # holdout partition (free: the predictions already exist). 0.0 = off (legacy full-label scoring).\n holdout_fraction: float = Field(default=0.25, ge=0.0, le=0.9)\n # Champion selection prefers the HOLDOUT metric among the nodes that have one (the val-top-k).\n # Recorded in run_started so replay applies the same rule; old logs fold to False (legacy pick).\n # The per-node val-holdout `generalization_gap` folds into the Trust panel either way.\n holdout_select: bool = True\n # How many val-leaders get a holdout evaluation at finish (host-graded tasks).\n holdout_top_k: int = Field(default=3, ge=1)\n # R1-c / Part IV — calibrated §12-verifier tie-break in best-selection. When a fresh node's robust\n # metric EXACTLY TIES an already-eligible node, the engine runs the §12 verifier (grounded on each\n # node's realized result, `selection_criteria`) and records a soundness score per node. The tie-break\n # then applies across the WHOLE selection path: the mean pick breaks a robust_metric tie by the score,\n # and — when `holdout_select` is on — the holdout-slot ranking prefers the sounder node so it isn't\n # denied a holdout eval, and the final holdout override breaks a holdout_metric tie by it too. Strictly\n # a TIE-BREAK — it can NEVER move a node ahead of a strictly-better metric (§21.7 advisory-never-\n # overrides). Best-effort: verification is LAZY (only fires on an actual tie) and bounded per cadence,\n # so a large/late tie stays on deterministic metric+id order unless one atomic treatment scores every\n # member. Recorded in run_started so\n # replay applies the same rule; old logs fold to False (legacy pick). Opt-in (default off); needs a\n # reachable LLM client. Enable only after validating calibration offline with `verifier.calibrate`.\n # See trust/verifier.py + core/fitness.py.\n select_verifier: bool = False\n # R1-d (§21.19): widen the verifier tie-break from EXACT-metric to a conservative STATISTICAL tie.\n # The band is capped by both the metric leader's SE and the pooled SE-of-difference, so challenger\n # variance cannot widen it and a significant difference is never a tie (§21.7). Off -> exact only.\n verifier_ci_tie: bool = False\n # Verifier sample count for `select_verifier` (the §12 repeated-sampling expectation). 3 tames the\n # single-shot variance; 1 = single-shot (cheaper, noisier).\n select_verifier_samples: int = Field(default=3, ge=1, le=32)\n # Budget (I13): hard wall-clock ceiling; the run aborts cleanly when exceeded.\n max_seconds: float | None = None\n # Eval-compute budget (#2): hard ceiling on cumulative time spent INSIDE evals (training\n # runs), separate from wall-clock. Survives resume (summed from the event log). The real\n # guard against a silent multi-hour sweep when an eval is a minutes-hours training run.\n max_eval_seconds: float | None = None\n # Cross-run memory (I19, ADR-10): if set, the best result of each run is stored as\n # a case here, and the cases become retrievable knowledge for future runs.\n memory_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"memory\"))\n # HITL (I21, ADR-11): pause for human approval of the final best before finishing.\n require_approval: bool = False\n # Diversity archive (I22): niche bucket width in parameter space.\n archive_resolution: float = 1.0\n # Coverage read-model (narrowing signal): at the strategist cadence, compute a breadth summary\n # (themes / param-niches / theme entropy / dominant-theme fraction) from the folded run, record\n # it as a `coverage_snapshot` audit event, and surface it into the Strategist's decision context.\n # Deterministic, cheap, and purely additive context — no search-behavior change on its own. On by\n # default (like the rest of the always-on situational context). See search/coverage.py.\n coverage_context: bool = True\n # PART IV Phase 2a live steering (§21.11/§21.13). When on, the `concept_retag_every` cadence (NOT\n # `strategist_every` — the producer gates on `_should_consult_concepts`, which uses the seed boundary\n # + `concept_retag_every`, default 30) records a\n # concept-graph coverage + uncovered-region snapshot (deterministic heuristic tagger over the\n # task-type skeleton) and, on an `explore` stance, the Researcher's novelty hint names the exact\n # uncovered regions (\"0 coverage in {negatives/external-mining, distillation} — go there\") instead\n # of the vague \"broaden\". ON by default in the product Settings (ce4a379), EngineOptions off: it\n # only enriches an already-mode-gated explore hint, never forces exploration, and no-ops for a task\n # with no curated concept skeleton. The snapshot is auditable, but its prompt cue can change future\n # candidate generation; it does not directly rewrite best-metric selection. See search/concept_graph.py.\n # DESIGN NOTE (2026-07-17 critique): now ON unconditionally, but a toy / non-ML task yields EMPTY concept\n # tags while still paying the per-node LLM concept cost (~10 calls/node with the full Part IV/V bundle).\n # Consider AUTO-QUIESCING: if the heuristic tagger returns empty for the first N nodes (no concept signal),\n # skip the LLM concept work for the run — keeps it on for ML tasks, free on toy/degenerate ones.\n concept_pivot: bool = True\n # PART IV Phase 2b — D3 graded novelty into the LIVE novelty gate (§21.4/§21.13). When on and the\n # task has a curated concept skeleton, the novelty gate first grades a proposal over the concept\n # graph: a level-4 \"same direction, DIFFERENT implementation\" or a level-5 \"re-opens a\n # wrongly-abandoned FAILED direction\" proposal is ALLOWED through unchanged — the flat LLM/semantic\n # dedup gate can't tell \"this DCL tweak\" from \"the whole DCL branch\" and would wrongly reject a\n # legitimate variant or never re-open a sound-but-killed direction (the node_63 archetype). Levels\n # 1/2/3 (identical / near-dup / prior-run) still fall through to the existing gate. Deterministic\n # (heuristic tagger, no LLM), audit event `novelty_graded`, replay-safe. ON by default in the product\n # Settings (ce4a379), EngineOptions off; no-ops for a task with no skeleton. See search/graded_novelty.py.\n graded_novelty: bool = True\n # PART IV Phase 2b — D7 capability-expansion forced-jump DIRECTIVE (§21.8/§21.13, issue #7). When on\n # and the concept-graph cadence detects action-space LOCK-IN (the search has stayed inside one D5\n # branch for a long consecutive streak) on an `explore` stance, the Researcher's novelty hint\n # ESCALATES from \"broaden\" to \"expand the ACTION SPACE — build the missing infra (external mining,\n # a new data pipeline, a different eval), do NOT swap another {locked} lever.\" CODEX AGENT: the same\n # gate also stamps the proposal's authoritative operator as `expand`; that node competes normally\n # under SearchFitness and its separate operator yield is measured. It does not force a winner.\n # Reads the 2a concept-coverage snapshot, so it needs `concept_pivot` to record one. OPT-IN\n # (default off). See engine/proposal_cues.py + search/lock_in.py.\n capability_expansion: bool = False\n # PART IV cross-run Step 0 (§21.20.12/§21.20.13). Universal task-fingerprint tokenization: drop the\n # ASCII-only `[a-z0-9]` allowlist on goal keywords so a non-Latin goal (Russian, CJK, …) is NOT\n # silently dropped from its cross-run fingerprint (today such a goal keys only on kind/dir/metric and\n # never reaches SIMILAR-task priors/lessons/cases). Uses `[^\\W_]+`/`.casefold()` — same splitting as\n # before, any script. ON by default in the product Settings (ce4a379); the bare-library EngineOptions\n # default stays off, so a run pinned to it won't silently re-key a portfolio mid-flight. See engine/memory.py.\n fingerprint_universal: bool = True\n # PART IV cross-run Step 2 (§21.20). Fill graded-novelty's `prior_concepts` from the cross-run\n # ConceptCapsuleStore: at run end write a per-run concept capsule (the shipped `node_concepts` tags +\n # best outcome, keyed by task_fingerprint) to `memory_dir`; when a later SIMILAR run proposes an idea\n # whose concept was tried before, SURFACE the prior outcome as a `cross_run_prior` audit event (never\n # reject — D3 level 3). Audit-only, off the selection path; ON by default in the product Settings\n # (ce4a379), EngineOptions off. Needs a `memory_dir` to share capsules.\n # See engine/memory.py (ConceptCapsuleStore) + engine/novelty.py.\n # PREREQUISITES (CODEX — this flag is NOT standalone): the WRITE needs per-run concept tags\n # (`node_concepts`, produced by `concept_pivot`/F1 tagging) — no tags => no capsule; the SURFACE path\n # runs inside the graded-novelty precheck, so it also needs `graded_novelty` on. With only this flag +\n # `memory_dir` it persists/surfaces nothing. Enable `concept_pivot` + `graded_novelty` alongside it.\n # A non-Latin (Russian/CJK) portfolio should ALSO set `fingerprint_universal`, else the capsule's goal\n # tokens are dropped and its fingerprint over-matches on kind/dir/metric alone (the novelty direction\n # gate narrows this, but goal-word similarity is still lost).\n cross_run_concepts: bool = True\n # PART V (B): run-base + node-DELTA concept authoring. When on, once the first evaluated node has\n # authored concepts the engine seeds RunState.run_base_concepts from them (a one-shot EV_RUN_CONCEPTS,\n # idempotent because it only fires while the base is empty), and the Researcher's proposal hint tells it\n # to author only `concepts_added`/`concepts_removed` vs the run base + its parent — minimal per-node\n # annotations that inherit down the DAG (a swap propagates). Off -> every node authors its full concept\n # set as before. Product-on; the bare EngineOptions mirror defaults off (test_options_divergence).\n concept_run_base: bool = True\n # PART IV cross-run Step 5 advisory (§21.20.5). Fold the bounded cross-run CONTEXT PACK — evidence-\n # grounded claims with BOTH support and counter-evidence (Step 4) + a portfolio-coverage line (Step 3) —\n # into the Researcher's proposal prompt, exactly like the E4 cross-run prior note. Advisory ONLY: it is\n # prompt-grounding: it can change the Researcher's later candidates, but does not directly rewrite\n # best-metric selection (§21.7). Reads `memory_dir` (lessons.jsonl + concept_capsules.jsonl); \"\" when\n # empty. ON by explicit experimental product choice; no frozen A/B validation is claimed. The\n # bare-library EngineOptions default stays off (engine/options.py, frozen in\n # test_options_divergence). See engine/proposal_cues.py.\n cross_run_advisory: bool = True\n # PART IV cross-run §21.20.13 (full CR of the lean fuzzy claim merge). Switch the claim read-model to the\n # SCOPE+POLARITY-safe STRUCTURED claim key (engine/claim_key.py): claims from different tasks never\n # merge, opposite polarity (\"X helps\" vs \"X never helps\") is surfaced as a CONTRADICTION instead of being\n # collapsed, paraphrase/inflection variants group by exact structured key (no transitive over-merge), and\n # operator governance is scope-precise (a decision in task A cannot reach a same-worded claim in task B).\n # Affects the `cross_run_advisory` context pack only; ON by default in the product Settings (ce4a379);\n # the bare-library EngineOptions default stays off (engine/options.py). See engine/claims.py.\n cross_run_structured_claims: bool = True\n # PART IV cross-run §22.4 (AGENTIC taxonomy steward). At finalize, when an LLM client is available, let\n # the concept steward (engine/concept_steward.py) review the freshly-updated portfolio concept graph and\n # PROPOSE a curation — merge duplicate slugs / split conflated ones / purge noise. Proposals are LOGGED to\n # `concept_curation_log.jsonl` for operator ratification (via the serve/CLI governance surface); the LLM\n # never mutates fold state. The calls run synchronously during finalize, so they add latency and may add\n # paid inference; a caught steward failure does not prevent terminal completion or trigger a retry.\n # ON by explicit experimental product choice when `memory_dir` + an LLM backend are present; the\n # bare-library EngineOptions default stays off (engine/options.py). See engine/lessons.py.\n cross_run_curation: bool = True\n # Deprecated compatibility flag. Steward output is untrusted and finalize is proposal-only regardless\n # of this value; retained so old snapshots still validate and logs can disclose that auto was requested.\n cross_run_curation_auto: bool = False\n # Role backend (ADR-7/14): \"toy\" (offline optimizer) | \"llm\" (live model).\n backend: str = \"toy\"\n # Developer backend (ADR-7): \"default\" (templated/LLM from the task) or an external\n # CLI coding agent: \"opencode\" | \"aider\" | \"goose\" | \"continue\".\n developer_backend: str = \"default\"\n # C2 best-of-N: generate N candidate implementations per node and keep the best by an\n # execution-free reward (static validity + metric-print). 1 = off. In-house LLM developer only\n # (not external agents — ADR-7 cost rule). The top SWE-bench reliability lever for weak models.\n best_of_n: int = 1\n # D10 list-wise best-of-N selection: when the top static-scorers TIE, break the tie with a\n # comparative LLM selection over the candidates presented together (+~3 pts vs pointwise on\n # GAIA, arXiv:2506.12928). The static score stays the primary filter — the LLM is a weak\n # comparative prior, never the sole oracle. ON by default (no-op when best_of_n==1).\n best_of_n_listwise: bool = True\n # === Foresight / predict-before-execute ===============================================\n # FOREAGENT predict-before-execute (arXiv:2601.05930): use the LLM as an IMPLICIT WORLD MODEL to\n # predict — WITHOUT executing — which candidate / hypothesis will score best, primed with a\n # Verified Data Analysis Report (data profile / brief) + the accumulated experiment memory. Master\n # switch, ON by default; a no-op wherever there is nothing to rank (best_of_n==1 and\n # foresight_panel==1) or no LLM client. See `looplab/search/foresight.py`.\n foresight: bool = True\n # Hypothesis foresight breadth: generate K candidate ideas per proposal and keep the one PREDICTED\n # best pre-execution by the world model — it ranks the STRUCTURAL / text ideas the numeric k-NN\n # surrogate (researcher_panel) is blind to, primed with the data profile + memory (the synergy).\n # >1 enables it (LLM backend only; needs `foresight` on); 2 = on by default at modest cost, 1 = off.\n foresight_panel: int = 2\n # AGENTIC foresight: run the ranking (hypothesis-board prioritization + K-idea pick) as a TOOL-USING\n # loop that can pull actual experiment results / data facts before deciding, instead of a one-shot\n # prediction from a pre-baked report. ON by default (needs foresight + a client; falls back to the\n # one-shot predictor on any hiccup). A few extra LLM calls per proposal; set False for the cheaper\n # single-call ranker.\n foresight_agentic: bool = True\n # §1 foresight confidence gate: the minimum predicted confidence at which a predict-before-execute\n # pick is ACTED ON. Below it the ranker abstains — the K-idea panel falls back to the first\n # proposal, best-of-N falls through to the D10 tie-break — instead of committing a low-confidence\n # choice. 0.0 (default) = OFF (act on every pick, the historical behavior); raise toward ~0.5 to\n # make the world model defer when it isn't sure. Pairs with the foresight track record (§1) the\n # predictor is now primed with. Range 0.0-1.0.\n foresight_min_confidence: float = 0.0\n # PART IV Phase 2c — replace the world model's SELF-REPORTED confidence (measured Pearson≈0 with the\n # realized outcome, §21.12) with a CALIBRATED §12-verifier score. When on and a client is available,\n # after the K-idea ranker picks the predicted-best candidate the §12 verifier scores it (grounded +\n # repeated + criteria-decomposed: `foresight_criteria` — likely to improve the objective, and\n # sound/feasible) and THAT calibrated score becomes the confidence the `foresight_min_confidence` gate\n # and the telemetry use. Degrades to the self-reported confidence without a client or on any verifier\n # error (the telemetry records which source was used). ON by default in the product Settings (ce4a379),\n # EngineOptions off; a few extra LLM calls per acted-on proposal. See looplab/trust/verifier.py + search/foresight.py.\n # REVISIT (cost/quality, not yet resolved): a live classification run measured ~10 LLM calls & ~3.8 min/node\n # on deepseek-v4-flash with the full Part IV/V default bundle; the calibrated verifier is part of that per-node\n # tax. Kept ON deliberately — it raises decision quality (self-reported foresight confidence has ≈0 Pearson\n # with realized outcome), and lowering quality to save calls is the wrong trade. TODO: run a frozen quality/cost\n # A/B (verifier-on vs -off) to quantify the recall@objective gain per extra call before considering a default flip.\n foresight_verify: bool = True\n # Verifier sample count for `foresight_verify` (the §12 repeated-sampling expectation on a no-logprob\n # backend). 3 tames the single-shot variance §21.12 measured; 1 = single-shot (cheaper, noisier).\n # RESUME-critical: this value is persisted in config.snapshot.json, so a hard `le` schema bound would\n # make an old run started with a larger LOOPLAB_FORESIGHT_VERIFY_SAMPLES un-resumable (Settings(**snapshot)\n # would raise on `looplab resume` / finalize-recovery / serve replay). CLAMP into [1, MAX] via a validator\n # instead of rejecting — the runtime cap is preserved without breaking the snapshots/env-compat rule.\n foresight_verify_samples: int = 3\n\n @field_validator(\"foresight_verify_samples\")\n @classmethod\n def _clamp_foresight_verify_samples(cls, v: int) -> int:\n # Coerce a persisted/env value into [1, MAX] rather than reject (see field note above).\n try:\n v = int(v)\n except (TypeError, ValueError):\n return 3\n return max(1, min(v, MAX_FORESIGHT_VERIFY_SAMPLES))\n\n # T7 LLM response cache: serve an identical DETERMINISTIC (temperature 0) request from an\n # in-process content-addressed cache instead of re-hitting the model — cuts cost on\n # retry/panel/verify flows. NEVER caches sampling calls (temp>0: best-of-N / panel / novelty\n # re-propose must vary). Off by default (most role calls run at temperature>0 anyway).\n llm_cache: bool = False\n agent_cmd: str | None = None # override the agent's launcher (path / wrapper)\n # External-agent validation (ADR-7): wrap a CLI-agent Developer with a validator that\n # audits each output (no-op/syntax/crash/timeout), retries with feedback, and falls\n # back to the in-house LLM Developer. Off -> use the raw agent output unchecked.\n validate_agent: bool = True\n agent_max_retries: int = 1 # re-prompts of the agent on an invalid result\n # Patch-gated multi-file agent (ADR-7 Rule 3): run the CLI agent in a git worktree and\n # accept only edits whose paths match `agent_surface` (reject-not-strip out-of-surface\n # touches). Lets the agent create helper modules, not just solution.py. Degrades to\n # whole-file readback if git is unavailable.\n agent_patch_gate: bool = True\n agent_surface: list[str] = [\"*.py\"] # edit-surface allow-list (globs)\n # Trust policy for an agent-authored eval/metric adapter (RepoTask onboarding, Phase 3):\n # \"ratify_freeze\" waits for human approval, \"autonomous\" accepts the proposed spec without that\n # gate, and \"ratify_freeze_drift\" also enforces the configured independent drift cross-check.\n eval_trust_mode: str = \"ratify_freeze\"\n # RepoTask node seeding policy (run-wide fallback; a task/editable `seed_mode` overrides):\n # \"auto\" (default) copies git-tracked files when the editable is a git repo (so a tree bloated\n # with untracked artifacts isn't deep-copied per node), else a full copy; \"tracked\" forces\n # code-only; \"all\" forces a full recursive copy (legacy). Genesis authors per-task from the\n # user's words; this is the global default when unspecified.\n seed_mode: str = \"auto\"\n llm_model: str = \"qwen3:8b\"\n # === LLM / transport ==================================================================\n llm_base_url: str = \"http://localhost:11434/v1\" # Ollama OpenAI-compatible endpoint\n llm_temperature: float = 0.6\n # H3 per-role model presets (5090 recipe): optionally run the Researcher and Developer on\n # DIFFERENT models/endpoints (e.g. Developer=Qwen3-Coder-30B for code, a fast model for breadth).\n # Blank = use the shared llm_model/llm_base_url. Generalizes the role-backend swap to per-role.\n researcher_model: str | None = None\n developer_model: str | None = None\n researcher_base_url: str | None = None\n developer_base_url: str | None = None\n # Per-role sampling temperature (§4.1): the Researcher wants breadth (higher temp = more diverse\n # ideas), the Developer wants determinism (lower temp = fewer syntax slips), the Strategist a\n # steady hand. Each overrides the shared `llm_temperature` for that role only; None = use the\n # shared value (byte-identical to before). Flat + nullable, mirroring the per-role model fields.\n researcher_temperature: float | None = None\n developer_temperature: float | None = None\n strategist_temperature: float | None = None\n # Unified self-driving agent: one LLM identity that plays Researcher + Developer (+ Strategist)\n # across pipeline stages, choosing its own model/toolset per stage. ON by default — the agent\n # drives the loop (incl. crash triage: repair/abandon/reject_idea) and, via\n # `agent_drives_actions`, picks the next macro action within a pure legal-action gate. Set both\n # to False for the legacy byte-identical split-role behavior. (No-op unless backend=\"llm\".)\n unified_agent: bool = True\n agent_drives_actions: bool = True\n # Layer 3 Card queue ownership. False preserves the existing policy/unified-pilot action path.\n # True is pinned in run_started because changing the selector on resume would mix two search\n # treatments in one run. When both this and agent_drives_actions are true, Card selection wins.\n card_driven_selection: bool = False\n # Layer 5 bounded speculative Card buffer. Zero is a hard OFF switch and preserves the\n # historical alternating build/eval spine; positive values are pinned by run_started so a\n # resume cannot silently mix search treatments after a snapshot/default edit.\n speculation_depth: int = Field(default=0, ge=0, le=64)\n # Local evidence receipt produced by ``looplab speculation-gate``. This is deliberately absent\n # from the curated Settings UI: positive-depth execution is a calibrated rollout decision, not a\n # casual tuning knob. The Engine validates the receipt against the current scorer,\n # implementation, GPU inventory and raw paired-run evidence before it enables speculation.\n speculation_gate_receipt: str | None = Field(default=None, max_length=4096)\n # Per-stage model/endpoint overrides for the unified agent. Recognized keys (see\n # tasks.build_unified_agent): `propose` (researcher), `implement`/`repair` (developer),\n # `strategy`, `pilot`. Unlisted keys are ignored. Empty = the per-role researcher_*/developer_*\n # models, then the shared llm_model. Explicit map wins. No-op unless `unified_agent` is true.\n agent_stage_models: dict = {}\n agent_stage_base_urls: dict = {}\n llm_parser: str = \"tool_call\"\n # Reasoning/thinking toggle sent IN the request (providers differ — see `llm.reasoning_body`):\n # Qwen3 on vLLM/SGLang -> chat_template_kwargs.enable_thinking (bool)\n # OpenAI/Ollama-v1/DeepSeek -> reasoning_effort (low|medium|high|none)\n # `llm_reasoning`: \"\" = send nothing (server default — current behavior); off = actively disable;\n # on = enable at default depth; low|medium|high = enable at that effort.\n # Defaults to \"high\" so the agent reasons before proposing/repairing/triaging (better code, fewer\n # crashes); set \"\" to send nothing (server default) or \"off\" to disable. The model's chain-of-thought\n # is CAPTURED either way (reasoning_content field or inline <think>). NOTE: to get a SEPARATE\n # reasoning_content field from SGLang/vLLM, the server also needs `--reasoning-parser qwen3`.\n llm_reasoning: str = \"high\"\n llm_reasoning_style: str = \"auto\" # auto | qwen | effort | none — how to shape the request param\n llm_reasoning_extra: dict = {} # raw fields merged into the body (escape hatch, e.g. Anthropic thinking)\n # H1: drive structured calls with the endpoint's constrained decoding (vLLM/SGLang guided_json +\n # response_format json_schema) so weak models can't emit invalid JSON. Off for Ollama (default).\n llm_guided_json: bool = False\n # Stream every LLM request (SSE) and reassemble it, so the request `timeout` is an INTER-TOKEN\n # idle timeout rather than a whole-request deadline: a long-but-alive generation is never cut off\n # (tokens keep resetting the timer), while a genuinely STALLED endpoint (no token for `timeout` s)\n # is detected and retried on a fresh connection — the fix for silent multi-minute hangs. Set False\n # to use one blocking read (old per-op timeout semantics) if an endpoint streams badly.\n llm_stream: bool = True\n # LLM IDLE timeout (seconds). Stream mode: the INTER-TOKEN stall limit — a steady generation is\n # never cut off, only a silent endpoint. Non-stream: bounds the whole read. Raise for endpoints\n # with a long prefill on huge prompts; lower to fail fast on a flaky shared endpoint.\n llm_timeout: float = Field(default=180.0, gt=0)\n # First-byte (response-headers) window for STREAM attempts, seconds: an SSE response sends its\n # headers on admission, so no-headers ≈ a black-holed request — fail over to a fresh connection\n # fast instead of waiting the full idle timeout. Clamped to llm_timeout. Non-stream attempts are\n # NOT bounded by this (their headers arrive only after the whole generation). The default mirrors\n # `DEFAULT_HEADER_TIMEOUT_S` in looplab/core/llm.py is the single source (imported above).\n llm_header_timeout: float = Field(default=DEFAULT_HEADER_TIMEOUT_S, gt=0)\n # Honour HTTP(S)_PROXY / NO_PROXY / SSL_CERT_FILE env vars for LLM calls. Default False: the\n # transport connects DIRECTLY (many internal/OLAP endpoints are reachable only by bypassing an\n # ambient proxy — a picked-up proxy yields \"connection refused\"). Set true when the endpoint is\n # reachable ONLY via a corporate proxy or needs a custom CA bundle from the environment.\n llm_trust_env: bool = False\n # H4/C2: compact the growing agentic tool-call history once it exceeds this many chars (auto_summary\n # LLM-summarizes the stale middle; else middle-truncate). Sized to the model's real context window so\n # files read earlier STAY in context instead of being compacted away and re-read: ~1,000,000 chars ≈\n # 250k tokens (≈4 chars/token), matching the configured deepseek-v4-flash (≥200k tokens confirmed).\n # The old 120k-char (~30k-token) default was ~8× too aggressive and drove a read-thrash loop. 0 = off.\n context_budget_chars: int = 1_000_000\n # M5: the Researcher's always-on experiments digest budget (chars). 0 = AUTO — scales with the\n # run size (60 chars/node, bounded [1200, 6000]) instead of one flat cap for an 8-node toy run\n # and a 200-node benchmark run alike. Set a positive value to pin it.\n digest_char_cap: int = Field(default=0, ge=0)\n # D11 compression model slot (open_deep_research's four-slot pattern): a dedicated CHEAP model\n # for agent-history summarization (C2 auto-summary), so compression doesn't pay the main\n # model's price. Blank = use each loop's own model (legacy). `compressor_base_url` blank =\n # the shared llm_base_url.\n compressor_model: str | None = None\n compressor_base_url: str | None = None\n # === Agentic tool-loop ================================================================\n # Agentic tool-loop limits — apply to EVERY tool-using agent (the LLM Researcher, the unified\n # agent's pilot + crash-triage, Deep-Research, the run-chat Boss, the genesis repo-scout, and the\n # cross-run report synthesizer). The loop lets the model call read-only tools across turns before\n # emitting its structured result. Both default to UNLIMITED so the agent takes as many turns / as\n # long as it needs — it is never cut off mid-reasoning; set a positive value here (or per-run, or\n # in the UI settings) to bound latency/cost. These used to be hardcoded per call site (e.g. the\n # boss at max_turns=3 / 45s), which silently dropped a slow reasoning model to a no-op reply.\n agent_max_turns: int = 0 # max tool turns before the emit is forced (0 = unlimited)\n agent_time_budget_s: float = 0.0 # wall-clock ceiling across the loop's turns (0 = no cap)\n # G · Soft convergence SAFETY NET (not a research cap). Two thresholds on the agent's tool-turn count:\n # at `agent_emit_after` the loop NUDGES the agent once (\"you've investigated enough — emit now\"); at\n # `agent_emit_force` it FORCES the emit from what was gathered. Set GENEROUSLY — a focused researcher\n # may legitimately read many files/experiments before it can propose a grounded idea; these only catch\n # a genuine runaway that keeps issuing DIFFERENT calls forever (which stuck-detection, keyed on\n # repeats, misses). NB: the old 499-read runaway (node 63) was 79% REDUNDANT re-reads caused by a\n # broken read_file — fixing read_file (pagination) is the real cure; this is just insurance. 0 = off.\n agent_emit_after: int = 300\n agent_emit_force: int = 500\n # B1 · No-progress / stuck detection — the safety net that makes \"unlimited turns\" safe. The\n # turn/time ceilings above are only BACKSTOPS; this is what actually stops a runaway loop, on the\n # cheapest signal: when the model repeats the SAME tool call (or ping-pongs between two, or keeps\n # hitting the SAME error) with no progress, the loop forces the final emit and finishes. ON by\n # default; reading DIFFERENT files or running ONE long command never trips it. (OpenHands-style.)\n agent_stuck_detection: bool = True\n agent_stuck_repeat: int = 4 # identical calls in a row that count as \"stuck\" (>=2)\n agent_stuck_alternate: int = 4 # ping-pong cycles between two calls that count as \"stuck\" (>=2)\n # C1 · Self-plan (TodoWrite-style): expose an `update_plan` tool so a long-running agent keeps its\n # OWN working TODO and re-surfaces it every `agent_plan_reinject_every` turns — keeps the goal in\n # view across a long loop so the agent \"writes its own context\" (Claude Code style). ON by default.\n agent_self_plan: bool = True\n agent_plan_reinject_every: int = 5\n # C2 · Auto-summary (ON by default): when the tool-loop history grows long, LLM-summarize the\n # stale middle instead of only middle-truncating it. The trigger is `context_budget_chars` if set,\n # else a built-in high-water mark (context_budget.DEFAULT_SUMMARY_CHARS ≈ 120k chars) so short\n # loops are untouched. Falls back to truncation on any summarizer error; one extra model call per\n # over-budget turn.\n agent_auto_summary: bool = True\n # C4 · Repo-developer plan decomposition. A big feature attempted in ONE developer session made a\n # non-converging reasoning model loop indefinitely — it kept writing + exploring without ever\n # emitting `done` (10k+ LLM calls / hours; agentic stuck-detection can't catch VARIED exploration,\n # only literal repeats). Fix: the developer first proposes an ordered plan of ATOMIC steps; a\n # multi-step plan is executed step-by-step, each in a FRESH bounded session with only that step's\n # scope + the files accumulated so far, syntax-validated per write. Every session stays\n # small-context and convergent. ON by default for repo tasks (a 1-step plan == the old single pass).\n developer_plan_decompose: bool = True\n developer_plan_min_steps: int = 2 # a proposed plan with >= this many steps runs step-by-step\n developer_plan_max_steps: int = 8 # cap on plan length (a runaway planner can't spawn 100 steps)\n # Hard per-session backstop for the repo developer — the ONE write-heavy agent that can run away\n # even with stuck-detection on (varied writes/reads never trip the repeat/ping-pong signal). Bounds\n # the plan phase, EACH step, and the single-session fallback. Unlike the global agent_* limits\n # (0 = unlimited, meant for read-only agents), the developer always gets a FINITE ceiling so a\n # model that never emits `done` fails cleanly with the code it wrote so far, instead of looping.\n developer_session_max_turns: int = 500\n developer_session_time_budget_s: float = 1200.0 # 20 min wall-clock per developer session\n # Phase-handoff summaries. Each LLM phase in a node build (Researcher·propose → Developer·stages →\n # plan → implement) ends with ONE extra LLM call that distills its transcript — the repo structure\n # it mapped, files/data confirmed, decisions made — into a brief injected into the NEXT phase (even\n # across the role boundary). The next phase TRUSTS that instead of re-reading the same files, which\n # cuts the biggest source of tool-call thrash (stages/plan/implement each re-exploring the repo).\n # One summary call per phase in exchange for many fewer read calls downstream. ON by default.\n phase_handoff_summary: bool = True\n # Observability (ADR-17): capture each LLM call's full prompt + completion into the active\n # span (spans.jsonl) so the UI can show exactly what the model read and wrote per node.\n # Diagnostics only — `replay.fold` never reads spans. Default on for local single-user; set\n # LOOPLAB_TRACE_LLM_IO=0 to suppress (e.g. to keep spans.jsonl small or avoid storing prompts).\n trace_llm_io: bool = True\n # Run-introspection tools (ON by default): make the LLM Researcher (and DeepResearcher) a\n # tool-using agent that can read its OWN experiments (list/read/find-analogous/themes/code) and\n # the task data (schema/profile/asset) mid-loop, instead of seeing only best+parent. Advisory —\n # never changes best-selection. Off = the legacy single-shot Researcher (richer digest still added).\n researcher_tools: bool = True\n # Cross-run introspection: give the agentic Researcher / pilot read-only tools to look at\n # SIBLING runs (same task_id, same run-root) — list them, read an experiment / its code, and\n # find analogous configs across runs — so a run can build on what neighbouring runs already\n # learned instead of rediscovering it. Advisory; never changes best-selection. Needs the run's\n # own dir wired through (no-op for unit-built roles). Off = the legacy single-run view only.\n cross_run_tools: bool = True\n # Read-only access to EVERY run on this machine ACROSS ALL TASKS (not just same-task siblings):\n # the Developer/Researcher get list_all_runs + read_run_code + read_run_experiment so they can read\n # the code + result of ANY past experiment anywhere and reuse an approach. Broader than\n # `cross_run_tools` (same-task only); the agent decides when a foreign run is relevant. Advisory;\n # never changes best-selection. Needs the run's own dir wired through (no-op for unit-built roles).\n all_runs_tools: bool = True\n # PART V §22 — read-only CROSS-RUN KNOWLEDGE tool for the reasoning roles (Researcher, Strategist,\n # deep-research, and a Developer-scoped variant). Adds `cross_run_prior_attempts` / `cross_run_claims`\n # / `cross_run_atlas` over the §21.20 read-models (concept overview + claim assessments + atlas), read\n # from `memory_dir` (lessons.jsonl + concept_capsules.jsonl). Read-only is a storage-authority\n # boundary, not a behavioral no-op: retrieved content can steer later proposals, and a tool-using\n # reasoning loop can consume additional model calls. The tools never mutate cross-run truth (facts\n # are engine-written, verdicts operator-ratified, §22.4) or directly select a winner. ON by explicit\n # experimental product choice, EngineOptions off; stores only exist once Part-IV features have run.\n cross_run_read_tools: bool = True\n # Agentic retrieval (ADR-16): if set, the LLM Researcher gets grep/kb_search/read\n # tools over this directory of markdown notes and chooses when to use them.\n knowledge_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"knowledge\"))\n # T4 real embeddings: model id for an OpenAI-compatible `/embeddings` endpoint used for\n # kb_search / case retrieval. Blank (default) = the dependency-free lexical `hash_embed` (today's\n # behavior). Set e.g. \"nomic-embed-text\" (Ollama) or \"text-embedding-3-small\" for SEMANTIC\n # retrieval; a misconfigured/offline endpoint degrades back to hash_embed at call time (never\n # crashes a run). `embed_base_url` blank = reuse `llm_base_url`; the shared `llm_api_key` is used.\n embed_model: str | None = None\n embed_base_url: str | None = None\n # Harmonic memory (Memora, ICML'26 — idea import). When ON, cross-run cases and knowledge notes are\n # indexed by a short ABSTRACTION + cue ANCHORS instead of their raw text, near-duplicate memories are\n # CONSOLIDATED into one entry under a matching abstraction, and `kb_search`/case retrieval EXPAND\n # through the top hits' anchors to surface related-but-not-similar memories. ON by default. Set\n # `memora=false` to restore the pre-Memora raw-text index. Never a source of truth — abstractions\n # live only in the derived, rebuildable retrieval index (never the event log or canonical cases.jsonl).\n memora: bool = True\n # Write abstractions with the wired chat model (richer than the deterministic lexical fallback). ON\n # by default; results are CACHED by content hash (see `memora_cache`) so a re-built index doesn't\n # re-call the model on unchanged notes/cases, and any endpoint failure degrades to lexical at call\n # time — so an offline box just gets lexical abstractions, never a crash. Set false to force lexical.\n memora_llm: bool = True\n # === Cross-run memory / Memora ========================================================\n memora_anchors: int = 6 # max cue anchors kept per memory\n # Cosine similarity at/above which a new case is CONSOLIDATED into an existing one (same evolving\n # topic) rather than stored as a separate entry. Only used when `memora` is on.\n memora_consolidate_threshold: float = 0.86\n # Where the LLM-abstraction cache lives (JSON). Blank (default) derives it from `memory_dir`\n # (`<memory_dir>/memora_cache.json`) or else `knowledge_dir` (`<knowledge_dir>/.memora_cache.json`);\n # with neither set the cache is in-memory only (per process). No-op unless `memora_llm` is on.\n memora_cache: str | None = None\n # E3 literature-grounded ideation (network-OPTIONAL): give the agentic Researcher an arXiv search\n # tool to ground ideas in real techniques. Off by default (egress is unreliable on some boxes);\n # fails gracefully if the network is blocked.\n literature_search: bool = False\n # Deep-Research stage (network-OPTIONAL): give the DeepResearcher a general web search/fetch\n # tool (DuckDuckGo) on top of arXiv to read across results + the web before steering the next\n # batch. Off by default (egress is unreliable on some boxes); fails gracefully when blocked.\n web_search: bool = False\n # Cadence for the Deep-Research stage: run it automatically every N created nodes. Zero disables\n # only the cadence; manual `deep_research` and Strategist `request_research` triggers still fire.\n # Default 3: deep research analyzes the accumulating results and steers the next batch (its\n # directions become hints + open hypotheses); paired with `concurrent_research` it overlaps the\n # think with the GPU-bound eval, so the cadence costs LLM tokens but no wall-clock on the search.\n deep_research_every: int = 3\n # Overlap a DUE deep-research \"think\" with the GPU-bound eval instead of running it in its own\n # serial step (the agent is otherwise idle while a node trains). research() computes from a state\n # snapshot; CODEX AGENT: the joined background task records only allowlisted, order-tolerant\n # research/hint/hypothesis events as soon as the memo finishes. ON by default: the LLM is typically\n # remote (no GPU contention with eval), so overlapping hides latency behind long training.\n concurrent_research: bool = True\n # Concurrent-research REPEAT — don't idle a multi-DAY eval. The overlapped \"think\" fires ONCE per\n # window by default; ON here, it RE-RUNS on an adaptive time cadence for the WHOLE eval window\n # (a two-day training must not leave the reasoning agents idle after one memo). Self-paced: it\n # only records a memo whose CONTENT is new (identical re-runs are skipped, so the log/board don't\n # bloat) and backs off geometrically as the analysis converges, capped so it always re-checks.\n # The appended records never rewrite the current metric champion, but their hints/open hypotheses\n # deliberately steer later proposals and replay reconstructs that advice. The library default is\n # one-shot (== today, byte-identical); this product default turns the repetition on.\n concurrent_research_repeat: bool = True\n # Base cadence (seconds) between repeated concurrent-research passes. Research is EXPENSIVE\n # (multi-turn LLM + web/arXiv), so this is a FLOOR, not a ceiling: the effective cadence is\n # max(this, ~5% of the per-experiment time budget) — a two-day eval is re-researched about hourly,\n # a short eval not at all (the first tick outlasts it). Only matters when the repeat is on.\n concurrent_research_interval_s: float = Field(default=1800.0, gt=0)\n # Per-eval-window backstop on repeated-research LLM calls (0 = cadence-only, no cap). The adaptive\n # cadence + convergence backoff are the primary budget control; this bounds a pathological\n # never-converging window. Past it the loop stops calling the LLM (the health monitor still runs).\n concurrent_research_max_calls: int = Field(default=40, ge=0)\n # Concurrent hypothesis-board CONSOLIDATION during the eval window. Repeated research keeps ADDING\n # near-duplicate directions to the open board (each memo registers up to 5 as open hypotheses), so a\n # long eval's board bloats before the between-nodes merge runs. This dedups/merges it on the SAME\n # background loop so the board stays clean for the next proposal. In Card-driven mode the same merge\n # changes ownership/readiness, so it is deferred to the joined main-task cadence instead. Needs the\n # reflect client + `track_hypotheses`.\n # Only active while `concurrent_research` runs the overlap loop. Library default off (== today).\n concurrent_consolidate: bool = True\n # Cadence for the agent-authored run report: regenerate the conclusion-first narrative every N\n # created nodes (0 = off; it still regenerates on a manual `report_refresh` from the UI). The\n # deterministic report always renders from the node set regardless of this knob.\n report_every: int = 3\n # Agent Skills (I18, ADR-9): dir of SKILL.md the Researcher can list/load as tools.\n skills_dir: str | None = None\n # Prompt store (I18, ADR-8): dir of editable, hot-reloaded role prompt .md files.\n prompt_dir: str | None = None\n # API key as a reference, never serialized as a value (ADR-11). Local servers\n # ignore it; default to a placeholder.\n llm_api_key: SecretStr | None = None\n\n @model_validator(mode=\"before\")\n @classmethod\n def _apply_profile(cls, data):\n \"\"\"Expand `profile` into a bundle of setting defaults BEFORE validation.\n\n pydantic-settings hands this validator the fully-merged input from ALL explicit sources\n (init kwargs + LOOPLAB_* env + .env), with fields the user left unset simply ABSENT (they\n fall to field defaults afterward). So a profile value is applied only when its key is not\n already present — i.e. any explicit file/CLI/env setting wins over the profile, and the\n profile wins over the bare field default. This makes `thorough` a true convenience preset,\n never an override of what the user asked for.\"\"\"\n if not isinstance(data, dict):\n return data\n prof = data.get(\"profile\", \"default\")\n # env may deliver the key as \"profile\" (env_prefix stripped) — normalize to a str\n prof = str(prof).strip().lower() if prof is not None else \"default\"\n overrides = PROFILES.get(prof)\n if overrides is None:\n raise ValueError(f\"unknown profile {prof!r}; known: {sorted(PROFILES)}\")\n for key, val in overrides.items():\n if key not in data: # explicit source (file/CLI/env) always wins\n data[key] = val\n return data\n\n @model_validator(mode=\"after\")\n def _check_trust_gate(self):\n if self.trust_gate not in (\"audit\", \"gate\", \"block\"):\n raise ValueError(\n f\"trust_gate must be audit|gate|block, got {self.trust_gate!r}\")\n if self.merge_mode not in (\"auto\", \"mean\", \"ensemble\"):\n raise ValueError(\n f\"merge_mode must be auto|mean|ensemble, got {self.merge_mode!r}\")\n # novelty_mode drives the dedup/novelty gate; an out-of-set value (e.g. a mis-cased\n # LOOPLAB_NOVELTY_MODE=LLM, or \"on\") used to fall through _apply_novelty_gate silently as a\n # NO-OP — turning the gate off with no diagnostic. Fail loudly at config time like trust_gate/\n # merge_mode do. (Other enum-ish string fields — seed_mode/eval_trust_mode/strategist_backend —\n # share the pattern; left unvalidated here only because their full value sets are less settled.)\n if self.novelty_mode not in (\"off\", \"algo\", \"llm\"):\n raise ValueError(\n f\"novelty_mode must be off|algo|llm, got {self.novelty_mode!r}\")\n # The remaining enum-ish string fields (arch-review §5 P3): a typo used to be accepted at\n # construction and only fail-safe/later-loud downstream (e.g. a mis-cased strategist_backend\n # silently ran the default). Fail loudly here like the fields above.\n if self.strategist_backend not in (\"off\", \"rule\", \"llm\", \"agent\"):\n raise ValueError(\n f\"strategist_backend must be off|rule|llm|agent, got {self.strategist_backend!r}\")\n if self.eval_trust_mode not in (\"ratify_freeze\", \"autonomous\", \"ratify_freeze_drift\"):\n raise ValueError(\n \"eval_trust_mode must be ratify_freeze|autonomous|ratify_freeze_drift, \"\n f\"got {self.eval_trust_mode!r}\")\n if self.seed_mode not in (\"auto\", \"tracked\", \"all\"):\n raise ValueError(f\"seed_mode must be auto|tracked|all, got {self.seed_mode!r}\")\n return self\n\n def masked_snapshot(self) -> dict:\n # Snapshots are a JSON contract (they are written verbatim to config.snapshot.json), not a\n # Python-object dump. In particular tuple-valued schema fields such as\n # ``inline_repair_reasons`` must become JSON arrays before callers hash the runtime envelope;\n # otherwise the strict speculation-scope canonicalizer sees a different/non-JSON preimage\n # from the one that ``json.dumps`` eventually persists.\n d = self.model_dump(mode=\"json\")\n if self.llm_api_key is not None:\n d[\"llm_api_key\"] = \"***\"\n else:\n d[\"llm_api_key\"] = None\n return d\n\n\n# Pre-versioned snapshots are full Settings dumps, so absence is a reliable per-field version marker.\n# Keep their historical effective behavior when newer product defaults become active: re-entry must not\n# silently add paid calls, interventions, concurrency or a different selection policy to an old run.\nLEGACY_CONFIG_SNAPSHOT_DEFAULTS: dict[str, object] = {\n \"parallel_build\": 1,\n \"eval_parallel\": None,\n \"llm_parallel\": None,\n \"train_monitor\": False,\n \"train_monitor_interval_s\": 600.0,\n \"train_monitor_kill\": False,\n \"train_monitor_kill_confidence\": 0.8,\n \"asha_live\": False,\n \"asha_live_kill\": False,\n \"asha_live_quantile\": 0.5,\n \"asha_live_min_siblings\": 3,\n # Before this operator ceiling existed, the sandbox's 24-hour process limit was authoritative.\n \"max_eval_timeout\": 24 * 3600.0,\n \"watchdog_reflection\": False,\n \"card_driven_selection\": False,\n \"speculation_depth\": 0,\n \"speculation_gate_receipt\": None,\n \"concurrent_research_repeat\": False,\n \"concurrent_research_interval_s\": 1800.0,\n # Zero means unlimited; 40 was the first bounded default, and is latent while repeat is disabled.\n \"concurrent_research_max_calls\": 40,\n \"concurrent_consolidate\": False,\n}\n\n\ndef migrate_config_snapshot(data: dict) -> dict:\n \"\"\"Return an effective copy of one legacy/current snapshot without rewriting evidence bytes.\"\"\"\n if not isinstance(data, dict):\n raise TypeError(\"config snapshot must be a JSON object\")\n migrated = dict(data)\n for key, value in LEGACY_CONFIG_SNAPSHOT_DEFAULTS.items():\n migrated.setdefault(key, value)\n return migrated\n\n\ndef settings_from_snapshot(data: dict) -> Settings:\n \"\"\"Build re-entry Settings from a masked snapshot under its historical missing-field contract.\"\"\"\n migrated = migrate_config_snapshot(data)\n migrated.pop(\"llm_api_key\", None)\n return Settings(**migrated)\n"}, "112b1215ec7f2abba32fd85b8c72e7bd970ee4dd": {"t": 1785768779.8783464, "n": 96763, "h": "bff7c14f3bf9a36d", "c": "\"\"\"Engine config: the schema (what settings exist). (I0, ADR-11)\n\nOne pydantic-settings class declaring every engine knob, its default, and its docs. The loader —\nhow a run file / CLI / env becomes a `Settings` — is `looplab.core.appconfig`, which also owns\nthe config-precedence order. A resolved, secret-masked snapshot is written next to each run for\nreproducibility. (No real secrets in P0, but the masking discipline is in place.)\n\"\"\"\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nfrom pydantic import Field, SecretStr, field_validator, model_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n# Single source of the LLM first-byte (response-headers) default — see core/llm.py.\nfrom looplab.core.llm import DEFAULT_HEADER_TIMEOUT_S\n\n# Default home for cross-run memory + the knowledge base (shared across every run). Both are ON by\n# default now (a user asked for it) so a fresh install accumulates + consults knowledge with no setup;\n# set the env var to \"\" to disable, or to a path to relocate. `~/.looplab/` keeps them out of any one\n# project + shared across projects.\n_LL_HOME = Path.home() / \".looplab\"\n\n# Autonomous roles that may be granted per-setting write access (see Settings.agent_control).\n# strategist = A7 meta-controller (run-wide); boss = run-chat operator-proxy (run-wide);\n# researcher = per-experiment proposer (per-node sizing, e.g. eval timeout for a heavy model).\nAGENT_ROLES = (\"strategist\", \"boss\", \"researcher\")\n\n# A foresight score is computed for every acted-on proposal. Keep this lower than the generic\n# verifier's emergency ceiling so a typo cannot multiply paid synchronous calls across a run.\nMAX_FORESIGHT_VERIFY_SAMPLES = 8\n\n\n# Layer-2 parallelism renamed two public settings without removing the durable legacy fields. Keep\n# the equivalence in one place: config-source precedence, governance migration, and live strategy\n# application must all agree which names describe the same authority/value.\nPARALLELISM_ALIASES: dict[str, str] = {\n \"max_parallel\": \"eval_parallel\",\n \"parallel_build\": \"llm_parallel\",\n}\n\n\ndef parallelism_aliases(setting: str) -> tuple[str, ...]:\n \"\"\"Return every spelling in ``setting``'s alias family, canonical name first.\"\"\"\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if setting in (legacy, canonical):\n return canonical, legacy\n return (setting,)\n\n\ndef canonicalize_parallelism_source(values: dict) -> dict:\n \"\"\"Promote legacy parallel keys *within one config-precedence source*.\n\n Applying this only after file/CLI/env were flattened would let a lower-priority canonical env\n value shadow a higher-priority legacy file value (or a file canonical value shadow a CLI legacy\n override), so callers with sub-layers normalize each layer independently.\n \"\"\"\n if not isinstance(values, dict):\n return values\n out = dict(values)\n for legacy, canonical in PARALLELISM_ALIASES.items():\n # CODEX AGENT: None is the durable legacy mode for llm_parallel, not a missing value. A normal\n # snapshot contains llm_parallel:null plus parallel_build:1; reloading it here promotes null to\n # 1 and unexpectedly enables the global one-call broker. Promote aliases only when the\n # canonical key is absent, and pin snapshot round-trip parity.\n if canonical not in out and out.get(legacy) is not None:\n # CODEX AGENT: canonicalize inside each precedence layer, never across the flattened\n # result. The alias migration must not invert CLI > file > env precedence.\n # CLAUDE REVIEW: promoting a legacy-ONLY parallel_build layer (e.g. just\n # LOOPLAB_PARALLEL_BUILD=1 in env) into canonical llm_parallel ALSO flips on the finite\n # shared LLM broker: llm_parallel=1 -> orchestrator._startup_llm_total=1, serializing\n # every provider call in the run (research overlap, novelty, tag_nodes_llm, monitors) —\n # contradicting llm_broker.py's docstring, orchestrator's \"legacy-only preserves unbounded\n # overlap\" note, the broker-neutral live-control path, and the configuration.md\n # llm_parallel row (\"falls back ... without enabling a shared total\"). Promote only the\n # build width (keep broker opt-in tied to an explicitly-spelled canonical value), or fix\n # all four contracts and pin the env/file semantics with a test.\n out[canonical] = out[legacy]\n return out\n\n# Settings whose semantics are committed by ``run_started`` and restored from the folded event log\n# on every re-entry. Changing one only in ``config.snapshot.json`` would mix incompatible evidence\n# (a different holdout split) or make the live producer disagree with replay's selection policy.\n# The HTTP config editor and the engine both consume this one contract; keep deliberately mutable\n# controls such as ``trust_gate`` out (that field has its own durable change event).\nRUN_START_PINNED_FIELDS = frozenset({\n \"card_driven_selection\",\n \"speculation_depth\",\n \"holdout_fraction\",\n \"holdout_select\",\n \"select_verifier\",\n \"select_verifier_samples\",\n \"verifier_ci_tie\",\n})\n\n\ndef run_start_pinned_settings(state) -> dict:\n \"\"\"Return the effective Settings names committed by a folded run-start record.\n\n Pre-pin legacy logs have no recorded holdout fraction, so their snapshot remains authoritative\n for both holdout knobs. Verifier fields, however, have always been re-pinned from the legacy-safe\n fold defaults whenever a valid ``run_started`` identity exists.\n \"\"\"\n if not getattr(state, \"run_id\", \"\"):\n return {}\n values = {\n # Layer 3 changes which queue owns macro-action selection. Once a run starts, replay and\n # resume must keep the same owner even if the snapshot or ambient default changes later.\n \"card_driven_selection\": bool(getattr(state, \"card_driven_selection\", False)),\n # Layer 5 overlaps Card production with evaluation only when this pinned treatment is\n # positive. Old logs omit it and therefore retain the exact serial build/eval spine.\n \"speculation_depth\": int(getattr(state, \"speculation_depth\", 0)),\n \"select_verifier\": bool(getattr(state, \"select_verifier_tiebreak\", False)),\n \"select_verifier_samples\": int(getattr(state, \"select_verifier_samples\", 3)),\n \"verifier_ci_tie\": bool(getattr(state, \"verifier_ci_tie\", False)),\n }\n holdout_fraction = getattr(state, \"holdout_fraction\", None)\n if holdout_fraction is not None:\n values.update({\n \"holdout_fraction\": holdout_fraction,\n \"holdout_select\": bool(getattr(state, \"holdout_select\", False)),\n })\n if not values.keys() <= RUN_START_PINNED_FIELDS:\n raise RuntimeError(\"folded run-start pinned settings contract drifted\")\n return values\n\n# --- Run profiles (config-first presets) -------------------------------------------------------\n# A `profile` is a NAMED BUNDLE of setting defaults — nothing more. It only fills fields the user\n# did NOT set explicitly (via file / --set / LOOPLAB_* env / init-kwargs), so any explicit knob\n# always wins (see `_apply_profile`). Most engine intelligence lives behind individual flags. The\n# PART IV/V cross-run + concept-graph machinery (concept_pivot, graded_novelty, cross_run_concepts /\n# _advisory / _structured_claims / _curation / _read_tools, fingerprint_universal, foresight_verify)\n# now ships ON in the product `Settings` default. That is an explicit experimental product choice,\n# not a claim that a frozen A/B or workload-wide cost/quality gate has cleared: the bundle can steer\n# prompts and candidate generation, change graded-novelty admission / pre-execution choices, and add\n# paid LLM work when its client and memory prerequisites are present. The bare-library `EngineOptions`\n# default stays lean (see tests/test_options_divergence.py — product side is the aggressive one) so a\n# toy `Engine(...)` in a test doesn't fire cross-run LLM work unasked. The trust/confirm/ablate QUALITY\n# bundle still ships OFF and is turned on by `thorough`. Every value here is reachable by hand — the\n# profile is a convenience, never a hidden mode.\n# CODEX AGENT: keep this rationale behavioral and evidence-based; do not relabel read-only storage\n# access or proposal-only governance as a no-op for agent behavior, cost, or proposal admission.\n#\n# `thorough` deliberately touches only QUALITY/TRUST machinery, not spend: it does NOT raise\n# max_nodes / eval_parallel (those are cost knobs the user owns). It enables multi-seed confirmation\n# (demote seed-lucky leaders), the reward-hack / leakage / critic monitors AND flips them from\n# advisory to *gating* (`trust_gate=\"gate\"` — a\n# flagged win can no longer be selected as best), ablation-driven refinement, and the prompt cues\n# (complexity / budget / failure-reflection / watchdog-reflection). Agentic novelty and the experimental\n# Part IV/V bundle are already product defaults, not profile changes. Effect is large; marginal cost is a\n# few extra evals.\nPROFILES: dict[str, dict] = {\n \"default\": {}, # explicit no-op alias for \"ship-as-is\" (== omitting profile)\n \"fast\": {}, # today's lean defaults, named for symmetry with `thorough`\n \"thorough\": {\n \"confirm_top_k\": 3,\n \"confirm_seeds\": 3,\n # novelty_gate intentionally NOT enabled here: duplicate-detection is the agentic Researcher's\n # job (it reads past experiments + find_analogous_across_runs and DECIDES), not an algorithmic\n # param-distance / embedding auto-reject that mis-fired (it nudged a good fresh idea onto a\n # twice-dead lineage — live node 61). Search stays as tools; the LLM judges novelty.\n \"operator_bandit\": True, # P4: adaptive operator mix over folded yields (GreedyTree)\n \"reward_hack_detect\": True,\n \"code_leakage_detect\": True,\n \"critic_check\": True,\n \"trust_gate\": \"gate\", # detectors stop gating a flagged node from WINNING (not just audit)\n \"ablate_every\": 3,\n \"complexity_cue\": True,\n \"budget_aware\": True,\n \"failure_reflection\": True,\n \"watchdog_reflection\": True, # feed recent live-watchdog (train-monitor/ASHA) flags to proposals\n \"reflection_priors\": True, # no-op unless memory_dir is set (cross-run priors)\n },\n}\n\n\n# The shipped agent-governance matrix (who may change which setting at runtime). Defined as a module\n# constant so the Engine's direct-construction default matches this shipped default exactly (the\n# EngineOptions \"Engine() == shipped defaults\" invariant) instead of diverging to an all-locked map.\nDEFAULT_AGENT_CONTROL: dict[str, list[str]] = {\n \"timeout\": [\"researcher\", \"strategist\"],\n # Layer-2 canonical names are the presented/default governance surface. `_agent_may` falls back\n # to an old snapshot's legacy grant only when its canonical counterpart is absent, so removing\n # duplicate defaults preserves resume authority while an explicit canonical revocation still wins.\n \"eval_parallel\": [\"strategist\"],\n \"llm_parallel\": [\"strategist\"],\n # Strategy-only pseudo-setting: the closed per-lane allocation inside the canonical LLM total.\n # It is not a launch Settings field; operators pin it through `set_strategy` and the Strategist\n # may reallocate it only while this grant is present.\n \"llm_lane_limits\": [\"strategist\"],\n \"max_nodes\": [\"strategist\", \"boss\"],\n \"max_eval_seconds\": [\"strategist\", \"boss\"],\n \"policy\": [\"strategist\"],\n # The policy NAME and its PARAMS are gated independently in `_apply_strategy`; grant both by default\n # so the Strategist that may switch to (say) MCTS may also apply the `c`/`eta` it decided — else its\n # tuned params are silently dropped and the recorded `active_strategy` diverges from the live engine.\n \"policy_params\": [\"strategist\"],\n \"n_seeds\": [\"strategist\"],\n \"ablate_every\": [\"strategist\"],\n \"merge_mode\": [\"strategist\"],\n \"complexity_cue\": [\"strategist\"],\n \"ablate_code_blocks\": [\"strategist\"],\n \"prefer_sweep\": [\"strategist\"],\n \"novelty_stance\": [\"strategist\"],\n \"developer\": [\"strategist\"],\n \"fidelity\": [\"strategist\", \"researcher\"],\n}\n\n\ndef default_agent_control() -> dict[str, list[str]]:\n \"\"\"A FRESH deep-ish copy of the shipped governance matrix (each role list copied so a caller can\n mutate its own map without touching the module constant). The SINGLE constructor of the default,\n called by both `Settings.agent_control`'s default_factory AND the Engine's direct-construction\n default — so \"Engine() == shipped defaults\" can't drift if the copy depth ever needs to change\n (e.g. nested values requiring copy.deepcopy), instead of two verbatim copy expressions.\"\"\"\n return {k: list(v) for k, v in DEFAULT_AGENT_CONTROL.items()}\n\n\nclass Settings(BaseSettings):\n \"\"\"The engine settings schema (every knob a run accepts).\n\n Timeout family — six distinct knobs, each owned by a different subsystem:\n - `timeout`: per-eval wall-clock budget for ONE experiment's evaluation (engine/eval).\n - `max_eval_timeout`: hard ceiling for a governed per-node Researcher timeout override.\n - `llm_timeout`: LLM request idle timeout — inter-token stall limit in stream mode\n (core.llm OpenAICompatibleClient).\n - `llm_header_timeout`: LLM first-byte (response-headers) window for stream attempts (core.llm).\n - `agent_time_budget_s`: wall-clock ceiling across a tool-using agent loop's turns (agents).\n - `dep_install_timeout`: per-package budget for auto-installing a missing dep (env self-prep).\n\n Config-source precedence is owned by `looplab.core.appconfig` (the loader) — see its module\n docstring for the one canonical order.\n \"\"\"\n # Config precedence: see appconfig.py — the loader owns the full order. The `.env` is read\n # so `looplab run`/`looplab ui` pick up LOOPLAB_LLM_BASE_URL etc. without exporting them by hand\n # (keys MUST carry the LOOPLAB_ prefix, same as the env vars). utf-8-sig tolerates a BOM from\n # Windows editors. The test suite disables this (tests/conftest.py) so a dev's real .env in the\n # repo root can't leak into assertions.\n model_config = SettingsConfigDict(\n env_prefix=\"LOOPLAB_\",\n env_file=\".env\",\n env_file_encoding=\"utf-8-sig\",\n extra=\"ignore\",\n allow_inf_nan=False,\n )\n\n @classmethod\n def settings_customise_sources(\n cls, settings_cls, init_settings, env_settings, dotenv_settings,\n file_secret_settings):\n \"\"\"Preserve source precedence across canonical/legacy parallelism names.\n\n Pydantic ranks init > env > dotenv correctly, but it ranks field names rather than semantic\n alias families. Promoting a legacy value inside each source before the merge makes an old\n explicit snapshot/file value beat a lower-priority canonical env value. ``appconfig`` also\n promotes its file/typed/--set sub-layers before combining them into the init source.\n \"\"\"\n def _with_parallel_aliases(source):\n return lambda: canonicalize_parallelism_source(source())\n\n return (\n _with_parallel_aliases(init_settings),\n _with_parallel_aliases(env_settings),\n _with_parallel_aliases(dotenv_settings),\n _with_parallel_aliases(file_secret_settings),\n )\n\n # Run profile (config-first preset, see PROFILES above). \"default\"/\"fast\" = today's lean\n # defaults; \"thorough\" turns the built quality/trust machinery ON in one word. A profile only\n # fills fields the user did NOT set — every knob it touches stays individually overridable.\n profile: str = \"default\"\n\n # Bounds (review C/⚪): a non-positive node/seed budget or timeout is never valid. Upper bounds are\n # equally load-bearing — the UI /start path writes these straight into the engine's env, and an\n # unbounded `eval_parallel` (or legacy `max_parallel`; e.g. 100000 from a crafted preflight) would\n # fan out that many sandboxes → resource exhaustion. Reject at config time, not mid-run. Ceilings\n # are generous; tests top out near 50.\n n_seeds: int = Field(default=3, ge=1, le=1024)\n max_nodes: int = Field(default=8, ge=1, le=1_000_000)\n # CODEX AGENT: legacy evaluation-width input. `eval_parallel` below is canonical and wins when set;\n # keep this field for old env/config/snapshots and direct Engine call sites. A width > 1 admits\n # concurrent evals, whose resource reservations may be CPU-only or span one/more GPUs — there is no\n # one-node/one-GPU promise. `max_parallel=0` is launch-time AUTO (detected GPU count, at least 1).\n max_parallel: int = Field(default=1, ge=0, le=1024)\n # CODEX AGENT: legacy build-width input. `llm_parallel` below is canonical and also controls the\n # shared provider-call total when positive. A width > 1 uses fresh pooled researcher/developer pairs;\n # it says nothing about the later eval's CPU/GPU reservation. Startup `0` derives build fan-out from\n # resolved eval width; a live Strategist/operator zero settles to 1. Without role_factory it clamps to 1.\n parallel_build: int = Field(default=1, ge=0, le=64)\n # Hypothesis-card Kanban re-architecture (docs/23, Layer 2). Two INDEPENDENT concurrency axes,\n # decoupled from each other so cost (LLM work) can be tuned separately from throughput (GPU evals).\n # These are the CANONICAL names going forward; `max_parallel`/`parallel_build` above are kept as thin\n # back-compat aliases (env vars / snapshots / ~100 Engine(...) call sites depend on the exact names,\n # so they cannot be renamed — CLAUDE.md). Resolution (orchestrator.__init__): a NEW name, when set,\n # WINS over its legacy alias; None => fall back to the legacy field => byte-identical to today.\n # `eval_parallel`: concurrent EVALS (the GPU/experiment consumer). None -> max_parallel; 0 = AUTO\n # (one experiment per detected GPU). This is the in-Run eval-concurrency ceiling; the resource\n # scheduler bin-packs declared footprints separately. Sibling local GPU-owning Runs serialize\n # through the conservative host-pool lease; external execution needs its own admission boundary.\n # `llm_parallel`: total concurrent LLM provider calls + BUILD fan-out. None -> legacy\n # parallel_build semantics without a shared total; 0 = startup AUTO (= resolved eval_parallel\n # for build fan-out, historical research overlap unbounded). A positive canonical value enables\n # the named-lane broker the Strategist can reallocate at cadence.\n eval_parallel: int | None = Field(default=None, ge=0, le=1024)\n llm_parallel: int | None = Field(default=None, ge=0, le=64)\n # Training-log monitor (I-series watchdog family): a per-eval background observer that tails the live\n # training log while a (often multi-hour) declared stage runs in a worker thread. ON in the product\n # surface (Settings). Its alert event is fold-ignored and cannot directly change lifecycle/champion;\n # CODEX AGENT: with watchdog_reflection on, that alert is nevertheless advice in a later Researcher\n # prompt and can change future proposals. It only fires on the command-eval path with an LLM client;\n # bare-library EngineOptions stays OFF. See test_options_divergence.\n # `_interval_s` is the BASE tick cadence (the effective one adapts to the per-experiment budget).\n train_monitor: bool = True\n train_monitor_interval_s: float = Field(default=600.0, gt=0)\n # Phase 3 — INTERVENTION (opt-in, separate from observation): let the monitor tree-kill a training the\n # LLM judges 'broken' (diverged / silent CPU fallback / not learning) EARLY, instead of burning the\n # whole budget. Off by default — the observer only WATCHES unless this is on. A kill only fires on a\n # 'broken' verdict (a plateau is 'watch', never killed) with confidence >= the threshold; the node then\n # fails normally (reason='monitor_broken'), so replay reconstructs it from that one terminal event.\n train_monitor_kill: bool = False\n train_monitor_kill_confidence: float = Field(default=0.8, ge=0.0, le=1.0)\n # ASHA live-curve watchdog (sibling of the training monitor): reads the latest INTERMEDIATE value of\n # the objective metric off the live log (reusing the eval's OWN metric reader). Finished-endpoint rank\n # remains advisory. An opt-in KILL additionally requires an operator-declared metric.resource_key and\n # enough sibling observations at exactly that resource; absent comparable evidence cannot stop a run.\n # Records a fold-ignored `asha_rank` diagnostic + trace span; the library default is OFF (off == today).\n asha_live: bool = True\n # Opt-in INTERVENTION (separate from the advisory signal): let the watchdog tree-kill a node whose\n # intermediate metric stays below the SAME-RESOURCE bar past a short grace window. Off by default —\n # it only surfaces endpoint rank unless this is on. A kill fails normally (asha_underperforming).\n asha_live_kill: bool = False\n # The bar sits at `asha_live_quantile` along a WORST->BEST ordering of the finished siblings' finals:\n # 0.5 = the median (a node worse than the median peer flags); a SMALLER value is more conservative —\n # it lowers the bar toward the WORST finished peer, so only a node worse than nearly all peers flags\n # (0.0 = worse than the worst). `min_siblings` is the floor of finished peers required before it ranks\n # at all, so it never acts on too little evidence.\n asha_live_quantile: float = Field(default=0.5, ge=0.0, le=1.0)\n asha_live_min_siblings: int = Field(default=3, ge=1)\n timeout: float = Field(default=30.0, gt=0)\n # Hard ceiling for the Researcher's governed per-node `Idea.eval_timeout`. One hour preserves the\n # existing heavy/neural-net authoring example while staying well below the sandbox's defensive\n # 24-hour subprocess ceiling. This is operator-owned run config: agents may request less or more,\n # but the accepted action is always clamped here after the `agent_control.timeout` permission gate.\n max_eval_timeout: float = Field(default=3600.0, gt=0, le=24 * 3600.0)\n # Intra-node sweep: a sweep node runs a whole grid in one process, so it gets this multiple of\n # `timeout` as its wall-clock budget (solution.py path; RepoTasks use their per-profile timeout).\n sweep_timeout_mult: float = Field(default=8.0, ge=1)\n # Sandbox tier (ADR-13): \"trusted_local\" (subprocess, no Docker) for the CLI;\n # \"untrusted\" (Docker --network none, shared-kernel runtime) for hosted/multi-tenant UI;\n # \"hostile\" (untrusted + a true-isolation OCI runtime, gVisor `runsc` by default / Kata) for\n # running untrusted code. See runtime/sandbox.py::make_sandbox for the tier selection.\n trust_mode: str = \"trusted_local\"\n # Image for the untrusted command-eval tier (RepoTask, Phase 4): the framework's deps\n # should be baked in (the container runs --network none, so a pip setup can't fetch).\n docker_image: str = \"python:3.12-slim\"\n # Resource caps for the untrusted/hostile Docker tier (passed to `docker run --memory/--cpus`).\n # The default 4g bound protects other tenants from an OOM, but a model-training MLE-bench eval\n # routinely needs more — raise it (e.g. \"16g\") for such runs. \"\" disables the cap (unbounded).\n # Ignored by the trusted_local (subprocess) tier.\n sandbox_memory: str = \"4g\"\n sandbox_cpus: str = \"\"\n # Best-effort host-OOM guard for the TRUSTED-LOCAL (subprocess) tier. When set (e.g. \"8g\"), each\n # eval child gets an RLIMIT_AS address-space cap so a runaway trainer hits MemoryError instead of\n # OOM-killing the whole host (and the engine). POSIX only; a no-op on Windows. Default \"\" = OFF:\n # RLIMIT_AS bounds VIRTUAL memory, and CUDA/torch reserve tens of GB of virtual without using it,\n # so a default cap would spuriously kill GPU evals — leave it off for those and use the Docker\n # tier's real --memory cgroup bound (sandbox_memory) instead. Ideal for CPU/numpy/sklearn runs.\n sandbox_memory_local: str = \"\"\n # Best-effort disk-fill guard for the TRUSTED-LOCAL tier (P1-5): an RLIMIT_FSIZE cap on the size of\n # any single file an eval child writes (e.g. \"2g\"), so a runaway that writes an unbounded file gets\n # SIGXFSZ instead of filling the host disk. POSIX only. Default \"\" = OFF (large model checkpoints\n # need big files); set it for tasks that write only small artifacts.\n sandbox_fsize_local: str = \"\"\n # Search policy (ADR-2): \"greedy\" | \"evolutionary\" | \"mcts\" | \"asha\".\n policy: str = \"greedy\"\n # Ablation-driven refinement (I7): every N improves, ablate the best to find the\n # highest-impact parameter and refine it. 0 = off. (GreedyTree only.)\n ablate_every: int = 0\n # A0a (MLE-STAR): when ablating, treat each generated pipeline *code block* (not just a\n # numeric param) as an ablation unit — neutralize a block, measure the metric delta, refine\n # the highest-impact block. Off => the classic numeric-param ablation. Config-first knob.\n ablate_code_blocks: bool = False\n # A0b: real merge/ensembling. \"mean\" = legacy mean-param merge; \"ensemble\" = the Developer\n # writes a code-recombination ensemble over the two parents' solutions (verified: agent-proposed\n # ensembling 37.9%->43.9%). \"auto\" (default) = ensemble whenever the Developer actually\n # generates code (LLM/agent backends; declared via `is_code_generating`), mean for the\n # templated/toy developers where a code ensemble is meaningless — the evidence says code\n # recombination is the strongest single merge (removing it costs ~9 pp), so it is the default\n # wherever it can work. Falls back to mean when the Developer can't ensemble.\n merge_mode: str = \"auto\"\n # A0d (AIRA): inject a dynamic complexity hint into the draft/improve prompt keyed on the\n # node's child count (few children -> keep minimal; many -> escalate to ensembling/HPO).\n complexity_cue: bool = False\n # I1 (CAAFE-style) LLM feature-engineering: instruct the proposer/developer to add\n # semantically-meaningful engineered features (ratios, interactions, aggregations) as code. The\n # existing cross-validation eval + best-selection is the CV GATE — a feature set that doesn't\n # improve CV is never selected (feature engineering is non-universal, so this gate is mandatory).\n # Tabular tasks (those exposing column data). Off by default.\n feature_engineering: bool = False\n # A5: surface the REMAINING eval-compute budget into the proposal prompt so the Researcher can\n # reason over how much compute is left (explore broad while flush, exploit/cheapen when low).\n # No-op unless a max_eval_seconds budget is set.\n budget_aware: bool = False\n # A4 (LATS-style): feed a summary of the most recent FAILED branches (operator + error reason)\n # back into the proposal prompt so the proposer reflects on and avoids repeating them. ON by\n # default: it is SELECTIVE by construction (injects only when recent failures exist — the\n # evidenced form of reflection; reflecting at every step shows no gains, arXiv:2506.12928).\n failure_reflection: bool = True\n # Feed the LIVE-WATCHDOG observations (train-monitor health verdicts + ASHA intermediate-rank flags)\n # of recent experiments back into the proposal prompt, so the Researcher avoids re-proposing a config\n # whose TRAINING was already observed to be weak — even when the watchdog kills are off (default) and\n # the node ran to completion (those diagnostics are fold-ignored, so failure_reflection never sees\n # them). ON by default and SELECTIVE like failure_reflection (injects only when a recent flag exists).\n watchdog_reflection: bool = True\n # C3 deep test-driven repair: hand the Developer the failure taxonomy + a structured \"reproduce\n # then fix\" directive on debug, not just the raw stderr tail. ON by default (product surface); the\n # conservative library default in EngineOptions stays off, per that module's contract.\n deep_repair: bool = True\n # === Inline crash repair ==============================================================\n # Hybrid in-node crash repair: when an LLM-generated node CRASHES at runtime (mechanical errors\n # — bad import, removed kwarg, typo), the agent triages it and may repair the code IN PLACE within\n # the same eval (cheap; does NOT consume max_nodes and does NOT add a node to the search tree).\n # Semantic failures still flow to a new idea/debug node via the policy. ON by default; set False\n # to restore the prior behavior (every crash waits for a budgeted inter-node debug node).\n inline_repair: bool = True\n # Max in-place repair attempts per node before it fails normally. Default 0 = UNLIMITED: the same\n # agent keeps fixing + re-evaluating IN THE SAME node until it produces a clean metric — no new\n # node just to retry. Runaway is prevented not by a count but by the anti-stuck guard\n # (`inline_repair_stuck_repeat`): when the SAME error signature repeats with no progress, or the\n # agent's crash-triage says \"abandon\", the node fails. Set a positive N to cap the retries instead.\n inline_repair_attempts: int = Field(default=0, ge=0)\n # Anti-stuck: abandon in-node repair when the SAME error recurs this many times in a row (no\n # progress). Keeps \"unlimited\" repair from looping forever on an unfixable error.\n inline_repair_stuck_repeat: int = Field(default=4, ge=2)\n # Which failure reasons (_failure_reason: crash|timeout|oom|setup|no_metric|drift) are eligible for\n # inline repair. Default: mechanical crashes, timeouts AND OOM-kills — a timeout/OOM means the code\n # was too slow / too memory-hungry for the budget (or the pod's cgroup limit), not that the idea is\n # wrong, so the agent repairs it by reducing compute/memory (fewer estimators/epochs/folds/seeds, a\n # lighter model, a smaller batch, subsampling) instead of letting the node die unrecovered. Env\n # override expects a JSON array.\n inline_repair_reasons: tuple[str, ...] = (\"crash\", \"timeout\", \"oom\")\n # Cost ceiling for in-node repair of a MULTI-STAGE eval: the max number of FULL pipeline re-runs\n # (i.e. re-trains from the first stage) the inline-repair loop may do before abandoning to the\n # inter-node debug operator. A late-stage failure (a broken `score`/eval script that didn't touch\n # `train`) REUSES the completed train checkpoint and re-runs only from the failed stage — that is\n # CHEAP and NOT counted here; only a repair that changes an EARLIER stage's code (train.py/loss.py/…)\n # forces a full re-train, which each costs the whole training time. Without this bound, a repair that\n # keeps changing training code could burn many full trains (the anti-stuck guard is error-signature\n # based, not cost based). 0 = unlimited (legacy behavior). Single-command evals ignore it.\n inline_repair_retrain_cap: int = Field(default=2, ge=0)\n # Environment self-prep: when a solution crashes purely because a KNOWN library isn't installed\n # (ModuleNotFoundError), the engine pip-installs it into the eval interpreter and re-runs — so a\n # torch/XGBoost/CatBoost idea (e.g. a GRU model) actually runs on a fresh box instead of being\n # thrown away as `idea_rejected`. Trusted_local tier only (the untrusted/hostile Docker tiers run\n # --network none). A typo'd or local helper module is NOT installed (that's a code bug to repair).\n auto_install_deps: bool = True\n # Per-package wall-clock budget for an auto-install (seconds). Generous: large wheels like torch\n # can take minutes to download/build.\n dep_install_timeout: float = Field(default=900.0, gt=0)\n # --- Agent governance: who may change a setting at runtime (per-setting allow-list of roles) ---\n # Roles: \"strategist\" (A7 meta-controller; run-wide), \"boss\" (run-chat operator-proxy; run-wide),\n # \"researcher\" (per-experiment proposer; PER-NODE — e.g. set a longer eval timeout for a neural-net\n # idea). A setting ABSENT from this map is LOCKED — no agent may change it, only a human via the\n # snapshot/UI. The sole conditional exception is Strategy-only `card_scoring`: Card mode grants\n # it implicitly without changing the default flag-off snapshot, while an explicit [] revokes it.\n # The gate is ENFORCED at every AGENT seam via `_agent_may` — the strategist's whole\n # control surface (policy/operators/novelty_stance/prefer_sweep/developer/fidelity/timeout/\n # eval_parallel/llm_parallel) is checked in `_apply_strategy`, so removing a role from a knob locks it,\n # not just greys out the UI pill. The boss/max_* grants document boss-relevant knobs, but a\n # `budget_extend` is a HUMAN control intent (the boss action-builder can only emit `add_nodes`), so\n # it is applied as-is — the human always wins via the UI/snapshot. Defaults are conservative: resource/\n # search-shape knobs the agents already reason about; provider infra (llm_model/base_url/api_key,\n # docker_image) stays locked. The UI shows S/B/R pills per setting. `timeout` granted to the\n # researcher IS the \"auto\" per-node mode — the researcher sizes each experiment's wall-clock\n # (Idea.eval_timeout); the config `timeout` is the fallback for nodes it doesn't size. Env override\n # expects a JSON object. (`fidelity`/`novelty_stance`/`prefer_sweep`/`developer` are governance keys\n # for the strategist's per-node dials — not 1:1 Settings fields, but gated the same way.) The default\n # lives in the module-level DEFAULT_AGENT_CONTROL so the Engine's direct-construction default matches.\n agent_control: dict[str, list[str]] = Field(default_factory=default_agent_control)\n # C1 (Agentless localization): for repo tasks, rank the source files most relevant to the most\n # recent failure (traceback + identifiers) and surface them in the proposal/repair prompt so the\n # Developer edits the right place. Off by default; repo tasks only.\n localize_faults: bool = False\n # A2 surrogate-guided proposal (BO-lite): fit a k-NN surrogate over (params->metric) and propose\n # by acquisition instead of random/hill-climb. Numeric-bounds tasks only; bootstraps via the\n # base Researcher. Off by default. `surrogate_explore` = UCB-style exploration weight.\n surrogate_proposer: bool = False\n surrogate_explore: float = 0.1\n # E2 researcher panel: generate K candidate ideas per proposal and keep the one ranked best by a\n # cheap empirical surrogate over the (params->metric) history (NOT an LLM-judge). 1 = off.\n researcher_panel: int = 1\n # A1 ASHA / successive-halving: reduction factor (keep top 1/eta per rung) and rung budget.\n asha_eta: int = Field(default=3, ge=2)\n # Rung-0 width for ASHA/BOHB (the wide base of cheap drafts). 0 = use n_seeds (default,\n # preserving prior behavior); set >0 to seed a wider/narrower base independent of n_seeds.\n asha_rung_nodes: int = Field(default=0, ge=0)\n # A6 proxy/predictive scoring: cheaply rank a candidate's potential from early-stage signals\n # and skip a full eval for the doomed bottom fraction. 0.0 = off (no candidate skipped).\n proxy_scoring: bool = False\n proxy_kill_fraction: float = Field(default=0.0, ge=0.0, le=0.9)\n # === Novelty / dedup ==================================================================\n # E1 novelty/dedup gate: reject near-duplicate proposals (normalized param-space distance below\n # `novelty_epsilon`) by deterministically nudging them off the duplicate — stops the search\n # wasting evals re-trying the same idea. Audit event `novelty_rejected`. Off by default.\n # Novelty/dedup MODE selector: how a fresh proposal is checked against already-tried experiments.\n # \"off\" — no explicit gate; the agentic Researcher's own read-the-history judgment is trusted.\n # \"algo\" — the deterministic gate: numeric param-distance (`novelty_epsilon`) + optional embedding\n # similarity (`novelty_semantic`). Cheap, no LLM call, but can't explain itself.\n # \"llm\" — an LLM adjudicates (reads the real experiments via tools) whether the idea is a\n # near-duplicate and, if so, asks the Researcher once more for something different. One\n # extra LLM call per proposal — highest quality, follows the \"let the LLM decide\" line.\n novelty_mode: str = \"llm\"\n # Legacy sub-toggles, honored by the \"algo\" mode (and back-compat: novelty_gate=True forces \"algo\"):\n novelty_gate: bool = False\n novelty_epsilon: float = 0.05\n # T5 semantic novelty (active whenever the deterministic gate runs — novelty_mode=algo,\n # novelty_gate=true which is the legacy alias for algo, OR the Strategist novelty stance is\n # \"explore\"; a no-op only under novelty_mode=llm/off with a non-explore stance): reject a proposal\n # whose idea TEXT embeds within\n # `novelty_semantic_threshold` cosine of an existing node's. OFF BY DEFAULT: novelty is the agentic\n # Researcher's call, made by READING the actual prior experiments (read_experiment /\n # find_analogous_across_runs / list_experiments), NOT an embedding-similarity auto-reject that can't\n # explain itself and mis-fires on paraphrases. Semantic/param search stays available as TOOLS in the\n # Researcher's context; the LLM decides whether an idea is a true duplicate.\n novelty_semantic: bool = False\n novelty_semantic_threshold: float = Field(default=0.92, ge=0.5, le=1.0)\n # T10: debug-lineage depth bound for every policy — how many error-feedback repairs a failing\n # lineage gets before it is abandoned. The old default (1) abandoned lineages after ONE repair;\n # multi-turn/deeper debugging is a verified lever (AIRA2 ReAct debug +5.5 percentile points).\n debug_depth: int = Field(default=2, ge=1)\n # P4 operator bandit (GreedyTree): replace the fixed merge/ablate cadences with a\n # deterministic UCB over per-operator yield (Δmetric per eval-second, folded from the run).\n # Off by default (the cadences are well-tested and the bandit has no direct published\n # ablation); `thorough` turns it on.\n operator_bandit: bool = False\n # P1 hypothesis ledger: ask the Researcher to state the one-line hypothesis each experiment tests,\n # register deep-research directions as hypotheses, and track them to a verdict on the board. ON by\n # default. CODEX AGENT: the board is shown to the Researcher and can be foresight-ordered, so it can\n # steer later proposals even though it never directly re-ranks already-evaluated nodes. Set False\n # to drop the prompt nudge + registration.\n track_hypotheses: bool = True\n # E4/M2/M3 reflection-memory -> priors (gradient-free cross-run meta-learning): at run end distill\n # the winner into `<memory_dir>/meta_notes.jsonl` AND structured lessons (incl. NEGATIVE results:\n # tested/abandoned hypotheses + failure themes) into `lessons.jsonl` with a task fingerprint; at run\n # start, inject exact-task notes + fingerprint-matched lessons from SIMILAR tasks into the proposal\n # prompt. ON by default — but a NO-OP until `memory_dir` is set (that's where cross-run memory lives).\n reflection_priors: bool = True\n # M6 comparative lessons (MARS \"comparative reflective memory\", doc 13 §7 item 2): distill\n # credit-assigned lessons from PAIRS of solutions — which SPECIFIC change made a child beat\n # (or regress from) its parent, and what fixed a failure — on top of the one-shot run-end\n # reflection. At most one extra LLM call per distillation (all pairs batched); offline a\n # deterministic param-diff credit stands in. Gated on reflection_priors + memory_dir like the\n # rest of cross-run memory — and since memory_dir has a default (~/.looplab/memory), this is\n # ACTIVE out of the box; clear memory_dir to disable cross-run memory wholesale.\n comparative_lessons: bool = True\n # M6 mid-run cadences (doc 13 §7 item 5 — the AgentRxiv live-share pattern): every N created\n # nodes, WRITE comparative lessons to the shared cross-run store during the run (lessons_every\n # — one batched LLM call per firing) and RE-READ the store so lessons distilled by CONCURRENT\n # runs reach this run's proposals (lessons_refresh_every; file re-read, no LLM call, skipped\n # when the store is unchanged). ON by default (every 4 nodes) and, like comparative_lessons\n # above, live out of the box because memory_dir defaults on; 0 = off — run-end write /\n # run-start read only, the pre-M6 behavior.\n lessons_every: int = Field(default=4, ge=0)\n lessons_refresh_every: int = Field(default=4, ge=0)\n # B3 output redaction: mask credentials (known key shapes + high-entropy tokens) in the\n # stdout/stderr tail before it is persisted to the event log / spans / UI — a leaked secret in a\n # print()/traceback must not land in the durable log. Off by default; recommend on for untrusted.\n redact_output: bool = False\n # B5 reward-hacking detector: a host-side monitor that flags suspicious wins (grader/answer-key\n # access, runtime writes to frozen files, suspiciously-perfect metrics) as a `reward_hack_suspected`\n # audit event in the Trust panel. Off by default. Whether a flag CHANGES selection is governed by\n # `trust_gate` below (default: audit-only, never changes selection).\n reward_hack_detect: bool = False\n # Trust enforcement (T2): what a reward-hack / data-leakage flag actually DOES to selection.\n # \"audit\" (default) = surface it in the Trust panel, never change selection (today's behavior);\n # \"gate\" = a flagged node can no longer be selected as BEST **and isn't bred/confirmed from**\n # (breed_excluded — see replay._apply_trust_gate / models.breedable_nodes), but stays\n # FEASIBLE so it still counts for diversity/audit — closes the \"a hacked/leaky node can\n # win OR seed its lineage\" hole;\n # \"block\" = additionally mark it fully INFEASIBLE (removed from feasible_nodes entirely).\n # Deliberately gates only high-precision CHEATING/LEAKAGE signals. Broad `critic:*` findings stay\n # advisory; CODEX AGENT: `critic:hardcoded_metric` is the one narrow high-precision critic exception\n # and gates because it requires a literal metric with no computed assignment anywhere.\n trust_gate: str = \"audit\"\n # I3 data-centric: static code-leakage scan of each evaluated solution (fit-before-split,\n # fit-on-test) surfaced into the Trust panel alongside reward-hack flags. Off by default.\n code_leakage_detect: bool = False\n # 4.4 sandbox instrumentation (needs reward_hack_detect): after each eval, flag a RUNTIME write\n # to a protected/frozen file (an answer key or grader tampered mid-run) — behavioral evidence a\n # static code scan misses. ON by default; only fires when the reward-hack detector is on.\n workdir_audit: bool = True\n # D8 decoupled research Verifier: check every Deep-Research memo's CLAIMS against their cited\n # evidence (node ids / urls) before recording — synthesis is the documented weak link (Kosmos\n # 57.9% accurate). Deterministic layer always; an LLM rubric pass when the DeepResearcher has a\n # client. The verdict cannot change this run's node/champion, but finalize uses aligned supported\n # verdicts as the evidence gate for persisted D8 cross-run claims; unavailable/misaligned stays\n # unverified. ON by default (no-op with no memo/claims).\n research_verify: bool = True\n # C4 independent critic: an execution-free critic of each solution (stub / hardcoded-metric /\n # params-ignored; on host-graded tasks the metric checks become a submission-output check)\n # surfaced in the Trust panel. Broad findings are advisory; `critic:hardcoded_metric` can gate under\n # trust_gate=gate/block. Off by default.\n critic_check: bool = False\n # A7 Strategist (NEW, user-requested): optional LLM/rule meta-controller that picks the search\n # policy/allocator + operator mix + fidelity (+ Developer backend) per situation. Config-first:\n # every choice the Strategist makes is also a direct knob. Defaults to \"agent\" so the agent adapts\n # the search policy/operators/fidelity per situation; set \"off\" for static config-driven search.\n # \"agent\" = a tool-using Strategist that READS the run/data/sibling-runs/KB/memory (B1-guarded)\n # before deciding, instead of the single-shot \"llm\" call over aggregate stats.\n strategist_backend: str = \"agent\" # \"off\" | \"rule\" | \"llm\" | \"agent\" — default AGENTIC: the\n # Strategist READS the run/data/sibling-runs/KB/memory with tools before deciding, not a single-shot\n # call over aggregate stats. \"llm\" = the old non-agentic single-shot; \"rule\"/\"off\" = no LLM.\n strategist_every: int = Field(default=3, ge=1) # consult cadence (created nodes)\n # PART V (F1): concept CLASSIFIER re-tag + consolidation cadence, DECOUPLED from strategist_every. The\n # LLM concept map is heavier and slower-moving than a strategy consult, so it refreshes on its own\n # (sparser) interval. Researcher-authored idea.concepts still fold immediately at node_created — this\n # only paces the classifier-evidence + consolidation refresh (and the concept-coverage pivot snapshot).\n concept_retag_every: int = Field(default=30, ge=1)\n # === Confirmation & holdout ===========================================================\n # Multi-seed confirmation (I12, ADR-15): confirm the top-k under N seeds before\n # finishing. 0 disables (default). Only meaningful when eval has variance.\n confirm_top_k: int = 0\n confirm_seeds: int = 0\n # D1 seed-holdout discipline: the FIRST seed the confirm phase uses. Search evals run with the\n # implicit seed 0 (LOOPLAB_EVAL_SEED unset -> \"0\"), so a base of 1 keeps every confirm split\n # DISJOINT from anything the search optimized against — the confirm metric is then a\n # generalization signal, not a re-measurement (AIRA: selecting on a signal the search saw\n # overfits by 9-13 pp). Set 0 to restore the legacy overlapping seeds 0..N-1.\n confirm_seed_base: int = Field(default=1, ge=0)\n # D1 holdout-gated promotion (B6, Arbor-style): for host-graded tasks, reserve this fraction of\n # the held-out labels as a FINAL holdout partition the search never sees — every search/confirm\n # eval is scored on the remaining rows only, and at finish the val-top-k are re-scored on the\n # holdout partition (free: the predictions already exist). 0.0 = off (legacy full-label scoring).\n holdout_fraction: float = Field(default=0.25, ge=0.0, le=0.9)\n # Champion selection prefers the HOLDOUT metric among the nodes that have one (the val-top-k).\n # Recorded in run_started so replay applies the same rule; old logs fold to False (legacy pick).\n # The per-node val-holdout `generalization_gap` folds into the Trust panel either way.\n holdout_select: bool = True\n # How many val-leaders get a holdout evaluation at finish (host-graded tasks).\n holdout_top_k: int = Field(default=3, ge=1)\n # R1-c / Part IV — calibrated §12-verifier tie-break in best-selection. When a fresh node's robust\n # metric EXACTLY TIES an already-eligible node, the engine runs the §12 verifier (grounded on each\n # node's realized result, `selection_criteria`) and records a soundness score per node. The tie-break\n # then applies across the WHOLE selection path: the mean pick breaks a robust_metric tie by the score,\n # and — when `holdout_select` is on — the holdout-slot ranking prefers the sounder node so it isn't\n # denied a holdout eval, and the final holdout override breaks a holdout_metric tie by it too. Strictly\n # a TIE-BREAK — it can NEVER move a node ahead of a strictly-better metric (§21.7 advisory-never-\n # overrides). Best-effort: verification is LAZY (only fires on an actual tie) and bounded per cadence,\n # so a large/late tie stays on deterministic metric+id order unless one atomic treatment scores every\n # member. Recorded in run_started so\n # replay applies the same rule; old logs fold to False (legacy pick). Opt-in (default off); needs a\n # reachable LLM client. Enable only after validating calibration offline with `verifier.calibrate`.\n # See trust/verifier.py + core/fitness.py.\n select_verifier: bool = False\n # R1-d (§21.19): widen the verifier tie-break from EXACT-metric to a conservative STATISTICAL tie.\n # The band is capped by both the metric leader's SE and the pooled SE-of-difference, so challenger\n # variance cannot widen it and a significant difference is never a tie (§21.7). Off -> exact only.\n verifier_ci_tie: bool = False\n # Verifier sample count for `select_verifier` (the §12 repeated-sampling expectation). 3 tames the\n # single-shot variance; 1 = single-shot (cheaper, noisier).\n select_verifier_samples: int = Field(default=3, ge=1, le=32)\n # Budget (I13): hard wall-clock ceiling; the run aborts cleanly when exceeded.\n max_seconds: float | None = None\n # Eval-compute budget (#2): hard ceiling on cumulative time spent INSIDE evals (training\n # runs), separate from wall-clock. Survives resume (summed from the event log). The real\n # guard against a silent multi-hour sweep when an eval is a minutes-hours training run.\n max_eval_seconds: float | None = None\n # Cross-run memory (I19, ADR-10): if set, the best result of each run is stored as\n # a case here, and the cases become retrievable knowledge for future runs.\n memory_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"memory\"))\n # HITL (I21, ADR-11): pause for human approval of the final best before finishing.\n require_approval: bool = False\n # Diversity archive (I22): niche bucket width in parameter space.\n archive_resolution: float = 1.0\n # Coverage read-model (narrowing signal): at the strategist cadence, compute a breadth summary\n # (themes / param-niches / theme entropy / dominant-theme fraction) from the folded run, record\n # it as a `coverage_snapshot` audit event, and surface it into the Strategist's decision context.\n # Deterministic, cheap, and purely additive context — no search-behavior change on its own. On by\n # default (like the rest of the always-on situational context). See search/coverage.py.\n coverage_context: bool = True\n # PART IV Phase 2a live steering (§21.11/§21.13). When on, the `concept_retag_every` cadence (NOT\n # `strategist_every` — the producer gates on `_should_consult_concepts`, which uses the seed boundary\n # + `concept_retag_every`, default 30) records a\n # concept-graph coverage + uncovered-region snapshot (deterministic heuristic tagger over the\n # task-type skeleton) and, on an `explore` stance, the Researcher's novelty hint names the exact\n # uncovered regions (\"0 coverage in {negatives/external-mining, distillation} — go there\") instead\n # of the vague \"broaden\". ON by default in the product Settings (ce4a379), EngineOptions off: it\n # only enriches an already-mode-gated explore hint, never forces exploration, and no-ops for a task\n # with no curated concept skeleton. The snapshot is auditable, but its prompt cue can change future\n # candidate generation; it does not directly rewrite best-metric selection. See search/concept_graph.py.\n # DESIGN NOTE (2026-07-17 critique): now ON unconditionally, but a toy / non-ML task yields EMPTY concept\n # tags while still paying the per-node LLM concept cost (~10 calls/node with the full Part IV/V bundle).\n # Consider AUTO-QUIESCING: if the heuristic tagger returns empty for the first N nodes (no concept signal),\n # skip the LLM concept work for the run — keeps it on for ML tasks, free on toy/degenerate ones.\n concept_pivot: bool = True\n # PART IV Phase 2b — D3 graded novelty into the LIVE novelty gate (§21.4/§21.13). When on and the\n # task has a curated concept skeleton, the novelty gate first grades a proposal over the concept\n # graph: a level-4 \"same direction, DIFFERENT implementation\" or a level-5 \"re-opens a\n # wrongly-abandoned FAILED direction\" proposal is ALLOWED through unchanged — the flat LLM/semantic\n # dedup gate can't tell \"this DCL tweak\" from \"the whole DCL branch\" and would wrongly reject a\n # legitimate variant or never re-open a sound-but-killed direction (the node_63 archetype). Levels\n # 1/2/3 (identical / near-dup / prior-run) still fall through to the existing gate. Deterministic\n # (heuristic tagger, no LLM), audit event `novelty_graded`, replay-safe. ON by default in the product\n # Settings (ce4a379), EngineOptions off; no-ops for a task with no skeleton. See search/graded_novelty.py.\n graded_novelty: bool = True\n # PART IV Phase 2b — D7 capability-expansion forced-jump DIRECTIVE (§21.8/§21.13, issue #7). When on\n # and the concept-graph cadence detects action-space LOCK-IN (the search has stayed inside one D5\n # branch for a long consecutive streak) on an `explore` stance, the Researcher's novelty hint\n # ESCALATES from \"broaden\" to \"expand the ACTION SPACE — build the missing infra (external mining,\n # a new data pipeline, a different eval), do NOT swap another {locked} lever.\" CODEX AGENT: the same\n # gate also stamps the proposal's authoritative operator as `expand`; that node competes normally\n # under SearchFitness and its separate operator yield is measured. It does not force a winner.\n # Reads the 2a concept-coverage snapshot, so it needs `concept_pivot` to record one. OPT-IN\n # (default off). See engine/proposal_cues.py + search/lock_in.py.\n capability_expansion: bool = False\n # PART IV cross-run Step 0 (§21.20.12/§21.20.13). Universal task-fingerprint tokenization: drop the\n # ASCII-only `[a-z0-9]` allowlist on goal keywords so a non-Latin goal (Russian, CJK, …) is NOT\n # silently dropped from its cross-run fingerprint (today such a goal keys only on kind/dir/metric and\n # never reaches SIMILAR-task priors/lessons/cases). Uses `[^\\W_]+`/`.casefold()` — same splitting as\n # before, any script. ON by default in the product Settings (ce4a379); the bare-library EngineOptions\n # default stays off, so a run pinned to it won't silently re-key a portfolio mid-flight. See engine/memory.py.\n fingerprint_universal: bool = True\n # PART IV cross-run Step 2 (§21.20). Fill graded-novelty's `prior_concepts` from the cross-run\n # ConceptCapsuleStore: at run end write a per-run concept capsule (the shipped `node_concepts` tags +\n # best outcome, keyed by task_fingerprint) to `memory_dir`; when a later SIMILAR run proposes an idea\n # whose concept was tried before, SURFACE the prior outcome as a `cross_run_prior` audit event (never\n # reject — D3 level 3). Audit-only, off the selection path; ON by default in the product Settings\n # (ce4a379), EngineOptions off. Needs a `memory_dir` to share capsules.\n # See engine/memory.py (ConceptCapsuleStore) + engine/novelty.py.\n # PREREQUISITES (CODEX — this flag is NOT standalone): the WRITE needs per-run concept tags\n # (`node_concepts`, produced by `concept_pivot`/F1 tagging) — no tags => no capsule; the SURFACE path\n # runs inside the graded-novelty precheck, so it also needs `graded_novelty` on. With only this flag +\n # `memory_dir` it persists/surfaces nothing. Enable `concept_pivot` + `graded_novelty` alongside it.\n # A non-Latin (Russian/CJK) portfolio should ALSO set `fingerprint_universal`, else the capsule's goal\n # tokens are dropped and its fingerprint over-matches on kind/dir/metric alone (the novelty direction\n # gate narrows this, but goal-word similarity is still lost).\n cross_run_concepts: bool = True\n # PART V (B): run-base + node-DELTA concept authoring. When on, once the first evaluated node has\n # authored concepts the engine seeds RunState.run_base_concepts from them (a one-shot EV_RUN_CONCEPTS,\n # idempotent because it only fires while the base is empty), and the Researcher's proposal hint tells it\n # to author only `concepts_added`/`concepts_removed` vs the run base + its parent — minimal per-node\n # annotations that inherit down the DAG (a swap propagates). Off -> every node authors its full concept\n # set as before. Product-on; the bare EngineOptions mirror defaults off (test_options_divergence).\n concept_run_base: bool = True\n # PART IV cross-run Step 5 advisory (§21.20.5). Fold the bounded cross-run CONTEXT PACK — evidence-\n # grounded claims with BOTH support and counter-evidence (Step 4) + a portfolio-coverage line (Step 3) —\n # into the Researcher's proposal prompt, exactly like the E4 cross-run prior note. Advisory ONLY: it is\n # prompt-grounding: it can change the Researcher's later candidates, but does not directly rewrite\n # best-metric selection (§21.7). Reads `memory_dir` (lessons.jsonl + concept_capsules.jsonl); \"\" when\n # empty. ON by explicit experimental product choice; no frozen A/B validation is claimed. The\n # bare-library EngineOptions default stays off (engine/options.py, frozen in\n # test_options_divergence). See engine/proposal_cues.py.\n cross_run_advisory: bool = True\n # PART IV cross-run §21.20.13 (full CR of the lean fuzzy claim merge). Switch the claim read-model to the\n # SCOPE+POLARITY-safe STRUCTURED claim key (engine/claim_key.py): claims from different tasks never\n # merge, opposite polarity (\"X helps\" vs \"X never helps\") is surfaced as a CONTRADICTION instead of being\n # collapsed, paraphrase/inflection variants group by exact structured key (no transitive over-merge), and\n # operator governance is scope-precise (a decision in task A cannot reach a same-worded claim in task B).\n # Affects the `cross_run_advisory` context pack only; ON by default in the product Settings (ce4a379);\n # the bare-library EngineOptions default stays off (engine/options.py). See engine/claims.py.\n cross_run_structured_claims: bool = True\n # PART IV cross-run §22.4 (AGENTIC taxonomy steward). At finalize, when an LLM client is available, let\n # the concept steward (engine/concept_steward.py) review the freshly-updated portfolio concept graph and\n # PROPOSE a curation — merge duplicate slugs / split conflated ones / purge noise. Proposals are LOGGED to\n # `concept_curation_log.jsonl` for operator ratification (via the serve/CLI governance surface); the LLM\n # never mutates fold state. The calls run synchronously during finalize, so they add latency and may add\n # paid inference; a caught steward failure does not prevent terminal completion or trigger a retry.\n # ON by explicit experimental product choice when `memory_dir` + an LLM backend are present; the\n # bare-library EngineOptions default stays off (engine/options.py). See engine/lessons.py.\n cross_run_curation: bool = True\n # Deprecated compatibility flag. Steward output is untrusted and finalize is proposal-only regardless\n # of this value; retained so old snapshots still validate and logs can disclose that auto was requested.\n cross_run_curation_auto: bool = False\n # Role backend (ADR-7/14): \"toy\" (offline optimizer) | \"llm\" (live model).\n backend: str = \"toy\"\n # Developer backend (ADR-7): \"default\" (templated/LLM from the task) or an external\n # CLI coding agent: \"opencode\" | \"aider\" | \"goose\" | \"continue\".\n developer_backend: str = \"default\"\n # C2 best-of-N: generate N candidate implementations per node and keep the best by an\n # execution-free reward (static validity + metric-print). 1 = off. In-house LLM developer only\n # (not external agents — ADR-7 cost rule). The top SWE-bench reliability lever for weak models.\n best_of_n: int = 1\n # D10 list-wise best-of-N selection: when the top static-scorers TIE, break the tie with a\n # comparative LLM selection over the candidates presented together (+~3 pts vs pointwise on\n # GAIA, arXiv:2506.12928). The static score stays the primary filter — the LLM is a weak\n # comparative prior, never the sole oracle. ON by default (no-op when best_of_n==1).\n best_of_n_listwise: bool = True\n # === Foresight / predict-before-execute ===============================================\n # FOREAGENT predict-before-execute (arXiv:2601.05930): use the LLM as an IMPLICIT WORLD MODEL to\n # predict — WITHOUT executing — which candidate / hypothesis will score best, primed with a\n # Verified Data Analysis Report (data profile / brief) + the accumulated experiment memory. Master\n # switch, ON by default; a no-op wherever there is nothing to rank (best_of_n==1 and\n # foresight_panel==1) or no LLM client. See `looplab/search/foresight.py`.\n foresight: bool = True\n # Hypothesis foresight breadth: generate K candidate ideas per proposal and keep the one PREDICTED\n # best pre-execution by the world model — it ranks the STRUCTURAL / text ideas the numeric k-NN\n # surrogate (researcher_panel) is blind to, primed with the data profile + memory (the synergy).\n # >1 enables it (LLM backend only; needs `foresight` on); 2 = on by default at modest cost, 1 = off.\n foresight_panel: int = 2\n # AGENTIC foresight: run the ranking (hypothesis-board prioritization + K-idea pick) as a TOOL-USING\n # loop that can pull actual experiment results / data facts before deciding, instead of a one-shot\n # prediction from a pre-baked report. ON by default (needs foresight + a client; falls back to the\n # one-shot predictor on any hiccup). A few extra LLM calls per proposal; set False for the cheaper\n # single-call ranker.\n foresight_agentic: bool = True\n # §1 foresight confidence gate: the minimum predicted confidence at which a predict-before-execute\n # pick is ACTED ON. Below it the ranker abstains — the K-idea panel falls back to the first\n # proposal, best-of-N falls through to the D10 tie-break — instead of committing a low-confidence\n # choice. 0.0 (default) = OFF (act on every pick, the historical behavior); raise toward ~0.5 to\n # make the world model defer when it isn't sure. Pairs with the foresight track record (§1) the\n # predictor is now primed with. Range 0.0-1.0.\n foresight_min_confidence: float = 0.0\n # PART IV Phase 2c — replace the world model's SELF-REPORTED confidence (measured Pearson≈0 with the\n # realized outcome, §21.12) with a CALIBRATED §12-verifier score. When on and a client is available,\n # after the K-idea ranker picks the predicted-best candidate the §12 verifier scores it (grounded +\n # repeated + criteria-decomposed: `foresight_criteria` — likely to improve the objective, and\n # sound/feasible) and THAT calibrated score becomes the confidence the `foresight_min_confidence` gate\n # and the telemetry use. Degrades to the self-reported confidence without a client or on any verifier\n # error (the telemetry records which source was used). ON by default in the product Settings (ce4a379),\n # EngineOptions off; a few extra LLM calls per acted-on proposal. See looplab/trust/verifier.py + search/foresight.py.\n # REVISIT (cost/quality, not yet resolved): a live classification run measured ~10 LLM calls & ~3.8 min/node\n # on deepseek-v4-flash with the full Part IV/V default bundle; the calibrated verifier is part of that per-node\n # tax. Kept ON deliberately — it raises decision quality (self-reported foresight confidence has ≈0 Pearson\n # with realized outcome), and lowering quality to save calls is the wrong trade. TODO: run a frozen quality/cost\n # A/B (verifier-on vs -off) to quantify the recall@objective gain per extra call before considering a default flip.\n foresight_verify: bool = True\n # Verifier sample count for `foresight_verify` (the §12 repeated-sampling expectation on a no-logprob\n # backend). 3 tames the single-shot variance §21.12 measured; 1 = single-shot (cheaper, noisier).\n # RESUME-critical: this value is persisted in config.snapshot.json, so a hard `le` schema bound would\n # make an old run started with a larger LOOPLAB_FORESIGHT_VERIFY_SAMPLES un-resumable (Settings(**snapshot)\n # would raise on `looplab resume` / finalize-recovery / serve replay). CLAMP into [1, MAX] via a validator\n # instead of rejecting — the runtime cap is preserved without breaking the snapshots/env-compat rule.\n foresight_verify_samples: int = 3\n\n @field_validator(\"foresight_verify_samples\")\n @classmethod\n def _clamp_foresight_verify_samples(cls, v: int) -> int:\n # Coerce a persisted/env value into [1, MAX] rather than reject (see field note above).\n try:\n v = int(v)\n except (TypeError, ValueError):\n return 3\n return max(1, min(v, MAX_FORESIGHT_VERIFY_SAMPLES))\n\n # T7 LLM response cache: serve an identical DETERMINISTIC (temperature 0) request from an\n # in-process content-addressed cache instead of re-hitting the model — cuts cost on\n # retry/panel/verify flows. NEVER caches sampling calls (temp>0: best-of-N / panel / novelty\n # re-propose must vary). Off by default (most role calls run at temperature>0 anyway).\n llm_cache: bool = False\n agent_cmd: str | None = None # override the agent's launcher (path / wrapper)\n # External-agent validation (ADR-7): wrap a CLI-agent Developer with a validator that\n # audits each output (no-op/syntax/crash/timeout), retries with feedback, and falls\n # back to the in-house LLM Developer. Off -> use the raw agent output unchecked.\n validate_agent: bool = True\n agent_max_retries: int = 1 # re-prompts of the agent on an invalid result\n # Patch-gated multi-file agent (ADR-7 Rule 3): run the CLI agent in a git worktree and\n # accept only edits whose paths match `agent_surface` (reject-not-strip out-of-surface\n # touches). Lets the agent create helper modules, not just solution.py. Degrades to\n # whole-file readback if git is unavailable.\n agent_patch_gate: bool = True\n agent_surface: list[str] = [\"*.py\"] # edit-surface allow-list (globs)\n # Trust policy for an agent-authored eval/metric adapter (RepoTask onboarding, Phase 3):\n # \"ratify_freeze\" waits for human approval, \"autonomous\" accepts the proposed spec without that\n # gate, and \"ratify_freeze_drift\" also enforces the configured independent drift cross-check.\n eval_trust_mode: str = \"ratify_freeze\"\n # RepoTask node seeding policy (run-wide fallback; a task/editable `seed_mode` overrides):\n # \"auto\" (default) copies git-tracked files when the editable is a git repo (so a tree bloated\n # with untracked artifacts isn't deep-copied per node), else a full copy; \"tracked\" forces\n # code-only; \"all\" forces a full recursive copy (legacy). Genesis authors per-task from the\n # user's words; this is the global default when unspecified.\n seed_mode: str = \"auto\"\n llm_model: str = \"qwen3:8b\"\n # === LLM / transport ==================================================================\n llm_base_url: str = \"http://localhost:11434/v1\" # Ollama OpenAI-compatible endpoint\n llm_temperature: float = 0.6\n # H3 per-role model presets (5090 recipe): optionally run the Researcher and Developer on\n # DIFFERENT models/endpoints (e.g. Developer=Qwen3-Coder-30B for code, a fast model for breadth).\n # Blank = use the shared llm_model/llm_base_url. Generalizes the role-backend swap to per-role.\n researcher_model: str | None = None\n developer_model: str | None = None\n researcher_base_url: str | None = None\n developer_base_url: str | None = None\n # Per-role sampling temperature (§4.1): the Researcher wants breadth (higher temp = more diverse\n # ideas), the Developer wants determinism (lower temp = fewer syntax slips), the Strategist a\n # steady hand. Each overrides the shared `llm_temperature` for that role only; None = use the\n # shared value (byte-identical to before). Flat + nullable, mirroring the per-role model fields.\n researcher_temperature: float | None = None\n developer_temperature: float | None = None\n strategist_temperature: float | None = None\n # Unified self-driving agent: one LLM identity that plays Researcher + Developer (+ Strategist)\n # across pipeline stages, choosing its own model/toolset per stage. ON by default — the agent\n # drives the loop (incl. crash triage: repair/abandon/reject_idea) and, via\n # `agent_drives_actions`, picks the next macro action within a pure legal-action gate. Set both\n # to False for the legacy byte-identical split-role behavior. (No-op unless backend=\"llm\".)\n unified_agent: bool = True\n agent_drives_actions: bool = True\n # Layer 3 Card queue ownership. False preserves the existing policy/unified-pilot action path.\n # True is pinned in run_started because changing the selector on resume would mix two search\n # treatments in one run. When both this and agent_drives_actions are true, Card selection wins.\n card_driven_selection: bool = False\n # Layer 5 bounded speculative Card buffer. Zero is a hard OFF switch and preserves the\n # historical alternating build/eval spine; positive values are pinned by run_started so a\n # resume cannot silently mix search treatments after a snapshot/default edit.\n speculation_depth: int = Field(default=0, ge=0, le=64)\n # Local evidence receipt produced by ``looplab speculation-gate``. This is deliberately absent\n # from the curated Settings UI: positive-depth execution is a calibrated rollout decision, not a\n # casual tuning knob. The Engine validates the receipt against the current scorer,\n # implementation, GPU inventory and raw paired-run evidence before it enables speculation.\n speculation_gate_receipt: str | None = Field(default=None, max_length=4096)\n # Per-stage model/endpoint overrides for the unified agent. Recognized keys (see\n # tasks.build_unified_agent): `propose` (researcher), `implement`/`repair` (developer),\n # `strategy`, `pilot`. Unlisted keys are ignored. Empty = the per-role researcher_*/developer_*\n # models, then the shared llm_model. Explicit map wins. No-op unless `unified_agent` is true.\n agent_stage_models: dict = {}\n agent_stage_base_urls: dict = {}\n llm_parser: str = \"tool_call\"\n # Reasoning/thinking toggle sent IN the request (providers differ — see `llm.reasoning_body`):\n # Qwen3 on vLLM/SGLang -> chat_template_kwargs.enable_thinking (bool)\n # OpenAI/Ollama-v1/DeepSeek -> reasoning_effort (low|medium|high|none)\n # `llm_reasoning`: \"\" = send nothing (server default — current behavior); off = actively disable;\n # on = enable at default depth; low|medium|high = enable at that effort.\n # Defaults to \"high\" so the agent reasons before proposing/repairing/triaging (better code, fewer\n # crashes); set \"\" to send nothing (server default) or \"off\" to disable. The model's chain-of-thought\n # is CAPTURED either way (reasoning_content field or inline <think>). NOTE: to get a SEPARATE\n # reasoning_content field from SGLang/vLLM, the server also needs `--reasoning-parser qwen3`.\n llm_reasoning: str = \"high\"\n llm_reasoning_style: str = \"auto\" # auto | qwen | effort | none — how to shape the request param\n llm_reasoning_extra: dict = {} # raw fields merged into the body (escape hatch, e.g. Anthropic thinking)\n # H1: drive structured calls with the endpoint's constrained decoding (vLLM/SGLang guided_json +\n # response_format json_schema) so weak models can't emit invalid JSON. Off for Ollama (default).\n llm_guided_json: bool = False\n # Stream every LLM request (SSE) and reassemble it, so the request `timeout` is an INTER-TOKEN\n # idle timeout rather than a whole-request deadline: a long-but-alive generation is never cut off\n # (tokens keep resetting the timer), while a genuinely STALLED endpoint (no token for `timeout` s)\n # is detected and retried on a fresh connection — the fix for silent multi-minute hangs. Set False\n # to use one blocking read (old per-op timeout semantics) if an endpoint streams badly.\n llm_stream: bool = True\n # LLM IDLE timeout (seconds). Stream mode: the INTER-TOKEN stall limit — a steady generation is\n # never cut off, only a silent endpoint. Non-stream: bounds the whole read. Raise for endpoints\n # with a long prefill on huge prompts; lower to fail fast on a flaky shared endpoint.\n llm_timeout: float = Field(default=180.0, gt=0)\n # First-byte (response-headers) window for STREAM attempts, seconds: an SSE response sends its\n # headers on admission, so no-headers ≈ a black-holed request — fail over to a fresh connection\n # fast instead of waiting the full idle timeout. Clamped to llm_timeout. Non-stream attempts are\n # NOT bounded by this (their headers arrive only after the whole generation). The default mirrors\n # `DEFAULT_HEADER_TIMEOUT_S` in looplab/core/llm.py is the single source (imported above).\n llm_header_timeout: float = Field(default=DEFAULT_HEADER_TIMEOUT_S, gt=0)\n # Honour HTTP(S)_PROXY / NO_PROXY / SSL_CERT_FILE env vars for LLM calls. Default False: the\n # transport connects DIRECTLY (many internal/OLAP endpoints are reachable only by bypassing an\n # ambient proxy — a picked-up proxy yields \"connection refused\"). Set true when the endpoint is\n # reachable ONLY via a corporate proxy or needs a custom CA bundle from the environment.\n llm_trust_env: bool = False\n # H4/C2: compact the growing agentic tool-call history once it exceeds this many chars (auto_summary\n # LLM-summarizes the stale middle; else middle-truncate). Sized to the model's real context window so\n # files read earlier STAY in context instead of being compacted away and re-read: ~1,000,000 chars ≈\n # 250k tokens (≈4 chars/token), matching the configured deepseek-v4-flash (≥200k tokens confirmed).\n # The old 120k-char (~30k-token) default was ~8× too aggressive and drove a read-thrash loop. 0 = off.\n context_budget_chars: int = 1_000_000\n # M5: the Researcher's always-on experiments digest budget (chars). 0 = AUTO — scales with the\n # run size (60 chars/node, bounded [1200, 6000]) instead of one flat cap for an 8-node toy run\n # and a 200-node benchmark run alike. Set a positive value to pin it.\n digest_char_cap: int = Field(default=0, ge=0)\n # D11 compression model slot (open_deep_research's four-slot pattern): a dedicated CHEAP model\n # for agent-history summarization (C2 auto-summary), so compression doesn't pay the main\n # model's price. Blank = use each loop's own model (legacy). `compressor_base_url` blank =\n # the shared llm_base_url.\n compressor_model: str | None = None\n compressor_base_url: str | None = None\n # === Agentic tool-loop ================================================================\n # Agentic tool-loop limits — apply to EVERY tool-using agent (the LLM Researcher, the unified\n # agent's pilot + crash-triage, Deep-Research, the run-chat Boss, the genesis repo-scout, and the\n # cross-run report synthesizer). The loop lets the model call read-only tools across turns before\n # emitting its structured result. Both default to UNLIMITED so the agent takes as many turns / as\n # long as it needs — it is never cut off mid-reasoning; set a positive value here (or per-run, or\n # in the UI settings) to bound latency/cost. These used to be hardcoded per call site (e.g. the\n # boss at max_turns=3 / 45s), which silently dropped a slow reasoning model to a no-op reply.\n agent_max_turns: int = 0 # max tool turns before the emit is forced (0 = unlimited)\n agent_time_budget_s: float = 0.0 # wall-clock ceiling across the loop's turns (0 = no cap)\n # G · Soft convergence SAFETY NET (not a research cap). Two thresholds on the agent's tool-turn count:\n # at `agent_emit_after` the loop NUDGES the agent once (\"you've investigated enough — emit now\"); at\n # `agent_emit_force` it FORCES the emit from what was gathered. Set GENEROUSLY — a focused researcher\n # may legitimately read many files/experiments before it can propose a grounded idea; these only catch\n # a genuine runaway that keeps issuing DIFFERENT calls forever (which stuck-detection, keyed on\n # repeats, misses). NB: the old 499-read runaway (node 63) was 79% REDUNDANT re-reads caused by a\n # broken read_file — fixing read_file (pagination) is the real cure; this is just insurance. 0 = off.\n agent_emit_after: int = 300\n agent_emit_force: int = 500\n # B1 · No-progress / stuck detection — the safety net that makes \"unlimited turns\" safe. The\n # turn/time ceilings above are only BACKSTOPS; this is what actually stops a runaway loop, on the\n # cheapest signal: when the model repeats the SAME tool call (or ping-pongs between two, or keeps\n # hitting the SAME error) with no progress, the loop forces the final emit and finishes. ON by\n # default; reading DIFFERENT files or running ONE long command never trips it. (OpenHands-style.)\n agent_stuck_detection: bool = True\n agent_stuck_repeat: int = 4 # identical calls in a row that count as \"stuck\" (>=2)\n agent_stuck_alternate: int = 4 # ping-pong cycles between two calls that count as \"stuck\" (>=2)\n # C1 · Self-plan (TodoWrite-style): expose an `update_plan` tool so a long-running agent keeps its\n # OWN working TODO and re-surfaces it every `agent_plan_reinject_every` turns — keeps the goal in\n # view across a long loop so the agent \"writes its own context\" (Claude Code style). ON by default.\n agent_self_plan: bool = True\n agent_plan_reinject_every: int = 5\n # C2 · Auto-summary (ON by default): when the tool-loop history grows long, LLM-summarize the\n # stale middle instead of only middle-truncating it. The trigger is `context_budget_chars` if set,\n # else a built-in high-water mark (context_budget.DEFAULT_SUMMARY_CHARS ≈ 120k chars) so short\n # loops are untouched. Falls back to truncation on any summarizer error; one extra model call per\n # over-budget turn.\n agent_auto_summary: bool = True\n # C4 · Repo-developer plan decomposition. A big feature attempted in ONE developer session made a\n # non-converging reasoning model loop indefinitely — it kept writing + exploring without ever\n # emitting `done` (10k+ LLM calls / hours; agentic stuck-detection can't catch VARIED exploration,\n # only literal repeats). Fix: the developer first proposes an ordered plan of ATOMIC steps; a\n # multi-step plan is executed step-by-step, each in a FRESH bounded session with only that step's\n # scope + the files accumulated so far, syntax-validated per write. Every session stays\n # small-context and convergent. ON by default for repo tasks (a 1-step plan == the old single pass).\n developer_plan_decompose: bool = True\n developer_plan_min_steps: int = 2 # a proposed plan with >= this many steps runs step-by-step\n developer_plan_max_steps: int = 8 # cap on plan length (a runaway planner can't spawn 100 steps)\n # Hard per-session backstop for the repo developer — the ONE write-heavy agent that can run away\n # even with stuck-detection on (varied writes/reads never trip the repeat/ping-pong signal). Bounds\n # the plan phase, EACH step, and the single-session fallback. Unlike the global agent_* limits\n # (0 = unlimited, meant for read-only agents), the developer always gets a FINITE ceiling so a\n # model that never emits `done` fails cleanly with the code it wrote so far, instead of looping.\n developer_session_max_turns: int = 500\n developer_session_time_budget_s: float = 1200.0 # 20 min wall-clock per developer session\n # Phase-handoff summaries. Each LLM phase in a node build (Researcher·propose → Developer·stages →\n # plan → implement) ends with ONE extra LLM call that distills its transcript — the repo structure\n # it mapped, files/data confirmed, decisions made — into a brief injected into the NEXT phase (even\n # across the role boundary). The next phase TRUSTS that instead of re-reading the same files, which\n # cuts the biggest source of tool-call thrash (stages/plan/implement each re-exploring the repo).\n # One summary call per phase in exchange for many fewer read calls downstream. ON by default.\n phase_handoff_summary: bool = True\n # Observability (ADR-17): capture each LLM call's full prompt + completion into the active\n # span (spans.jsonl) so the UI can show exactly what the model read and wrote per node.\n # Diagnostics only — `replay.fold` never reads spans. Default on for local single-user; set\n # LOOPLAB_TRACE_LLM_IO=0 to suppress (e.g. to keep spans.jsonl small or avoid storing prompts).\n trace_llm_io: bool = True\n # Run-introspection tools (ON by default): make the LLM Researcher (and DeepResearcher) a\n # tool-using agent that can read its OWN experiments (list/read/find-analogous/themes/code) and\n # the task data (schema/profile/asset) mid-loop, instead of seeing only best+parent. Advisory —\n # never changes best-selection. Off = the legacy single-shot Researcher (richer digest still added).\n researcher_tools: bool = True\n # Cross-run introspection: give the agentic Researcher / pilot read-only tools to look at\n # SIBLING runs (same task_id, same run-root) — list them, read an experiment / its code, and\n # find analogous configs across runs — so a run can build on what neighbouring runs already\n # learned instead of rediscovering it. Advisory; never changes best-selection. Needs the run's\n # own dir wired through (no-op for unit-built roles). Off = the legacy single-run view only.\n cross_run_tools: bool = True\n # Read-only access to EVERY run on this machine ACROSS ALL TASKS (not just same-task siblings):\n # the Developer/Researcher get list_all_runs + read_run_code + read_run_experiment so they can read\n # the code + result of ANY past experiment anywhere and reuse an approach. Broader than\n # `cross_run_tools` (same-task only); the agent decides when a foreign run is relevant. Advisory;\n # never changes best-selection. Needs the run's own dir wired through (no-op for unit-built roles).\n all_runs_tools: bool = True\n # PART V §22 — read-only CROSS-RUN KNOWLEDGE tool for the reasoning roles (Researcher, Strategist,\n # deep-research, and a Developer-scoped variant). Adds `cross_run_prior_attempts` / `cross_run_claims`\n # / `cross_run_atlas` over the §21.20 read-models (concept overview + claim assessments + atlas), read\n # from `memory_dir` (lessons.jsonl + concept_capsules.jsonl). Read-only is a storage-authority\n # boundary, not a behavioral no-op: retrieved content can steer later proposals, and a tool-using\n # reasoning loop can consume additional model calls. The tools never mutate cross-run truth (facts\n # are engine-written, verdicts operator-ratified, §22.4) or directly select a winner. ON by explicit\n # experimental product choice, EngineOptions off; stores only exist once Part-IV features have run.\n cross_run_read_tools: bool = True\n # Agentic retrieval (ADR-16): if set, the LLM Researcher gets grep/kb_search/read\n # tools over this directory of markdown notes and chooses when to use them.\n knowledge_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"knowledge\"))\n # T4 real embeddings: model id for an OpenAI-compatible `/embeddings` endpoint used for\n # kb_search / case retrieval. Blank (default) = the dependency-free lexical `hash_embed` (today's\n # behavior). Set e.g. \"nomic-embed-text\" (Ollama) or \"text-embedding-3-small\" for SEMANTIC\n # retrieval; a misconfigured/offline endpoint degrades back to hash_embed at call time (never\n # crashes a run). `embed_base_url` blank = reuse `llm_base_url`; the shared `llm_api_key` is used.\n embed_model: str | None = None\n embed_base_url: str | None = None\n # Harmonic memory (Memora, ICML'26 — idea import). When ON, cross-run cases and knowledge notes are\n # indexed by a short ABSTRACTION + cue ANCHORS instead of their raw text, near-duplicate memories are\n # CONSOLIDATED into one entry under a matching abstraction, and `kb_search`/case retrieval EXPAND\n # through the top hits' anchors to surface related-but-not-similar memories. ON by default. Set\n # `memora=false` to restore the pre-Memora raw-text index. Never a source of truth — abstractions\n # live only in the derived, rebuildable retrieval index (never the event log or canonical cases.jsonl).\n memora: bool = True\n # Write abstractions with the wired chat model (richer than the deterministic lexical fallback). ON\n # by default; results are CACHED by content hash (see `memora_cache`) so a re-built index doesn't\n # re-call the model on unchanged notes/cases, and any endpoint failure degrades to lexical at call\n # time — so an offline box just gets lexical abstractions, never a crash. Set false to force lexical.\n memora_llm: bool = True\n # === Cross-run memory / Memora ========================================================\n memora_anchors: int = 6 # max cue anchors kept per memory\n # Cosine similarity at/above which a new case is CONSOLIDATED into an existing one (same evolving\n # topic) rather than stored as a separate entry. Only used when `memora` is on.\n memora_consolidate_threshold: float = 0.86\n # Where the LLM-abstraction cache lives (JSON). Blank (default) derives it from `memory_dir`\n # (`<memory_dir>/memora_cache.json`) or else `knowledge_dir` (`<knowledge_dir>/.memora_cache.json`);\n # with neither set the cache is in-memory only (per process). No-op unless `memora_llm` is on.\n memora_cache: str | None = None\n # E3 literature-grounded ideation (network-OPTIONAL): give the agentic Researcher an arXiv search\n # tool to ground ideas in real techniques. Off by default (egress is unreliable on some boxes);\n # fails gracefully if the network is blocked.\n literature_search: bool = False\n # Deep-Research stage (network-OPTIONAL): give the DeepResearcher a general web search/fetch\n # tool (DuckDuckGo) on top of arXiv to read across results + the web before steering the next\n # batch. Off by default (egress is unreliable on some boxes); fails gracefully when blocked.\n web_search: bool = False\n # Cadence for the Deep-Research stage: run it automatically every N created nodes. Zero disables\n # only the cadence; manual `deep_research` and Strategist `request_research` triggers still fire.\n # Default 3: deep research analyzes the accumulating results and steers the next batch (its\n # directions become hints + open hypotheses); paired with `concurrent_research` it overlaps the\n # think with the GPU-bound eval, so the cadence costs LLM tokens but no wall-clock on the search.\n deep_research_every: int = 3\n # Overlap a DUE deep-research \"think\" with the GPU-bound eval instead of running it in its own\n # serial step (the agent is otherwise idle while a node trains). research() computes from a state\n # snapshot; CODEX AGENT: the joined background task records only allowlisted, order-tolerant\n # research/hint/hypothesis events as soon as the memo finishes. ON by default: the LLM is typically\n # remote (no GPU contention with eval), so overlapping hides latency behind long training.\n concurrent_research: bool = True\n # Concurrent-research REPEAT — don't idle a multi-DAY eval. The overlapped \"think\" fires ONCE per\n # window by default; ON here, it RE-RUNS on an adaptive time cadence for the WHOLE eval window\n # (a two-day training must not leave the reasoning agents idle after one memo). Self-paced: it\n # only records a memo whose CONTENT is new (identical re-runs are skipped, so the log/board don't\n # bloat) and backs off geometrically as the analysis converges, capped so it always re-checks.\n # The appended records never rewrite the current metric champion, but their hints/open hypotheses\n # deliberately steer later proposals and replay reconstructs that advice. The library default is\n # one-shot (== today, byte-identical); this product default turns the repetition on.\n concurrent_research_repeat: bool = True\n # Base cadence (seconds) between repeated concurrent-research passes. Research is EXPENSIVE\n # (multi-turn LLM + web/arXiv), so this is a FLOOR, not a ceiling: the effective cadence is\n # max(this, ~5% of the per-experiment time budget) — a two-day eval is re-researched about hourly,\n # a short eval not at all (the first tick outlasts it). Only matters when the repeat is on.\n concurrent_research_interval_s: float = Field(default=1800.0, gt=0)\n # Per-eval-window backstop on repeated-research LLM calls (0 = cadence-only, no cap). The adaptive\n # cadence + convergence backoff are the primary budget control; this bounds a pathological\n # never-converging window. Past it the loop stops calling the LLM (the health monitor still runs).\n concurrent_research_max_calls: int = Field(default=40, ge=0)\n # Concurrent hypothesis-board CONSOLIDATION during the eval window. Repeated research keeps ADDING\n # near-duplicate directions to the open board (each memo registers up to 5 as open hypotheses), so a\n # long eval's board bloats before the between-nodes merge runs. This dedups/merges it on the SAME\n # background loop so the board stays clean for the next proposal. In Card-driven mode the same merge\n # changes ownership/readiness, so it is deferred to the joined main-task cadence instead. Needs the\n # reflect client + `track_hypotheses`.\n # Only active while `concurrent_research` runs the overlap loop. Library default off (== today).\n concurrent_consolidate: bool = True\n # Cadence for the agent-authored run report: regenerate the conclusion-first narrative every N\n # created nodes (0 = off; it still regenerates on a manual `report_refresh` from the UI). The\n # deterministic report always renders from the node set regardless of this knob.\n report_every: int = 3\n # Agent Skills (I18, ADR-9): dir of SKILL.md the Researcher can list/load as tools.\n skills_dir: str | None = None\n # Prompt store (I18, ADR-8): dir of editable, hot-reloaded role prompt .md files.\n prompt_dir: str | None = None\n # API key as a reference, never serialized as a value (ADR-11). Local servers\n # ignore it; default to a placeholder.\n llm_api_key: SecretStr | None = None\n\n @model_validator(mode=\"before\")\n @classmethod\n def _apply_profile(cls, data):\n \"\"\"Expand `profile` into a bundle of setting defaults BEFORE validation.\n\n pydantic-settings hands this validator the fully-merged input from ALL explicit sources\n (init kwargs + LOOPLAB_* env + .env), with fields the user left unset simply ABSENT (they\n fall to field defaults afterward). So a profile value is applied only when its key is not\n already present — i.e. any explicit file/CLI/env setting wins over the profile, and the\n profile wins over the bare field default. This makes `thorough` a true convenience preset,\n never an override of what the user asked for.\"\"\"\n if not isinstance(data, dict):\n return data\n prof = data.get(\"profile\", \"default\")\n # env may deliver the key as \"profile\" (env_prefix stripped) — normalize to a str\n prof = str(prof).strip().lower() if prof is not None else \"default\"\n overrides = PROFILES.get(prof)\n if overrides is None:\n raise ValueError(f\"unknown profile {prof!r}; known: {sorted(PROFILES)}\")\n for key, val in overrides.items():\n if key not in data: # explicit source (file/CLI/env) always wins\n data[key] = val\n return data\n\n @model_validator(mode=\"after\")\n def _check_trust_gate(self):\n if self.trust_gate not in (\"audit\", \"gate\", \"block\"):\n raise ValueError(\n f\"trust_gate must be audit|gate|block, got {self.trust_gate!r}\")\n if self.merge_mode not in (\"auto\", \"mean\", \"ensemble\"):\n raise ValueError(\n f\"merge_mode must be auto|mean|ensemble, got {self.merge_mode!r}\")\n # novelty_mode drives the dedup/novelty gate; an out-of-set value (e.g. a mis-cased\n # LOOPLAB_NOVELTY_MODE=LLM, or \"on\") used to fall through _apply_novelty_gate silently as a\n # NO-OP — turning the gate off with no diagnostic. Fail loudly at config time like trust_gate/\n # merge_mode do. (Other enum-ish string fields — seed_mode/eval_trust_mode/strategist_backend —\n # share the pattern; left unvalidated here only because their full value sets are less settled.)\n if self.novelty_mode not in (\"off\", \"algo\", \"llm\"):\n raise ValueError(\n f\"novelty_mode must be off|algo|llm, got {self.novelty_mode!r}\")\n # The remaining enum-ish string fields (arch-review §5 P3): a typo used to be accepted at\n # construction and only fail-safe/later-loud downstream (e.g. a mis-cased strategist_backend\n # silently ran the default). Fail loudly here like the fields above.\n if self.strategist_backend not in (\"off\", \"rule\", \"llm\", \"agent\"):\n raise ValueError(\n f\"strategist_backend must be off|rule|llm|agent, got {self.strategist_backend!r}\")\n if self.eval_trust_mode not in (\"ratify_freeze\", \"autonomous\", \"ratify_freeze_drift\"):\n raise ValueError(\n \"eval_trust_mode must be ratify_freeze|autonomous|ratify_freeze_drift, \"\n f\"got {self.eval_trust_mode!r}\")\n if self.seed_mode not in (\"auto\", \"tracked\", \"all\"):\n raise ValueError(f\"seed_mode must be auto|tracked|all, got {self.seed_mode!r}\")\n return self\n\n def masked_snapshot(self) -> dict:\n # Snapshots are a JSON contract (they are written verbatim to config.snapshot.json), not a\n # Python-object dump. In particular tuple-valued schema fields such as\n # ``inline_repair_reasons`` must become JSON arrays before callers hash the runtime envelope;\n # otherwise the strict speculation-scope canonicalizer sees a different/non-JSON preimage\n # from the one that ``json.dumps`` eventually persists.\n d = self.model_dump(mode=\"json\")\n if self.llm_api_key is not None:\n d[\"llm_api_key\"] = \"***\"\n else:\n d[\"llm_api_key\"] = None\n return d\n\n\n# Pre-versioned snapshots are full Settings dumps, so absence is a reliable per-field version marker.\n# Keep their historical effective behavior when newer product defaults become active: re-entry must not\n# silently add paid calls, interventions, concurrency or a different selection policy to an old run.\nLEGACY_CONFIG_SNAPSHOT_DEFAULTS: dict[str, object] = {\n \"parallel_build\": 1,\n \"eval_parallel\": None,\n \"llm_parallel\": None,\n \"train_monitor\": False,\n \"train_monitor_interval_s\": 600.0,\n \"train_monitor_kill\": False,\n \"train_monitor_kill_confidence\": 0.8,\n \"asha_live\": False,\n \"asha_live_kill\": False,\n \"asha_live_quantile\": 0.5,\n \"asha_live_min_siblings\": 3,\n # Before this operator ceiling existed, the sandbox's 24-hour process limit was authoritative.\n \"max_eval_timeout\": 24 * 3600.0,\n \"watchdog_reflection\": False,\n \"card_driven_selection\": False,\n \"speculation_depth\": 0,\n \"speculation_gate_receipt\": None,\n \"concurrent_research_repeat\": False,\n \"concurrent_research_interval_s\": 1800.0,\n # Zero means unlimited; 40 was the first bounded default, and is latent while repeat is disabled.\n \"concurrent_research_max_calls\": 40,\n \"concurrent_consolidate\": False,\n}\n\n\ndef migrate_config_snapshot(data: dict) -> dict:\n \"\"\"Return an effective copy of one legacy/current snapshot without rewriting evidence bytes.\"\"\"\n if not isinstance(data, dict):\n raise TypeError(\"config snapshot must be a JSON object\")\n migrated = dict(data)\n for key, value in LEGACY_CONFIG_SNAPSHOT_DEFAULTS.items():\n migrated.setdefault(key, value)\n return migrated\n\n\ndef settings_from_snapshot(data: dict) -> Settings:\n \"\"\"Build re-entry Settings from a masked snapshot under its historical missing-field contract.\"\"\"\n migrated = migrate_config_snapshot(data)\n migrated.pop(\"llm_api_key\", None)\n return Settings(**migrated)\n"}, "f019358c731b0e0b31a41bfe16342738e96e7b41": {"t": 1785768782.8494425, "n": 95907, "h": "db766c0ece22923e", "c": "\"\"\"Engine config: the schema (what settings exist). (I0, ADR-11)\n\nOne pydantic-settings class declaring every engine knob, its default, and its docs. The loader —\nhow a run file / CLI / env becomes a `Settings` — is `looplab.core.appconfig`, which also owns\nthe config-precedence order. A resolved, secret-masked snapshot is written next to each run for\nreproducibility. (No real secrets in P0, but the masking discipline is in place.)\n\"\"\"\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nfrom pydantic import Field, SecretStr, field_validator, model_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n# Single source of the LLM first-byte (response-headers) default — see core/llm.py.\nfrom looplab.core.llm import DEFAULT_HEADER_TIMEOUT_S\n\n# Default home for cross-run memory + the knowledge base (shared across every run). Both are ON by\n# default now (a user asked for it) so a fresh install accumulates + consults knowledge with no setup;\n# set the env var to \"\" to disable, or to a path to relocate. `~/.looplab/` keeps them out of any one\n# project + shared across projects.\n_LL_HOME = Path.home() / \".looplab\"\n\n# Autonomous roles that may be granted per-setting write access (see Settings.agent_control).\n# strategist = A7 meta-controller (run-wide); boss = run-chat operator-proxy (run-wide);\n# researcher = per-experiment proposer (per-node sizing, e.g. eval timeout for a heavy model).\nAGENT_ROLES = (\"strategist\", \"boss\", \"researcher\")\n\n# A foresight score is computed for every acted-on proposal. Keep this lower than the generic\n# verifier's emergency ceiling so a typo cannot multiply paid synchronous calls across a run.\nMAX_FORESIGHT_VERIFY_SAMPLES = 8\n\n\n# Layer-2 parallelism renamed two public settings without removing the durable legacy fields. Keep\n# the equivalence in one place: config-source precedence, governance migration, and live strategy\n# application must all agree which names describe the same authority/value.\nPARALLELISM_ALIASES: dict[str, str] = {\n \"max_parallel\": \"eval_parallel\",\n \"parallel_build\": \"llm_parallel\",\n}\n\n\ndef parallelism_aliases(setting: str) -> tuple[str, ...]:\n \"\"\"Return every spelling in ``setting``'s alias family, canonical name first.\"\"\"\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if setting in (legacy, canonical):\n return canonical, legacy\n return (setting,)\n\n\ndef canonicalize_parallelism_source(values: dict) -> dict:\n \"\"\"Promote legacy parallel keys *within one config-precedence source*.\n\n Applying this only after file/CLI/env were flattened would let a lower-priority canonical env\n value shadow a higher-priority legacy file value (or a file canonical value shadow a CLI legacy\n override), so callers with sub-layers normalize each layer independently.\n \"\"\"\n if not isinstance(values, dict):\n return values\n out = dict(values)\n for legacy, canonical in PARALLELISM_ALIASES.items():\n # CODEX AGENT: None is the durable legacy mode for llm_parallel, not a missing value. A normal\n # snapshot contains llm_parallel:null plus parallel_build:1; reloading it here promotes null to\n # 1 and unexpectedly enables the global one-call broker. Promote aliases only when the\n # canonical key is absent, and pin snapshot round-trip parity.\n if canonical not in out and out.get(legacy) is not None:\n # CODEX AGENT: canonicalize inside each precedence layer, never across the flattened\n # result. The alias migration must not invert CLI > file > env precedence.\n out[canonical] = out[legacy]\n return out\n\n# Settings whose semantics are committed by ``run_started`` and restored from the folded event log\n# on every re-entry. Changing one only in ``config.snapshot.json`` would mix incompatible evidence\n# (a different holdout split) or make the live producer disagree with replay's selection policy.\n# The HTTP config editor and the engine both consume this one contract; keep deliberately mutable\n# controls such as ``trust_gate`` out (that field has its own durable change event).\nRUN_START_PINNED_FIELDS = frozenset({\n \"card_driven_selection\",\n \"speculation_depth\",\n \"holdout_fraction\",\n \"holdout_select\",\n \"select_verifier\",\n \"select_verifier_samples\",\n \"verifier_ci_tie\",\n})\n\n\ndef run_start_pinned_settings(state) -> dict:\n \"\"\"Return the effective Settings names committed by a folded run-start record.\n\n Pre-pin legacy logs have no recorded holdout fraction, so their snapshot remains authoritative\n for both holdout knobs. Verifier fields, however, have always been re-pinned from the legacy-safe\n fold defaults whenever a valid ``run_started`` identity exists.\n \"\"\"\n if not getattr(state, \"run_id\", \"\"):\n return {}\n values = {\n # Layer 3 changes which queue owns macro-action selection. Once a run starts, replay and\n # resume must keep the same owner even if the snapshot or ambient default changes later.\n \"card_driven_selection\": bool(getattr(state, \"card_driven_selection\", False)),\n # Layer 5 overlaps Card production with evaluation only when this pinned treatment is\n # positive. Old logs omit it and therefore retain the exact serial build/eval spine.\n \"speculation_depth\": int(getattr(state, \"speculation_depth\", 0)),\n \"select_verifier\": bool(getattr(state, \"select_verifier_tiebreak\", False)),\n \"select_verifier_samples\": int(getattr(state, \"select_verifier_samples\", 3)),\n \"verifier_ci_tie\": bool(getattr(state, \"verifier_ci_tie\", False)),\n }\n holdout_fraction = getattr(state, \"holdout_fraction\", None)\n if holdout_fraction is not None:\n values.update({\n \"holdout_fraction\": holdout_fraction,\n \"holdout_select\": bool(getattr(state, \"holdout_select\", False)),\n })\n if not values.keys() <= RUN_START_PINNED_FIELDS:\n raise RuntimeError(\"folded run-start pinned settings contract drifted\")\n return values\n\n# --- Run profiles (config-first presets) -------------------------------------------------------\n# A `profile` is a NAMED BUNDLE of setting defaults — nothing more. It only fills fields the user\n# did NOT set explicitly (via file / --set / LOOPLAB_* env / init-kwargs), so any explicit knob\n# always wins (see `_apply_profile`). Most engine intelligence lives behind individual flags. The\n# PART IV/V cross-run + concept-graph machinery (concept_pivot, graded_novelty, cross_run_concepts /\n# _advisory / _structured_claims / _curation / _read_tools, fingerprint_universal, foresight_verify)\n# now ships ON in the product `Settings` default. That is an explicit experimental product choice,\n# not a claim that a frozen A/B or workload-wide cost/quality gate has cleared: the bundle can steer\n# prompts and candidate generation, change graded-novelty admission / pre-execution choices, and add\n# paid LLM work when its client and memory prerequisites are present. The bare-library `EngineOptions`\n# default stays lean (see tests/test_options_divergence.py — product side is the aggressive one) so a\n# toy `Engine(...)` in a test doesn't fire cross-run LLM work unasked. The trust/confirm/ablate QUALITY\n# bundle still ships OFF and is turned on by `thorough`. Every value here is reachable by hand — the\n# profile is a convenience, never a hidden mode.\n# CODEX AGENT: keep this rationale behavioral and evidence-based; do not relabel read-only storage\n# access or proposal-only governance as a no-op for agent behavior, cost, or proposal admission.\n#\n# `thorough` deliberately touches only QUALITY/TRUST machinery, not spend: it does NOT raise\n# max_nodes / eval_parallel (those are cost knobs the user owns). It enables multi-seed confirmation\n# (demote seed-lucky leaders), the reward-hack / leakage / critic monitors AND flips them from\n# advisory to *gating* (`trust_gate=\"gate\"` — a\n# flagged win can no longer be selected as best), ablation-driven refinement, and the prompt cues\n# (complexity / budget / failure-reflection / watchdog-reflection). Agentic novelty and the experimental\n# Part IV/V bundle are already product defaults, not profile changes. Effect is large; marginal cost is a\n# few extra evals.\nPROFILES: dict[str, dict] = {\n \"default\": {}, # explicit no-op alias for \"ship-as-is\" (== omitting profile)\n \"fast\": {}, # today's lean defaults, named for symmetry with `thorough`\n \"thorough\": {\n \"confirm_top_k\": 3,\n \"confirm_seeds\": 3,\n # novelty_gate intentionally NOT enabled here: duplicate-detection is the agentic Researcher's\n # job (it reads past experiments + find_analogous_across_runs and DECIDES), not an algorithmic\n # param-distance / embedding auto-reject that mis-fired (it nudged a good fresh idea onto a\n # twice-dead lineage — live node 61). Search stays as tools; the LLM judges novelty.\n \"operator_bandit\": True, # P4: adaptive operator mix over folded yields (GreedyTree)\n \"reward_hack_detect\": True,\n \"code_leakage_detect\": True,\n \"critic_check\": True,\n \"trust_gate\": \"gate\", # detectors stop gating a flagged node from WINNING (not just audit)\n \"ablate_every\": 3,\n \"complexity_cue\": True,\n \"budget_aware\": True,\n \"failure_reflection\": True,\n \"watchdog_reflection\": True, # feed recent live-watchdog (train-monitor/ASHA) flags to proposals\n \"reflection_priors\": True, # no-op unless memory_dir is set (cross-run priors)\n },\n}\n\n\n# The shipped agent-governance matrix (who may change which setting at runtime). Defined as a module\n# constant so the Engine's direct-construction default matches this shipped default exactly (the\n# EngineOptions \"Engine() == shipped defaults\" invariant) instead of diverging to an all-locked map.\nDEFAULT_AGENT_CONTROL: dict[str, list[str]] = {\n \"timeout\": [\"researcher\", \"strategist\"],\n # Layer-2 canonical names are the presented/default governance surface. `_agent_may` falls back\n # to an old snapshot's legacy grant only when its canonical counterpart is absent, so removing\n # duplicate defaults preserves resume authority while an explicit canonical revocation still wins.\n \"eval_parallel\": [\"strategist\"],\n \"llm_parallel\": [\"strategist\"],\n # Strategy-only pseudo-setting: the closed per-lane allocation inside the canonical LLM total.\n # It is not a launch Settings field; operators pin it through `set_strategy` and the Strategist\n # may reallocate it only while this grant is present.\n \"llm_lane_limits\": [\"strategist\"],\n \"max_nodes\": [\"strategist\", \"boss\"],\n \"max_eval_seconds\": [\"strategist\", \"boss\"],\n \"policy\": [\"strategist\"],\n # The policy NAME and its PARAMS are gated independently in `_apply_strategy`; grant both by default\n # so the Strategist that may switch to (say) MCTS may also apply the `c`/`eta` it decided — else its\n # tuned params are silently dropped and the recorded `active_strategy` diverges from the live engine.\n \"policy_params\": [\"strategist\"],\n \"n_seeds\": [\"strategist\"],\n \"ablate_every\": [\"strategist\"],\n \"merge_mode\": [\"strategist\"],\n \"complexity_cue\": [\"strategist\"],\n \"ablate_code_blocks\": [\"strategist\"],\n \"prefer_sweep\": [\"strategist\"],\n \"novelty_stance\": [\"strategist\"],\n \"developer\": [\"strategist\"],\n \"fidelity\": [\"strategist\", \"researcher\"],\n}\n\n\ndef default_agent_control() -> dict[str, list[str]]:\n \"\"\"A FRESH deep-ish copy of the shipped governance matrix (each role list copied so a caller can\n mutate its own map without touching the module constant). The SINGLE constructor of the default,\n called by both `Settings.agent_control`'s default_factory AND the Engine's direct-construction\n default — so \"Engine() == shipped defaults\" can't drift if the copy depth ever needs to change\n (e.g. nested values requiring copy.deepcopy), instead of two verbatim copy expressions.\"\"\"\n return {k: list(v) for k, v in DEFAULT_AGENT_CONTROL.items()}\n\n\nclass Settings(BaseSettings):\n \"\"\"The engine settings schema (every knob a run accepts).\n\n Timeout family — six distinct knobs, each owned by a different subsystem:\n - `timeout`: per-eval wall-clock budget for ONE experiment's evaluation (engine/eval).\n - `max_eval_timeout`: hard ceiling for a governed per-node Researcher timeout override.\n - `llm_timeout`: LLM request idle timeout — inter-token stall limit in stream mode\n (core.llm OpenAICompatibleClient).\n - `llm_header_timeout`: LLM first-byte (response-headers) window for stream attempts (core.llm).\n - `agent_time_budget_s`: wall-clock ceiling across a tool-using agent loop's turns (agents).\n - `dep_install_timeout`: per-package budget for auto-installing a missing dep (env self-prep).\n\n Config-source precedence is owned by `looplab.core.appconfig` (the loader) — see its module\n docstring for the one canonical order.\n \"\"\"\n # Config precedence: see appconfig.py — the loader owns the full order. The `.env` is read\n # so `looplab run`/`looplab ui` pick up LOOPLAB_LLM_BASE_URL etc. without exporting them by hand\n # (keys MUST carry the LOOPLAB_ prefix, same as the env vars). utf-8-sig tolerates a BOM from\n # Windows editors. The test suite disables this (tests/conftest.py) so a dev's real .env in the\n # repo root can't leak into assertions.\n model_config = SettingsConfigDict(\n env_prefix=\"LOOPLAB_\",\n env_file=\".env\",\n env_file_encoding=\"utf-8-sig\",\n extra=\"ignore\",\n allow_inf_nan=False,\n )\n\n @classmethod\n def settings_customise_sources(\n cls, settings_cls, init_settings, env_settings, dotenv_settings,\n file_secret_settings):\n \"\"\"Preserve source precedence across canonical/legacy parallelism names.\n\n Pydantic ranks init > env > dotenv correctly, but it ranks field names rather than semantic\n alias families. Promoting a legacy value inside each source before the merge makes an old\n explicit snapshot/file value beat a lower-priority canonical env value. ``appconfig`` also\n promotes its file/typed/--set sub-layers before combining them into the init source.\n \"\"\"\n def _with_parallel_aliases(source):\n return lambda: canonicalize_parallelism_source(source())\n\n return (\n _with_parallel_aliases(init_settings),\n _with_parallel_aliases(env_settings),\n _with_parallel_aliases(dotenv_settings),\n _with_parallel_aliases(file_secret_settings),\n )\n\n # Run profile (config-first preset, see PROFILES above). \"default\"/\"fast\" = today's lean\n # defaults; \"thorough\" turns the built quality/trust machinery ON in one word. A profile only\n # fills fields the user did NOT set — every knob it touches stays individually overridable.\n profile: str = \"default\"\n\n # Bounds (review C/⚪): a non-positive node/seed budget or timeout is never valid. Upper bounds are\n # equally load-bearing — the UI /start path writes these straight into the engine's env, and an\n # unbounded `eval_parallel` (or legacy `max_parallel`; e.g. 100000 from a crafted preflight) would\n # fan out that many sandboxes → resource exhaustion. Reject at config time, not mid-run. Ceilings\n # are generous; tests top out near 50.\n n_seeds: int = Field(default=3, ge=1, le=1024)\n max_nodes: int = Field(default=8, ge=1, le=1_000_000)\n # CODEX AGENT: legacy evaluation-width input. `eval_parallel` below is canonical and wins when set;\n # keep this field for old env/config/snapshots and direct Engine call sites. A width > 1 admits\n # concurrent evals, whose resource reservations may be CPU-only or span one/more GPUs — there is no\n # one-node/one-GPU promise. `max_parallel=0` is launch-time AUTO (detected GPU count, at least 1).\n max_parallel: int = Field(default=1, ge=0, le=1024)\n # CODEX AGENT: legacy build-width input. `llm_parallel` below is canonical and also controls the\n # shared provider-call total when positive. A width > 1 uses fresh pooled researcher/developer pairs;\n # it says nothing about the later eval's CPU/GPU reservation. Startup `0` derives build fan-out from\n # resolved eval width; a live Strategist/operator zero settles to 1. Without role_factory it clamps to 1.\n parallel_build: int = Field(default=1, ge=0, le=64)\n # Hypothesis-card Kanban re-architecture (docs/23, Layer 2). Two INDEPENDENT concurrency axes,\n # decoupled from each other so cost (LLM work) can be tuned separately from throughput (GPU evals).\n # These are the CANONICAL names going forward; `max_parallel`/`parallel_build` above are kept as thin\n # back-compat aliases (env vars / snapshots / ~100 Engine(...) call sites depend on the exact names,\n # so they cannot be renamed — CLAUDE.md). Resolution (orchestrator.__init__): a NEW name, when set,\n # WINS over its legacy alias; None => fall back to the legacy field => byte-identical to today.\n # `eval_parallel`: concurrent EVALS (the GPU/experiment consumer). None -> max_parallel; 0 = AUTO\n # (one experiment per detected GPU). This is the in-Run eval-concurrency ceiling; the resource\n # scheduler bin-packs declared footprints separately. Sibling local GPU-owning Runs serialize\n # through the conservative host-pool lease; external execution needs its own admission boundary.\n # `llm_parallel`: total concurrent LLM provider calls + BUILD fan-out. None -> legacy\n # parallel_build semantics without a shared total; 0 = startup AUTO (= resolved eval_parallel\n # for build fan-out, historical research overlap unbounded). A positive canonical value enables\n # the named-lane broker the Strategist can reallocate at cadence.\n eval_parallel: int | None = Field(default=None, ge=0, le=1024)\n llm_parallel: int | None = Field(default=None, ge=0, le=64)\n # Training-log monitor (I-series watchdog family): a per-eval background observer that tails the live\n # training log while a (often multi-hour) declared stage runs in a worker thread. ON in the product\n # surface (Settings). Its alert event is fold-ignored and cannot directly change lifecycle/champion;\n # CODEX AGENT: with watchdog_reflection on, that alert is nevertheless advice in a later Researcher\n # prompt and can change future proposals. It only fires on the command-eval path with an LLM client;\n # bare-library EngineOptions stays OFF. See test_options_divergence.\n # `_interval_s` is the BASE tick cadence (the effective one adapts to the per-experiment budget).\n train_monitor: bool = True\n train_monitor_interval_s: float = Field(default=600.0, gt=0)\n # Phase 3 — INTERVENTION (opt-in, separate from observation): let the monitor tree-kill a training the\n # LLM judges 'broken' (diverged / silent CPU fallback / not learning) EARLY, instead of burning the\n # whole budget. Off by default — the observer only WATCHES unless this is on. A kill only fires on a\n # 'broken' verdict (a plateau is 'watch', never killed) with confidence >= the threshold; the node then\n # fails normally (reason='monitor_broken'), so replay reconstructs it from that one terminal event.\n train_monitor_kill: bool = False\n train_monitor_kill_confidence: float = Field(default=0.8, ge=0.0, le=1.0)\n # ASHA live-curve watchdog (sibling of the training monitor): reads the latest INTERMEDIATE value of\n # the objective metric off the live log (reusing the eval's OWN metric reader). Finished-endpoint rank\n # remains advisory. An opt-in KILL additionally requires an operator-declared metric.resource_key and\n # enough sibling observations at exactly that resource; absent comparable evidence cannot stop a run.\n # Records a fold-ignored `asha_rank` diagnostic + trace span; the library default is OFF (off == today).\n asha_live: bool = True\n # Opt-in INTERVENTION (separate from the advisory signal): let the watchdog tree-kill a node whose\n # intermediate metric stays below the SAME-RESOURCE bar past a short grace window. Off by default —\n # it only surfaces endpoint rank unless this is on. A kill fails normally (asha_underperforming).\n asha_live_kill: bool = False\n # The bar sits at `asha_live_quantile` along a WORST->BEST ordering of the finished siblings' finals:\n # 0.5 = the median (a node worse than the median peer flags); a SMALLER value is more conservative —\n # it lowers the bar toward the WORST finished peer, so only a node worse than nearly all peers flags\n # (0.0 = worse than the worst). `min_siblings` is the floor of finished peers required before it ranks\n # at all, so it never acts on too little evidence.\n asha_live_quantile: float = Field(default=0.5, ge=0.0, le=1.0)\n asha_live_min_siblings: int = Field(default=3, ge=1)\n timeout: float = Field(default=30.0, gt=0)\n # Hard ceiling for the Researcher's governed per-node `Idea.eval_timeout`. One hour preserves the\n # existing heavy/neural-net authoring example while staying well below the sandbox's defensive\n # 24-hour subprocess ceiling. This is operator-owned run config: agents may request less or more,\n # but the accepted action is always clamped here after the `agent_control.timeout` permission gate.\n max_eval_timeout: float = Field(default=3600.0, gt=0, le=24 * 3600.0)\n # Intra-node sweep: a sweep node runs a whole grid in one process, so it gets this multiple of\n # `timeout` as its wall-clock budget (solution.py path; RepoTasks use their per-profile timeout).\n sweep_timeout_mult: float = Field(default=8.0, ge=1)\n # Sandbox tier (ADR-13): \"trusted_local\" (subprocess, no Docker) for the CLI;\n # \"untrusted\" (Docker --network none, shared-kernel runtime) for hosted/multi-tenant UI;\n # \"hostile\" (untrusted + a true-isolation OCI runtime, gVisor `runsc` by default / Kata) for\n # running untrusted code. See runtime/sandbox.py::make_sandbox for the tier selection.\n trust_mode: str = \"trusted_local\"\n # Image for the untrusted command-eval tier (RepoTask, Phase 4): the framework's deps\n # should be baked in (the container runs --network none, so a pip setup can't fetch).\n docker_image: str = \"python:3.12-slim\"\n # Resource caps for the untrusted/hostile Docker tier (passed to `docker run --memory/--cpus`).\n # The default 4g bound protects other tenants from an OOM, but a model-training MLE-bench eval\n # routinely needs more — raise it (e.g. \"16g\") for such runs. \"\" disables the cap (unbounded).\n # Ignored by the trusted_local (subprocess) tier.\n sandbox_memory: str = \"4g\"\n sandbox_cpus: str = \"\"\n # Best-effort host-OOM guard for the TRUSTED-LOCAL (subprocess) tier. When set (e.g. \"8g\"), each\n # eval child gets an RLIMIT_AS address-space cap so a runaway trainer hits MemoryError instead of\n # OOM-killing the whole host (and the engine). POSIX only; a no-op on Windows. Default \"\" = OFF:\n # RLIMIT_AS bounds VIRTUAL memory, and CUDA/torch reserve tens of GB of virtual without using it,\n # so a default cap would spuriously kill GPU evals — leave it off for those and use the Docker\n # tier's real --memory cgroup bound (sandbox_memory) instead. Ideal for CPU/numpy/sklearn runs.\n sandbox_memory_local: str = \"\"\n # Best-effort disk-fill guard for the TRUSTED-LOCAL tier (P1-5): an RLIMIT_FSIZE cap on the size of\n # any single file an eval child writes (e.g. \"2g\"), so a runaway that writes an unbounded file gets\n # SIGXFSZ instead of filling the host disk. POSIX only. Default \"\" = OFF (large model checkpoints\n # need big files); set it for tasks that write only small artifacts.\n sandbox_fsize_local: str = \"\"\n # Search policy (ADR-2): \"greedy\" | \"evolutionary\" | \"mcts\" | \"asha\".\n policy: str = \"greedy\"\n # Ablation-driven refinement (I7): every N improves, ablate the best to find the\n # highest-impact parameter and refine it. 0 = off. (GreedyTree only.)\n ablate_every: int = 0\n # A0a (MLE-STAR): when ablating, treat each generated pipeline *code block* (not just a\n # numeric param) as an ablation unit — neutralize a block, measure the metric delta, refine\n # the highest-impact block. Off => the classic numeric-param ablation. Config-first knob.\n ablate_code_blocks: bool = False\n # A0b: real merge/ensembling. \"mean\" = legacy mean-param merge; \"ensemble\" = the Developer\n # writes a code-recombination ensemble over the two parents' solutions (verified: agent-proposed\n # ensembling 37.9%->43.9%). \"auto\" (default) = ensemble whenever the Developer actually\n # generates code (LLM/agent backends; declared via `is_code_generating`), mean for the\n # templated/toy developers where a code ensemble is meaningless — the evidence says code\n # recombination is the strongest single merge (removing it costs ~9 pp), so it is the default\n # wherever it can work. Falls back to mean when the Developer can't ensemble.\n merge_mode: str = \"auto\"\n # A0d (AIRA): inject a dynamic complexity hint into the draft/improve prompt keyed on the\n # node's child count (few children -> keep minimal; many -> escalate to ensembling/HPO).\n complexity_cue: bool = False\n # I1 (CAAFE-style) LLM feature-engineering: instruct the proposer/developer to add\n # semantically-meaningful engineered features (ratios, interactions, aggregations) as code. The\n # existing cross-validation eval + best-selection is the CV GATE — a feature set that doesn't\n # improve CV is never selected (feature engineering is non-universal, so this gate is mandatory).\n # Tabular tasks (those exposing column data). Off by default.\n feature_engineering: bool = False\n # A5: surface the REMAINING eval-compute budget into the proposal prompt so the Researcher can\n # reason over how much compute is left (explore broad while flush, exploit/cheapen when low).\n # No-op unless a max_eval_seconds budget is set.\n budget_aware: bool = False\n # A4 (LATS-style): feed a summary of the most recent FAILED branches (operator + error reason)\n # back into the proposal prompt so the proposer reflects on and avoids repeating them. ON by\n # default: it is SELECTIVE by construction (injects only when recent failures exist — the\n # evidenced form of reflection; reflecting at every step shows no gains, arXiv:2506.12928).\n failure_reflection: bool = True\n # Feed the LIVE-WATCHDOG observations (train-monitor health verdicts + ASHA intermediate-rank flags)\n # of recent experiments back into the proposal prompt, so the Researcher avoids re-proposing a config\n # whose TRAINING was already observed to be weak — even when the watchdog kills are off (default) and\n # the node ran to completion (those diagnostics are fold-ignored, so failure_reflection never sees\n # them). ON by default and SELECTIVE like failure_reflection (injects only when a recent flag exists).\n watchdog_reflection: bool = True\n # C3 deep test-driven repair: hand the Developer the failure taxonomy + a structured \"reproduce\n # then fix\" directive on debug, not just the raw stderr tail. ON by default (product surface); the\n # conservative library default in EngineOptions stays off, per that module's contract.\n deep_repair: bool = True\n # === Inline crash repair ==============================================================\n # Hybrid in-node crash repair: when an LLM-generated node CRASHES at runtime (mechanical errors\n # — bad import, removed kwarg, typo), the agent triages it and may repair the code IN PLACE within\n # the same eval (cheap; does NOT consume max_nodes and does NOT add a node to the search tree).\n # Semantic failures still flow to a new idea/debug node via the policy. ON by default; set False\n # to restore the prior behavior (every crash waits for a budgeted inter-node debug node).\n inline_repair: bool = True\n # Max in-place repair attempts per node before it fails normally. Default 0 = UNLIMITED: the same\n # agent keeps fixing + re-evaluating IN THE SAME node until it produces a clean metric — no new\n # node just to retry. Runaway is prevented not by a count but by the anti-stuck guard\n # (`inline_repair_stuck_repeat`): when the SAME error signature repeats with no progress, or the\n # agent's crash-triage says \"abandon\", the node fails. Set a positive N to cap the retries instead.\n inline_repair_attempts: int = Field(default=0, ge=0)\n # Anti-stuck: abandon in-node repair when the SAME error recurs this many times in a row (no\n # progress). Keeps \"unlimited\" repair from looping forever on an unfixable error.\n inline_repair_stuck_repeat: int = Field(default=4, ge=2)\n # Which failure reasons (_failure_reason: crash|timeout|oom|setup|no_metric|drift) are eligible for\n # inline repair. Default: mechanical crashes, timeouts AND OOM-kills — a timeout/OOM means the code\n # was too slow / too memory-hungry for the budget (or the pod's cgroup limit), not that the idea is\n # wrong, so the agent repairs it by reducing compute/memory (fewer estimators/epochs/folds/seeds, a\n # lighter model, a smaller batch, subsampling) instead of letting the node die unrecovered. Env\n # override expects a JSON array.\n inline_repair_reasons: tuple[str, ...] = (\"crash\", \"timeout\", \"oom\")\n # Cost ceiling for in-node repair of a MULTI-STAGE eval: the max number of FULL pipeline re-runs\n # (i.e. re-trains from the first stage) the inline-repair loop may do before abandoning to the\n # inter-node debug operator. A late-stage failure (a broken `score`/eval script that didn't touch\n # `train`) REUSES the completed train checkpoint and re-runs only from the failed stage — that is\n # CHEAP and NOT counted here; only a repair that changes an EARLIER stage's code (train.py/loss.py/…)\n # forces a full re-train, which each costs the whole training time. Without this bound, a repair that\n # keeps changing training code could burn many full trains (the anti-stuck guard is error-signature\n # based, not cost based). 0 = unlimited (legacy behavior). Single-command evals ignore it.\n inline_repair_retrain_cap: int = Field(default=2, ge=0)\n # Environment self-prep: when a solution crashes purely because a KNOWN library isn't installed\n # (ModuleNotFoundError), the engine pip-installs it into the eval interpreter and re-runs — so a\n # torch/XGBoost/CatBoost idea (e.g. a GRU model) actually runs on a fresh box instead of being\n # thrown away as `idea_rejected`. Trusted_local tier only (the untrusted/hostile Docker tiers run\n # --network none). A typo'd or local helper module is NOT installed (that's a code bug to repair).\n auto_install_deps: bool = True\n # Per-package wall-clock budget for an auto-install (seconds). Generous: large wheels like torch\n # can take minutes to download/build.\n dep_install_timeout: float = Field(default=900.0, gt=0)\n # --- Agent governance: who may change a setting at runtime (per-setting allow-list of roles) ---\n # Roles: \"strategist\" (A7 meta-controller; run-wide), \"boss\" (run-chat operator-proxy; run-wide),\n # \"researcher\" (per-experiment proposer; PER-NODE — e.g. set a longer eval timeout for a neural-net\n # idea). A setting ABSENT from this map is LOCKED — no agent may change it, only a human via the\n # snapshot/UI. The sole conditional exception is Strategy-only `card_scoring`: Card mode grants\n # it implicitly without changing the default flag-off snapshot, while an explicit [] revokes it.\n # The gate is ENFORCED at every AGENT seam via `_agent_may` — the strategist's whole\n # control surface (policy/operators/novelty_stance/prefer_sweep/developer/fidelity/timeout/\n # eval_parallel/llm_parallel) is checked in `_apply_strategy`, so removing a role from a knob locks it,\n # not just greys out the UI pill. The boss/max_* grants document boss-relevant knobs, but a\n # `budget_extend` is a HUMAN control intent (the boss action-builder can only emit `add_nodes`), so\n # it is applied as-is — the human always wins via the UI/snapshot. Defaults are conservative: resource/\n # search-shape knobs the agents already reason about; provider infra (llm_model/base_url/api_key,\n # docker_image) stays locked. The UI shows S/B/R pills per setting. `timeout` granted to the\n # researcher IS the \"auto\" per-node mode — the researcher sizes each experiment's wall-clock\n # (Idea.eval_timeout); the config `timeout` is the fallback for nodes it doesn't size. Env override\n # expects a JSON object. (`fidelity`/`novelty_stance`/`prefer_sweep`/`developer` are governance keys\n # for the strategist's per-node dials — not 1:1 Settings fields, but gated the same way.) The default\n # lives in the module-level DEFAULT_AGENT_CONTROL so the Engine's direct-construction default matches.\n agent_control: dict[str, list[str]] = Field(default_factory=default_agent_control)\n # C1 (Agentless localization): for repo tasks, rank the source files most relevant to the most\n # recent failure (traceback + identifiers) and surface them in the proposal/repair prompt so the\n # Developer edits the right place. Off by default; repo tasks only.\n localize_faults: bool = False\n # A2 surrogate-guided proposal (BO-lite): fit a k-NN surrogate over (params->metric) and propose\n # by acquisition instead of random/hill-climb. Numeric-bounds tasks only; bootstraps via the\n # base Researcher. Off by default. `surrogate_explore` = UCB-style exploration weight.\n surrogate_proposer: bool = False\n surrogate_explore: float = 0.1\n # E2 researcher panel: generate K candidate ideas per proposal and keep the one ranked best by a\n # cheap empirical surrogate over the (params->metric) history (NOT an LLM-judge). 1 = off.\n researcher_panel: int = 1\n # A1 ASHA / successive-halving: reduction factor (keep top 1/eta per rung) and rung budget.\n asha_eta: int = Field(default=3, ge=2)\n # Rung-0 width for ASHA/BOHB (the wide base of cheap drafts). 0 = use n_seeds (default,\n # preserving prior behavior); set >0 to seed a wider/narrower base independent of n_seeds.\n asha_rung_nodes: int = Field(default=0, ge=0)\n # A6 proxy/predictive scoring: cheaply rank a candidate's potential from early-stage signals\n # and skip a full eval for the doomed bottom fraction. 0.0 = off (no candidate skipped).\n proxy_scoring: bool = False\n proxy_kill_fraction: float = Field(default=0.0, ge=0.0, le=0.9)\n # === Novelty / dedup ==================================================================\n # E1 novelty/dedup gate: reject near-duplicate proposals (normalized param-space distance below\n # `novelty_epsilon`) by deterministically nudging them off the duplicate — stops the search\n # wasting evals re-trying the same idea. Audit event `novelty_rejected`. Off by default.\n # Novelty/dedup MODE selector: how a fresh proposal is checked against already-tried experiments.\n # \"off\" — no explicit gate; the agentic Researcher's own read-the-history judgment is trusted.\n # \"algo\" — the deterministic gate: numeric param-distance (`novelty_epsilon`) + optional embedding\n # similarity (`novelty_semantic`). Cheap, no LLM call, but can't explain itself.\n # \"llm\" — an LLM adjudicates (reads the real experiments via tools) whether the idea is a\n # near-duplicate and, if so, asks the Researcher once more for something different. One\n # extra LLM call per proposal — highest quality, follows the \"let the LLM decide\" line.\n novelty_mode: str = \"llm\"\n # Legacy sub-toggles, honored by the \"algo\" mode (and back-compat: novelty_gate=True forces \"algo\"):\n novelty_gate: bool = False\n novelty_epsilon: float = 0.05\n # T5 semantic novelty (active whenever the deterministic gate runs — novelty_mode=algo,\n # novelty_gate=true which is the legacy alias for algo, OR the Strategist novelty stance is\n # \"explore\"; a no-op only under novelty_mode=llm/off with a non-explore stance): reject a proposal\n # whose idea TEXT embeds within\n # `novelty_semantic_threshold` cosine of an existing node's. OFF BY DEFAULT: novelty is the agentic\n # Researcher's call, made by READING the actual prior experiments (read_experiment /\n # find_analogous_across_runs / list_experiments), NOT an embedding-similarity auto-reject that can't\n # explain itself and mis-fires on paraphrases. Semantic/param search stays available as TOOLS in the\n # Researcher's context; the LLM decides whether an idea is a true duplicate.\n novelty_semantic: bool = False\n novelty_semantic_threshold: float = Field(default=0.92, ge=0.5, le=1.0)\n # T10: debug-lineage depth bound for every policy — how many error-feedback repairs a failing\n # lineage gets before it is abandoned. The old default (1) abandoned lineages after ONE repair;\n # multi-turn/deeper debugging is a verified lever (AIRA2 ReAct debug +5.5 percentile points).\n debug_depth: int = Field(default=2, ge=1)\n # P4 operator bandit (GreedyTree): replace the fixed merge/ablate cadences with a\n # deterministic UCB over per-operator yield (Δmetric per eval-second, folded from the run).\n # Off by default (the cadences are well-tested and the bandit has no direct published\n # ablation); `thorough` turns it on.\n operator_bandit: bool = False\n # P1 hypothesis ledger: ask the Researcher to state the one-line hypothesis each experiment tests,\n # register deep-research directions as hypotheses, and track them to a verdict on the board. ON by\n # default. CODEX AGENT: the board is shown to the Researcher and can be foresight-ordered, so it can\n # steer later proposals even though it never directly re-ranks already-evaluated nodes. Set False\n # to drop the prompt nudge + registration.\n track_hypotheses: bool = True\n # E4/M2/M3 reflection-memory -> priors (gradient-free cross-run meta-learning): at run end distill\n # the winner into `<memory_dir>/meta_notes.jsonl` AND structured lessons (incl. NEGATIVE results:\n # tested/abandoned hypotheses + failure themes) into `lessons.jsonl` with a task fingerprint; at run\n # start, inject exact-task notes + fingerprint-matched lessons from SIMILAR tasks into the proposal\n # prompt. ON by default — but a NO-OP until `memory_dir` is set (that's where cross-run memory lives).\n reflection_priors: bool = True\n # M6 comparative lessons (MARS \"comparative reflective memory\", doc 13 §7 item 2): distill\n # credit-assigned lessons from PAIRS of solutions — which SPECIFIC change made a child beat\n # (or regress from) its parent, and what fixed a failure — on top of the one-shot run-end\n # reflection. At most one extra LLM call per distillation (all pairs batched); offline a\n # deterministic param-diff credit stands in. Gated on reflection_priors + memory_dir like the\n # rest of cross-run memory — and since memory_dir has a default (~/.looplab/memory), this is\n # ACTIVE out of the box; clear memory_dir to disable cross-run memory wholesale.\n comparative_lessons: bool = True\n # M6 mid-run cadences (doc 13 §7 item 5 — the AgentRxiv live-share pattern): every N created\n # nodes, WRITE comparative lessons to the shared cross-run store during the run (lessons_every\n # — one batched LLM call per firing) and RE-READ the store so lessons distilled by CONCURRENT\n # runs reach this run's proposals (lessons_refresh_every; file re-read, no LLM call, skipped\n # when the store is unchanged). ON by default (every 4 nodes) and, like comparative_lessons\n # above, live out of the box because memory_dir defaults on; 0 = off — run-end write /\n # run-start read only, the pre-M6 behavior.\n lessons_every: int = Field(default=4, ge=0)\n lessons_refresh_every: int = Field(default=4, ge=0)\n # B3 output redaction: mask credentials (known key shapes + high-entropy tokens) in the\n # stdout/stderr tail before it is persisted to the event log / spans / UI — a leaked secret in a\n # print()/traceback must not land in the durable log. Off by default; recommend on for untrusted.\n redact_output: bool = False\n # B5 reward-hacking detector: a host-side monitor that flags suspicious wins (grader/answer-key\n # access, runtime writes to frozen files, suspiciously-perfect metrics) as a `reward_hack_suspected`\n # audit event in the Trust panel. Off by default. Whether a flag CHANGES selection is governed by\n # `trust_gate` below (default: audit-only, never changes selection).\n reward_hack_detect: bool = False\n # Trust enforcement (T2): what a reward-hack / data-leakage flag actually DOES to selection.\n # \"audit\" (default) = surface it in the Trust panel, never change selection (today's behavior);\n # \"gate\" = a flagged node can no longer be selected as BEST **and isn't bred/confirmed from**\n # (breed_excluded — see replay._apply_trust_gate / models.breedable_nodes), but stays\n # FEASIBLE so it still counts for diversity/audit — closes the \"a hacked/leaky node can\n # win OR seed its lineage\" hole;\n # \"block\" = additionally mark it fully INFEASIBLE (removed from feasible_nodes entirely).\n # Deliberately gates only high-precision CHEATING/LEAKAGE signals. Broad `critic:*` findings stay\n # advisory; CODEX AGENT: `critic:hardcoded_metric` is the one narrow high-precision critic exception\n # and gates because it requires a literal metric with no computed assignment anywhere.\n trust_gate: str = \"audit\"\n # I3 data-centric: static code-leakage scan of each evaluated solution (fit-before-split,\n # fit-on-test) surfaced into the Trust panel alongside reward-hack flags. Off by default.\n code_leakage_detect: bool = False\n # 4.4 sandbox instrumentation (needs reward_hack_detect): after each eval, flag a RUNTIME write\n # to a protected/frozen file (an answer key or grader tampered mid-run) — behavioral evidence a\n # static code scan misses. ON by default; only fires when the reward-hack detector is on.\n workdir_audit: bool = True\n # D8 decoupled research Verifier: check every Deep-Research memo's CLAIMS against their cited\n # evidence (node ids / urls) before recording — synthesis is the documented weak link (Kosmos\n # 57.9% accurate). Deterministic layer always; an LLM rubric pass when the DeepResearcher has a\n # client. The verdict cannot change this run's node/champion, but finalize uses aligned supported\n # verdicts as the evidence gate for persisted D8 cross-run claims; unavailable/misaligned stays\n # unverified. ON by default (no-op with no memo/claims).\n research_verify: bool = True\n # C4 independent critic: an execution-free critic of each solution (stub / hardcoded-metric /\n # params-ignored; on host-graded tasks the metric checks become a submission-output check)\n # surfaced in the Trust panel. Broad findings are advisory; `critic:hardcoded_metric` can gate under\n # trust_gate=gate/block. Off by default.\n critic_check: bool = False\n # A7 Strategist (NEW, user-requested): optional LLM/rule meta-controller that picks the search\n # policy/allocator + operator mix + fidelity (+ Developer backend) per situation. Config-first:\n # every choice the Strategist makes is also a direct knob. Defaults to \"agent\" so the agent adapts\n # the search policy/operators/fidelity per situation; set \"off\" for static config-driven search.\n # \"agent\" = a tool-using Strategist that READS the run/data/sibling-runs/KB/memory (B1-guarded)\n # before deciding, instead of the single-shot \"llm\" call over aggregate stats.\n strategist_backend: str = \"agent\" # \"off\" | \"rule\" | \"llm\" | \"agent\" — default AGENTIC: the\n # Strategist READS the run/data/sibling-runs/KB/memory with tools before deciding, not a single-shot\n # call over aggregate stats. \"llm\" = the old non-agentic single-shot; \"rule\"/\"off\" = no LLM.\n strategist_every: int = Field(default=3, ge=1) # consult cadence (created nodes)\n # PART V (F1): concept CLASSIFIER re-tag + consolidation cadence, DECOUPLED from strategist_every. The\n # LLM concept map is heavier and slower-moving than a strategy consult, so it refreshes on its own\n # (sparser) interval. Researcher-authored idea.concepts still fold immediately at node_created — this\n # only paces the classifier-evidence + consolidation refresh (and the concept-coverage pivot snapshot).\n concept_retag_every: int = Field(default=30, ge=1)\n # === Confirmation & holdout ===========================================================\n # Multi-seed confirmation (I12, ADR-15): confirm the top-k under N seeds before\n # finishing. 0 disables (default). Only meaningful when eval has variance.\n confirm_top_k: int = 0\n confirm_seeds: int = 0\n # D1 seed-holdout discipline: the FIRST seed the confirm phase uses. Search evals run with the\n # implicit seed 0 (LOOPLAB_EVAL_SEED unset -> \"0\"), so a base of 1 keeps every confirm split\n # DISJOINT from anything the search optimized against — the confirm metric is then a\n # generalization signal, not a re-measurement (AIRA: selecting on a signal the search saw\n # overfits by 9-13 pp). Set 0 to restore the legacy overlapping seeds 0..N-1.\n confirm_seed_base: int = Field(default=1, ge=0)\n # D1 holdout-gated promotion (B6, Arbor-style): for host-graded tasks, reserve this fraction of\n # the held-out labels as a FINAL holdout partition the search never sees — every search/confirm\n # eval is scored on the remaining rows only, and at finish the val-top-k are re-scored on the\n # holdout partition (free: the predictions already exist). 0.0 = off (legacy full-label scoring).\n holdout_fraction: float = Field(default=0.25, ge=0.0, le=0.9)\n # Champion selection prefers the HOLDOUT metric among the nodes that have one (the val-top-k).\n # Recorded in run_started so replay applies the same rule; old logs fold to False (legacy pick).\n # The per-node val-holdout `generalization_gap` folds into the Trust panel either way.\n holdout_select: bool = True\n # How many val-leaders get a holdout evaluation at finish (host-graded tasks).\n holdout_top_k: int = Field(default=3, ge=1)\n # R1-c / Part IV — calibrated §12-verifier tie-break in best-selection. When a fresh node's robust\n # metric EXACTLY TIES an already-eligible node, the engine runs the §12 verifier (grounded on each\n # node's realized result, `selection_criteria`) and records a soundness score per node. The tie-break\n # then applies across the WHOLE selection path: the mean pick breaks a robust_metric tie by the score,\n # and — when `holdout_select` is on — the holdout-slot ranking prefers the sounder node so it isn't\n # denied a holdout eval, and the final holdout override breaks a holdout_metric tie by it too. Strictly\n # a TIE-BREAK — it can NEVER move a node ahead of a strictly-better metric (§21.7 advisory-never-\n # overrides). Best-effort: verification is LAZY (only fires on an actual tie) and bounded per cadence,\n # so a large/late tie stays on deterministic metric+id order unless one atomic treatment scores every\n # member. Recorded in run_started so\n # replay applies the same rule; old logs fold to False (legacy pick). Opt-in (default off); needs a\n # reachable LLM client. Enable only after validating calibration offline with `verifier.calibrate`.\n # See trust/verifier.py + core/fitness.py.\n select_verifier: bool = False\n # R1-d (§21.19): widen the verifier tie-break from EXACT-metric to a conservative STATISTICAL tie.\n # The band is capped by both the metric leader's SE and the pooled SE-of-difference, so challenger\n # variance cannot widen it and a significant difference is never a tie (§21.7). Off -> exact only.\n verifier_ci_tie: bool = False\n # Verifier sample count for `select_verifier` (the §12 repeated-sampling expectation). 3 tames the\n # single-shot variance; 1 = single-shot (cheaper, noisier).\n select_verifier_samples: int = Field(default=3, ge=1, le=32)\n # Budget (I13): hard wall-clock ceiling; the run aborts cleanly when exceeded.\n max_seconds: float | None = None\n # Eval-compute budget (#2): hard ceiling on cumulative time spent INSIDE evals (training\n # runs), separate from wall-clock. Survives resume (summed from the event log). The real\n # guard against a silent multi-hour sweep when an eval is a minutes-hours training run.\n max_eval_seconds: float | None = None\n # Cross-run memory (I19, ADR-10): if set, the best result of each run is stored as\n # a case here, and the cases become retrievable knowledge for future runs.\n memory_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"memory\"))\n # HITL (I21, ADR-11): pause for human approval of the final best before finishing.\n require_approval: bool = False\n # Diversity archive (I22): niche bucket width in parameter space.\n archive_resolution: float = 1.0\n # Coverage read-model (narrowing signal): at the strategist cadence, compute a breadth summary\n # (themes / param-niches / theme entropy / dominant-theme fraction) from the folded run, record\n # it as a `coverage_snapshot` audit event, and surface it into the Strategist's decision context.\n # Deterministic, cheap, and purely additive context — no search-behavior change on its own. On by\n # default (like the rest of the always-on situational context). See search/coverage.py.\n coverage_context: bool = True\n # PART IV Phase 2a live steering (§21.11/§21.13). When on, the `concept_retag_every` cadence (NOT\n # `strategist_every` — the producer gates on `_should_consult_concepts`, which uses the seed boundary\n # + `concept_retag_every`, default 30) records a\n # concept-graph coverage + uncovered-region snapshot (deterministic heuristic tagger over the\n # task-type skeleton) and, on an `explore` stance, the Researcher's novelty hint names the exact\n # uncovered regions (\"0 coverage in {negatives/external-mining, distillation} — go there\") instead\n # of the vague \"broaden\". ON by default in the product Settings (ce4a379), EngineOptions off: it\n # only enriches an already-mode-gated explore hint, never forces exploration, and no-ops for a task\n # with no curated concept skeleton. The snapshot is auditable, but its prompt cue can change future\n # candidate generation; it does not directly rewrite best-metric selection. See search/concept_graph.py.\n # DESIGN NOTE (2026-07-17 critique): now ON unconditionally, but a toy / non-ML task yields EMPTY concept\n # tags while still paying the per-node LLM concept cost (~10 calls/node with the full Part IV/V bundle).\n # Consider AUTO-QUIESCING: if the heuristic tagger returns empty for the first N nodes (no concept signal),\n # skip the LLM concept work for the run — keeps it on for ML tasks, free on toy/degenerate ones.\n concept_pivot: bool = True\n # PART IV Phase 2b — D3 graded novelty into the LIVE novelty gate (§21.4/§21.13). When on and the\n # task has a curated concept skeleton, the novelty gate first grades a proposal over the concept\n # graph: a level-4 \"same direction, DIFFERENT implementation\" or a level-5 \"re-opens a\n # wrongly-abandoned FAILED direction\" proposal is ALLOWED through unchanged — the flat LLM/semantic\n # dedup gate can't tell \"this DCL tweak\" from \"the whole DCL branch\" and would wrongly reject a\n # legitimate variant or never re-open a sound-but-killed direction (the node_63 archetype). Levels\n # 1/2/3 (identical / near-dup / prior-run) still fall through to the existing gate. Deterministic\n # (heuristic tagger, no LLM), audit event `novelty_graded`, replay-safe. ON by default in the product\n # Settings (ce4a379), EngineOptions off; no-ops for a task with no skeleton. See search/graded_novelty.py.\n graded_novelty: bool = True\n # PART IV Phase 2b — D7 capability-expansion forced-jump DIRECTIVE (§21.8/§21.13, issue #7). When on\n # and the concept-graph cadence detects action-space LOCK-IN (the search has stayed inside one D5\n # branch for a long consecutive streak) on an `explore` stance, the Researcher's novelty hint\n # ESCALATES from \"broaden\" to \"expand the ACTION SPACE — build the missing infra (external mining,\n # a new data pipeline, a different eval), do NOT swap another {locked} lever.\" CODEX AGENT: the same\n # gate also stamps the proposal's authoritative operator as `expand`; that node competes normally\n # under SearchFitness and its separate operator yield is measured. It does not force a winner.\n # Reads the 2a concept-coverage snapshot, so it needs `concept_pivot` to record one. OPT-IN\n # (default off). See engine/proposal_cues.py + search/lock_in.py.\n capability_expansion: bool = False\n # PART IV cross-run Step 0 (§21.20.12/§21.20.13). Universal task-fingerprint tokenization: drop the\n # ASCII-only `[a-z0-9]` allowlist on goal keywords so a non-Latin goal (Russian, CJK, …) is NOT\n # silently dropped from its cross-run fingerprint (today such a goal keys only on kind/dir/metric and\n # never reaches SIMILAR-task priors/lessons/cases). Uses `[^\\W_]+`/`.casefold()` — same splitting as\n # before, any script. ON by default in the product Settings (ce4a379); the bare-library EngineOptions\n # default stays off, so a run pinned to it won't silently re-key a portfolio mid-flight. See engine/memory.py.\n fingerprint_universal: bool = True\n # PART IV cross-run Step 2 (§21.20). Fill graded-novelty's `prior_concepts` from the cross-run\n # ConceptCapsuleStore: at run end write a per-run concept capsule (the shipped `node_concepts` tags +\n # best outcome, keyed by task_fingerprint) to `memory_dir`; when a later SIMILAR run proposes an idea\n # whose concept was tried before, SURFACE the prior outcome as a `cross_run_prior` audit event (never\n # reject — D3 level 3). Audit-only, off the selection path; ON by default in the product Settings\n # (ce4a379), EngineOptions off. Needs a `memory_dir` to share capsules.\n # See engine/memory.py (ConceptCapsuleStore) + engine/novelty.py.\n # PREREQUISITES (CODEX — this flag is NOT standalone): the WRITE needs per-run concept tags\n # (`node_concepts`, produced by `concept_pivot`/F1 tagging) — no tags => no capsule; the SURFACE path\n # runs inside the graded-novelty precheck, so it also needs `graded_novelty` on. With only this flag +\n # `memory_dir` it persists/surfaces nothing. Enable `concept_pivot` + `graded_novelty` alongside it.\n # A non-Latin (Russian/CJK) portfolio should ALSO set `fingerprint_universal`, else the capsule's goal\n # tokens are dropped and its fingerprint over-matches on kind/dir/metric alone (the novelty direction\n # gate narrows this, but goal-word similarity is still lost).\n cross_run_concepts: bool = True\n # PART V (B): run-base + node-DELTA concept authoring. When on, once the first evaluated node has\n # authored concepts the engine seeds RunState.run_base_concepts from them (a one-shot EV_RUN_CONCEPTS,\n # idempotent because it only fires while the base is empty), and the Researcher's proposal hint tells it\n # to author only `concepts_added`/`concepts_removed` vs the run base + its parent — minimal per-node\n # annotations that inherit down the DAG (a swap propagates). Off -> every node authors its full concept\n # set as before. Product-on; the bare EngineOptions mirror defaults off (test_options_divergence).\n concept_run_base: bool = True\n # PART IV cross-run Step 5 advisory (§21.20.5). Fold the bounded cross-run CONTEXT PACK — evidence-\n # grounded claims with BOTH support and counter-evidence (Step 4) + a portfolio-coverage line (Step 3) —\n # into the Researcher's proposal prompt, exactly like the E4 cross-run prior note. Advisory ONLY: it is\n # prompt-grounding: it can change the Researcher's later candidates, but does not directly rewrite\n # best-metric selection (§21.7). Reads `memory_dir` (lessons.jsonl + concept_capsules.jsonl); \"\" when\n # empty. ON by explicit experimental product choice; no frozen A/B validation is claimed. The\n # bare-library EngineOptions default stays off (engine/options.py, frozen in\n # test_options_divergence). See engine/proposal_cues.py.\n cross_run_advisory: bool = True\n # PART IV cross-run §21.20.13 (full CR of the lean fuzzy claim merge). Switch the claim read-model to the\n # SCOPE+POLARITY-safe STRUCTURED claim key (engine/claim_key.py): claims from different tasks never\n # merge, opposite polarity (\"X helps\" vs \"X never helps\") is surfaced as a CONTRADICTION instead of being\n # collapsed, paraphrase/inflection variants group by exact structured key (no transitive over-merge), and\n # operator governance is scope-precise (a decision in task A cannot reach a same-worded claim in task B).\n # Affects the `cross_run_advisory` context pack only; ON by default in the product Settings (ce4a379);\n # the bare-library EngineOptions default stays off (engine/options.py). See engine/claims.py.\n cross_run_structured_claims: bool = True\n # PART IV cross-run §22.4 (AGENTIC taxonomy steward). At finalize, when an LLM client is available, let\n # the concept steward (engine/concept_steward.py) review the freshly-updated portfolio concept graph and\n # PROPOSE a curation — merge duplicate slugs / split conflated ones / purge noise. Proposals are LOGGED to\n # `concept_curation_log.jsonl` for operator ratification (via the serve/CLI governance surface); the LLM\n # never mutates fold state. The calls run synchronously during finalize, so they add latency and may add\n # paid inference; a caught steward failure does not prevent terminal completion or trigger a retry.\n # ON by explicit experimental product choice when `memory_dir` + an LLM backend are present; the\n # bare-library EngineOptions default stays off (engine/options.py). See engine/lessons.py.\n cross_run_curation: bool = True\n # Deprecated compatibility flag. Steward output is untrusted and finalize is proposal-only regardless\n # of this value; retained so old snapshots still validate and logs can disclose that auto was requested.\n cross_run_curation_auto: bool = False\n # Role backend (ADR-7/14): \"toy\" (offline optimizer) | \"llm\" (live model).\n backend: str = \"toy\"\n # Developer backend (ADR-7): \"default\" (templated/LLM from the task) or an external\n # CLI coding agent: \"opencode\" | \"aider\" | \"goose\" | \"continue\".\n developer_backend: str = \"default\"\n # C2 best-of-N: generate N candidate implementations per node and keep the best by an\n # execution-free reward (static validity + metric-print). 1 = off. In-house LLM developer only\n # (not external agents — ADR-7 cost rule). The top SWE-bench reliability lever for weak models.\n best_of_n: int = 1\n # D10 list-wise best-of-N selection: when the top static-scorers TIE, break the tie with a\n # comparative LLM selection over the candidates presented together (+~3 pts vs pointwise on\n # GAIA, arXiv:2506.12928). The static score stays the primary filter — the LLM is a weak\n # comparative prior, never the sole oracle. ON by default (no-op when best_of_n==1).\n best_of_n_listwise: bool = True\n # === Foresight / predict-before-execute ===============================================\n # FOREAGENT predict-before-execute (arXiv:2601.05930): use the LLM as an IMPLICIT WORLD MODEL to\n # predict — WITHOUT executing — which candidate / hypothesis will score best, primed with a\n # Verified Data Analysis Report (data profile / brief) + the accumulated experiment memory. Master\n # switch, ON by default; a no-op wherever there is nothing to rank (best_of_n==1 and\n # foresight_panel==1) or no LLM client. See `looplab/search/foresight.py`.\n foresight: bool = True\n # Hypothesis foresight breadth: generate K candidate ideas per proposal and keep the one PREDICTED\n # best pre-execution by the world model — it ranks the STRUCTURAL / text ideas the numeric k-NN\n # surrogate (researcher_panel) is blind to, primed with the data profile + memory (the synergy).\n # >1 enables it (LLM backend only; needs `foresight` on); 2 = on by default at modest cost, 1 = off.\n foresight_panel: int = 2\n # AGENTIC foresight: run the ranking (hypothesis-board prioritization + K-idea pick) as a TOOL-USING\n # loop that can pull actual experiment results / data facts before deciding, instead of a one-shot\n # prediction from a pre-baked report. ON by default (needs foresight + a client; falls back to the\n # one-shot predictor on any hiccup). A few extra LLM calls per proposal; set False for the cheaper\n # single-call ranker.\n foresight_agentic: bool = True\n # §1 foresight confidence gate: the minimum predicted confidence at which a predict-before-execute\n # pick is ACTED ON. Below it the ranker abstains — the K-idea panel falls back to the first\n # proposal, best-of-N falls through to the D10 tie-break — instead of committing a low-confidence\n # choice. 0.0 (default) = OFF (act on every pick, the historical behavior); raise toward ~0.5 to\n # make the world model defer when it isn't sure. Pairs with the foresight track record (§1) the\n # predictor is now primed with. Range 0.0-1.0.\n foresight_min_confidence: float = 0.0\n # PART IV Phase 2c — replace the world model's SELF-REPORTED confidence (measured Pearson≈0 with the\n # realized outcome, §21.12) with a CALIBRATED §12-verifier score. When on and a client is available,\n # after the K-idea ranker picks the predicted-best candidate the §12 verifier scores it (grounded +\n # repeated + criteria-decomposed: `foresight_criteria` — likely to improve the objective, and\n # sound/feasible) and THAT calibrated score becomes the confidence the `foresight_min_confidence` gate\n # and the telemetry use. Degrades to the self-reported confidence without a client or on any verifier\n # error (the telemetry records which source was used). ON by default in the product Settings (ce4a379),\n # EngineOptions off; a few extra LLM calls per acted-on proposal. See looplab/trust/verifier.py + search/foresight.py.\n # REVISIT (cost/quality, not yet resolved): a live classification run measured ~10 LLM calls & ~3.8 min/node\n # on deepseek-v4-flash with the full Part IV/V default bundle; the calibrated verifier is part of that per-node\n # tax. Kept ON deliberately — it raises decision quality (self-reported foresight confidence has ≈0 Pearson\n # with realized outcome), and lowering quality to save calls is the wrong trade. TODO: run a frozen quality/cost\n # A/B (verifier-on vs -off) to quantify the recall@objective gain per extra call before considering a default flip.\n foresight_verify: bool = True\n # Verifier sample count for `foresight_verify` (the §12 repeated-sampling expectation on a no-logprob\n # backend). 3 tames the single-shot variance §21.12 measured; 1 = single-shot (cheaper, noisier).\n # RESUME-critical: this value is persisted in config.snapshot.json, so a hard `le` schema bound would\n # make an old run started with a larger LOOPLAB_FORESIGHT_VERIFY_SAMPLES un-resumable (Settings(**snapshot)\n # would raise on `looplab resume` / finalize-recovery / serve replay). CLAMP into [1, MAX] via a validator\n # instead of rejecting — the runtime cap is preserved without breaking the snapshots/env-compat rule.\n foresight_verify_samples: int = 3\n\n @field_validator(\"foresight_verify_samples\")\n @classmethod\n def _clamp_foresight_verify_samples(cls, v: int) -> int:\n # Coerce a persisted/env value into [1, MAX] rather than reject (see field note above).\n try:\n v = int(v)\n except (TypeError, ValueError):\n return 3\n return max(1, min(v, MAX_FORESIGHT_VERIFY_SAMPLES))\n\n # T7 LLM response cache: serve an identical DETERMINISTIC (temperature 0) request from an\n # in-process content-addressed cache instead of re-hitting the model — cuts cost on\n # retry/panel/verify flows. NEVER caches sampling calls (temp>0: best-of-N / panel / novelty\n # re-propose must vary). Off by default (most role calls run at temperature>0 anyway).\n llm_cache: bool = False\n agent_cmd: str | None = None # override the agent's launcher (path / wrapper)\n # External-agent validation (ADR-7): wrap a CLI-agent Developer with a validator that\n # audits each output (no-op/syntax/crash/timeout), retries with feedback, and falls\n # back to the in-house LLM Developer. Off -> use the raw agent output unchecked.\n validate_agent: bool = True\n agent_max_retries: int = 1 # re-prompts of the agent on an invalid result\n # Patch-gated multi-file agent (ADR-7 Rule 3): run the CLI agent in a git worktree and\n # accept only edits whose paths match `agent_surface` (reject-not-strip out-of-surface\n # touches). Lets the agent create helper modules, not just solution.py. Degrades to\n # whole-file readback if git is unavailable.\n agent_patch_gate: bool = True\n agent_surface: list[str] = [\"*.py\"] # edit-surface allow-list (globs)\n # Trust policy for an agent-authored eval/metric adapter (RepoTask onboarding, Phase 3):\n # \"ratify_freeze\" waits for human approval, \"autonomous\" accepts the proposed spec without that\n # gate, and \"ratify_freeze_drift\" also enforces the configured independent drift cross-check.\n eval_trust_mode: str = \"ratify_freeze\"\n # RepoTask node seeding policy (run-wide fallback; a task/editable `seed_mode` overrides):\n # \"auto\" (default) copies git-tracked files when the editable is a git repo (so a tree bloated\n # with untracked artifacts isn't deep-copied per node), else a full copy; \"tracked\" forces\n # code-only; \"all\" forces a full recursive copy (legacy). Genesis authors per-task from the\n # user's words; this is the global default when unspecified.\n seed_mode: str = \"auto\"\n llm_model: str = \"qwen3:8b\"\n # === LLM / transport ==================================================================\n llm_base_url: str = \"http://localhost:11434/v1\" # Ollama OpenAI-compatible endpoint\n llm_temperature: float = 0.6\n # H3 per-role model presets (5090 recipe): optionally run the Researcher and Developer on\n # DIFFERENT models/endpoints (e.g. Developer=Qwen3-Coder-30B for code, a fast model for breadth).\n # Blank = use the shared llm_model/llm_base_url. Generalizes the role-backend swap to per-role.\n researcher_model: str | None = None\n developer_model: str | None = None\n researcher_base_url: str | None = None\n developer_base_url: str | None = None\n # Per-role sampling temperature (§4.1): the Researcher wants breadth (higher temp = more diverse\n # ideas), the Developer wants determinism (lower temp = fewer syntax slips), the Strategist a\n # steady hand. Each overrides the shared `llm_temperature` for that role only; None = use the\n # shared value (byte-identical to before). Flat + nullable, mirroring the per-role model fields.\n researcher_temperature: float | None = None\n developer_temperature: float | None = None\n strategist_temperature: float | None = None\n # Unified self-driving agent: one LLM identity that plays Researcher + Developer (+ Strategist)\n # across pipeline stages, choosing its own model/toolset per stage. ON by default — the agent\n # drives the loop (incl. crash triage: repair/abandon/reject_idea) and, via\n # `agent_drives_actions`, picks the next macro action within a pure legal-action gate. Set both\n # to False for the legacy byte-identical split-role behavior. (No-op unless backend=\"llm\".)\n unified_agent: bool = True\n agent_drives_actions: bool = True\n # Layer 3 Card queue ownership. False preserves the existing policy/unified-pilot action path.\n # True is pinned in run_started because changing the selector on resume would mix two search\n # treatments in one run. When both this and agent_drives_actions are true, Card selection wins.\n card_driven_selection: bool = False\n # Layer 5 bounded speculative Card buffer. Zero is a hard OFF switch and preserves the\n # historical alternating build/eval spine; positive values are pinned by run_started so a\n # resume cannot silently mix search treatments after a snapshot/default edit.\n speculation_depth: int = Field(default=0, ge=0, le=64)\n # Local evidence receipt produced by ``looplab speculation-gate``. This is deliberately absent\n # from the curated Settings UI: positive-depth execution is a calibrated rollout decision, not a\n # casual tuning knob. The Engine validates the receipt against the current scorer,\n # implementation, GPU inventory and raw paired-run evidence before it enables speculation.\n speculation_gate_receipt: str | None = Field(default=None, max_length=4096)\n # Per-stage model/endpoint overrides for the unified agent. Recognized keys (see\n # tasks.build_unified_agent): `propose` (researcher), `implement`/`repair` (developer),\n # `strategy`, `pilot`. Unlisted keys are ignored. Empty = the per-role researcher_*/developer_*\n # models, then the shared llm_model. Explicit map wins. No-op unless `unified_agent` is true.\n agent_stage_models: dict = {}\n agent_stage_base_urls: dict = {}\n llm_parser: str = \"tool_call\"\n # Reasoning/thinking toggle sent IN the request (providers differ — see `llm.reasoning_body`):\n # Qwen3 on vLLM/SGLang -> chat_template_kwargs.enable_thinking (bool)\n # OpenAI/Ollama-v1/DeepSeek -> reasoning_effort (low|medium|high|none)\n # `llm_reasoning`: \"\" = send nothing (server default — current behavior); off = actively disable;\n # on = enable at default depth; low|medium|high = enable at that effort.\n # Defaults to \"high\" so the agent reasons before proposing/repairing/triaging (better code, fewer\n # crashes); set \"\" to send nothing (server default) or \"off\" to disable. The model's chain-of-thought\n # is CAPTURED either way (reasoning_content field or inline <think>). NOTE: to get a SEPARATE\n # reasoning_content field from SGLang/vLLM, the server also needs `--reasoning-parser qwen3`.\n llm_reasoning: str = \"high\"\n llm_reasoning_style: str = \"auto\" # auto | qwen | effort | none — how to shape the request param\n llm_reasoning_extra: dict = {} # raw fields merged into the body (escape hatch, e.g. Anthropic thinking)\n # H1: drive structured calls with the endpoint's constrained decoding (vLLM/SGLang guided_json +\n # response_format json_schema) so weak models can't emit invalid JSON. Off for Ollama (default).\n llm_guided_json: bool = False\n # Stream every LLM request (SSE) and reassemble it, so the request `timeout` is an INTER-TOKEN\n # idle timeout rather than a whole-request deadline: a long-but-alive generation is never cut off\n # (tokens keep resetting the timer), while a genuinely STALLED endpoint (no token for `timeout` s)\n # is detected and retried on a fresh connection — the fix for silent multi-minute hangs. Set False\n # to use one blocking read (old per-op timeout semantics) if an endpoint streams badly.\n llm_stream: bool = True\n # LLM IDLE timeout (seconds). Stream mode: the INTER-TOKEN stall limit — a steady generation is\n # never cut off, only a silent endpoint. Non-stream: bounds the whole read. Raise for endpoints\n # with a long prefill on huge prompts; lower to fail fast on a flaky shared endpoint.\n llm_timeout: float = Field(default=180.0, gt=0)\n # First-byte (response-headers) window for STREAM attempts, seconds: an SSE response sends its\n # headers on admission, so no-headers ≈ a black-holed request — fail over to a fresh connection\n # fast instead of waiting the full idle timeout. Clamped to llm_timeout. Non-stream attempts are\n # NOT bounded by this (their headers arrive only after the whole generation). The default mirrors\n # `DEFAULT_HEADER_TIMEOUT_S` in looplab/core/llm.py is the single source (imported above).\n llm_header_timeout: float = Field(default=DEFAULT_HEADER_TIMEOUT_S, gt=0)\n # Honour HTTP(S)_PROXY / NO_PROXY / SSL_CERT_FILE env vars for LLM calls. Default False: the\n # transport connects DIRECTLY (many internal/OLAP endpoints are reachable only by bypassing an\n # ambient proxy — a picked-up proxy yields \"connection refused\"). Set true when the endpoint is\n # reachable ONLY via a corporate proxy or needs a custom CA bundle from the environment.\n llm_trust_env: bool = False\n # H4/C2: compact the growing agentic tool-call history once it exceeds this many chars (auto_summary\n # LLM-summarizes the stale middle; else middle-truncate). Sized to the model's real context window so\n # files read earlier STAY in context instead of being compacted away and re-read: ~1,000,000 chars ≈\n # 250k tokens (≈4 chars/token), matching the configured deepseek-v4-flash (≥200k tokens confirmed).\n # The old 120k-char (~30k-token) default was ~8× too aggressive and drove a read-thrash loop. 0 = off.\n context_budget_chars: int = 1_000_000\n # M5: the Researcher's always-on experiments digest budget (chars). 0 = AUTO — scales with the\n # run size (60 chars/node, bounded [1200, 6000]) instead of one flat cap for an 8-node toy run\n # and a 200-node benchmark run alike. Set a positive value to pin it.\n digest_char_cap: int = Field(default=0, ge=0)\n # D11 compression model slot (open_deep_research's four-slot pattern): a dedicated CHEAP model\n # for agent-history summarization (C2 auto-summary), so compression doesn't pay the main\n # model's price. Blank = use each loop's own model (legacy). `compressor_base_url` blank =\n # the shared llm_base_url.\n compressor_model: str | None = None\n compressor_base_url: str | None = None\n # === Agentic tool-loop ================================================================\n # Agentic tool-loop limits — apply to EVERY tool-using agent (the LLM Researcher, the unified\n # agent's pilot + crash-triage, Deep-Research, the run-chat Boss, the genesis repo-scout, and the\n # cross-run report synthesizer). The loop lets the model call read-only tools across turns before\n # emitting its structured result. Both default to UNLIMITED so the agent takes as many turns / as\n # long as it needs — it is never cut off mid-reasoning; set a positive value here (or per-run, or\n # in the UI settings) to bound latency/cost. These used to be hardcoded per call site (e.g. the\n # boss at max_turns=3 / 45s), which silently dropped a slow reasoning model to a no-op reply.\n agent_max_turns: int = 0 # max tool turns before the emit is forced (0 = unlimited)\n agent_time_budget_s: float = 0.0 # wall-clock ceiling across the loop's turns (0 = no cap)\n # G · Soft convergence SAFETY NET (not a research cap). Two thresholds on the agent's tool-turn count:\n # at `agent_emit_after` the loop NUDGES the agent once (\"you've investigated enough — emit now\"); at\n # `agent_emit_force` it FORCES the emit from what was gathered. Set GENEROUSLY — a focused researcher\n # may legitimately read many files/experiments before it can propose a grounded idea; these only catch\n # a genuine runaway that keeps issuing DIFFERENT calls forever (which stuck-detection, keyed on\n # repeats, misses). NB: the old 499-read runaway (node 63) was 79% REDUNDANT re-reads caused by a\n # broken read_file — fixing read_file (pagination) is the real cure; this is just insurance. 0 = off.\n agent_emit_after: int = 300\n agent_emit_force: int = 500\n # B1 · No-progress / stuck detection — the safety net that makes \"unlimited turns\" safe. The\n # turn/time ceilings above are only BACKSTOPS; this is what actually stops a runaway loop, on the\n # cheapest signal: when the model repeats the SAME tool call (or ping-pongs between two, or keeps\n # hitting the SAME error) with no progress, the loop forces the final emit and finishes. ON by\n # default; reading DIFFERENT files or running ONE long command never trips it. (OpenHands-style.)\n agent_stuck_detection: bool = True\n agent_stuck_repeat: int = 4 # identical calls in a row that count as \"stuck\" (>=2)\n agent_stuck_alternate: int = 4 # ping-pong cycles between two calls that count as \"stuck\" (>=2)\n # C1 · Self-plan (TodoWrite-style): expose an `update_plan` tool so a long-running agent keeps its\n # OWN working TODO and re-surfaces it every `agent_plan_reinject_every` turns — keeps the goal in\n # view across a long loop so the agent \"writes its own context\" (Claude Code style). ON by default.\n agent_self_plan: bool = True\n agent_plan_reinject_every: int = 5\n # C2 · Auto-summary (ON by default): when the tool-loop history grows long, LLM-summarize the\n # stale middle instead of only middle-truncating it. The trigger is `context_budget_chars` if set,\n # else a built-in high-water mark (context_budget.DEFAULT_SUMMARY_CHARS ≈ 120k chars) so short\n # loops are untouched. Falls back to truncation on any summarizer error; one extra model call per\n # over-budget turn.\n agent_auto_summary: bool = True\n # C4 · Repo-developer plan decomposition. A big feature attempted in ONE developer session made a\n # non-converging reasoning model loop indefinitely — it kept writing + exploring without ever\n # emitting `done` (10k+ LLM calls / hours; agentic stuck-detection can't catch VARIED exploration,\n # only literal repeats). Fix: the developer first proposes an ordered plan of ATOMIC steps; a\n # multi-step plan is executed step-by-step, each in a FRESH bounded session with only that step's\n # scope + the files accumulated so far, syntax-validated per write. Every session stays\n # small-context and convergent. ON by default for repo tasks (a 1-step plan == the old single pass).\n developer_plan_decompose: bool = True\n developer_plan_min_steps: int = 2 # a proposed plan with >= this many steps runs step-by-step\n developer_plan_max_steps: int = 8 # cap on plan length (a runaway planner can't spawn 100 steps)\n # Hard per-session backstop for the repo developer — the ONE write-heavy agent that can run away\n # even with stuck-detection on (varied writes/reads never trip the repeat/ping-pong signal). Bounds\n # the plan phase, EACH step, and the single-session fallback. Unlike the global agent_* limits\n # (0 = unlimited, meant for read-only agents), the developer always gets a FINITE ceiling so a\n # model that never emits `done` fails cleanly with the code it wrote so far, instead of looping.\n developer_session_max_turns: int = 500\n developer_session_time_budget_s: float = 1200.0 # 20 min wall-clock per developer session\n # Phase-handoff summaries. Each LLM phase in a node build (Researcher·propose → Developer·stages →\n # plan → implement) ends with ONE extra LLM call that distills its transcript — the repo structure\n # it mapped, files/data confirmed, decisions made — into a brief injected into the NEXT phase (even\n # across the role boundary). The next phase TRUSTS that instead of re-reading the same files, which\n # cuts the biggest source of tool-call thrash (stages/plan/implement each re-exploring the repo).\n # One summary call per phase in exchange for many fewer read calls downstream. ON by default.\n phase_handoff_summary: bool = True\n # Observability (ADR-17): capture each LLM call's full prompt + completion into the active\n # span (spans.jsonl) so the UI can show exactly what the model read and wrote per node.\n # Diagnostics only — `replay.fold` never reads spans. Default on for local single-user; set\n # LOOPLAB_TRACE_LLM_IO=0 to suppress (e.g. to keep spans.jsonl small or avoid storing prompts).\n trace_llm_io: bool = True\n # Run-introspection tools (ON by default): make the LLM Researcher (and DeepResearcher) a\n # tool-using agent that can read its OWN experiments (list/read/find-analogous/themes/code) and\n # the task data (schema/profile/asset) mid-loop, instead of seeing only best+parent. Advisory —\n # never changes best-selection. Off = the legacy single-shot Researcher (richer digest still added).\n researcher_tools: bool = True\n # Cross-run introspection: give the agentic Researcher / pilot read-only tools to look at\n # SIBLING runs (same task_id, same run-root) — list them, read an experiment / its code, and\n # find analogous configs across runs — so a run can build on what neighbouring runs already\n # learned instead of rediscovering it. Advisory; never changes best-selection. Needs the run's\n # own dir wired through (no-op for unit-built roles). Off = the legacy single-run view only.\n cross_run_tools: bool = True\n # Read-only access to EVERY run on this machine ACROSS ALL TASKS (not just same-task siblings):\n # the Developer/Researcher get list_all_runs + read_run_code + read_run_experiment so they can read\n # the code + result of ANY past experiment anywhere and reuse an approach. Broader than\n # `cross_run_tools` (same-task only); the agent decides when a foreign run is relevant. Advisory;\n # never changes best-selection. Needs the run's own dir wired through (no-op for unit-built roles).\n all_runs_tools: bool = True\n # PART V §22 — read-only CROSS-RUN KNOWLEDGE tool for the reasoning roles (Researcher, Strategist,\n # deep-research, and a Developer-scoped variant). Adds `cross_run_prior_attempts` / `cross_run_claims`\n # / `cross_run_atlas` over the §21.20 read-models (concept overview + claim assessments + atlas), read\n # from `memory_dir` (lessons.jsonl + concept_capsules.jsonl). Read-only is a storage-authority\n # boundary, not a behavioral no-op: retrieved content can steer later proposals, and a tool-using\n # reasoning loop can consume additional model calls. The tools never mutate cross-run truth (facts\n # are engine-written, verdicts operator-ratified, §22.4) or directly select a winner. ON by explicit\n # experimental product choice, EngineOptions off; stores only exist once Part-IV features have run.\n cross_run_read_tools: bool = True\n # Agentic retrieval (ADR-16): if set, the LLM Researcher gets grep/kb_search/read\n # tools over this directory of markdown notes and chooses when to use them.\n knowledge_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"knowledge\"))\n # T4 real embeddings: model id for an OpenAI-compatible `/embeddings` endpoint used for\n # kb_search / case retrieval. Blank (default) = the dependency-free lexical `hash_embed` (today's\n # behavior). Set e.g. \"nomic-embed-text\" (Ollama) or \"text-embedding-3-small\" for SEMANTIC\n # retrieval; a misconfigured/offline endpoint degrades back to hash_embed at call time (never\n # crashes a run). `embed_base_url` blank = reuse `llm_base_url`; the shared `llm_api_key` is used.\n embed_model: str | None = None\n embed_base_url: str | None = None\n # Harmonic memory (Memora, ICML'26 — idea import). When ON, cross-run cases and knowledge notes are\n # indexed by a short ABSTRACTION + cue ANCHORS instead of their raw text, near-duplicate memories are\n # CONSOLIDATED into one entry under a matching abstraction, and `kb_search`/case retrieval EXPAND\n # through the top hits' anchors to surface related-but-not-similar memories. ON by default. Set\n # `memora=false` to restore the pre-Memora raw-text index. Never a source of truth — abstractions\n # live only in the derived, rebuildable retrieval index (never the event log or canonical cases.jsonl).\n memora: bool = True\n # Write abstractions with the wired chat model (richer than the deterministic lexical fallback). ON\n # by default; results are CACHED by content hash (see `memora_cache`) so a re-built index doesn't\n # re-call the model on unchanged notes/cases, and any endpoint failure degrades to lexical at call\n # time — so an offline box just gets lexical abstractions, never a crash. Set false to force lexical.\n memora_llm: bool = True\n # === Cross-run memory / Memora ========================================================\n memora_anchors: int = 6 # max cue anchors kept per memory\n # Cosine similarity at/above which a new case is CONSOLIDATED into an existing one (same evolving\n # topic) rather than stored as a separate entry. Only used when `memora` is on.\n memora_consolidate_threshold: float = 0.86\n # Where the LLM-abstraction cache lives (JSON). Blank (default) derives it from `memory_dir`\n # (`<memory_dir>/memora_cache.json`) or else `knowledge_dir` (`<knowledge_dir>/.memora_cache.json`);\n # with neither set the cache is in-memory only (per process). No-op unless `memora_llm` is on.\n memora_cache: str | None = None\n # E3 literature-grounded ideation (network-OPTIONAL): give the agentic Researcher an arXiv search\n # tool to ground ideas in real techniques. Off by default (egress is unreliable on some boxes);\n # fails gracefully if the network is blocked.\n literature_search: bool = False\n # Deep-Research stage (network-OPTIONAL): give the DeepResearcher a general web search/fetch\n # tool (DuckDuckGo) on top of arXiv to read across results + the web before steering the next\n # batch. Off by default (egress is unreliable on some boxes); fails gracefully when blocked.\n web_search: bool = False\n # Cadence for the Deep-Research stage: run it automatically every N created nodes. Zero disables\n # only the cadence; manual `deep_research` and Strategist `request_research` triggers still fire.\n # Default 3: deep research analyzes the accumulating results and steers the next batch (its\n # directions become hints + open hypotheses); paired with `concurrent_research` it overlaps the\n # think with the GPU-bound eval, so the cadence costs LLM tokens but no wall-clock on the search.\n deep_research_every: int = 3\n # Overlap a DUE deep-research \"think\" with the GPU-bound eval instead of running it in its own\n # serial step (the agent is otherwise idle while a node trains). research() computes from a state\n # snapshot; CODEX AGENT: the joined background task records only allowlisted, order-tolerant\n # research/hint/hypothesis events as soon as the memo finishes. ON by default: the LLM is typically\n # remote (no GPU contention with eval), so overlapping hides latency behind long training.\n concurrent_research: bool = True\n # Concurrent-research REPEAT — don't idle a multi-DAY eval. The overlapped \"think\" fires ONCE per\n # window by default; ON here, it RE-RUNS on an adaptive time cadence for the WHOLE eval window\n # (a two-day training must not leave the reasoning agents idle after one memo). Self-paced: it\n # only records a memo whose CONTENT is new (identical re-runs are skipped, so the log/board don't\n # bloat) and backs off geometrically as the analysis converges, capped so it always re-checks.\n # The appended records never rewrite the current metric champion, but their hints/open hypotheses\n # deliberately steer later proposals and replay reconstructs that advice. The library default is\n # one-shot (== today, byte-identical); this product default turns the repetition on.\n concurrent_research_repeat: bool = True\n # Base cadence (seconds) between repeated concurrent-research passes. Research is EXPENSIVE\n # (multi-turn LLM + web/arXiv), so this is a FLOOR, not a ceiling: the effective cadence is\n # max(this, ~5% of the per-experiment time budget) — a two-day eval is re-researched about hourly,\n # a short eval not at all (the first tick outlasts it). Only matters when the repeat is on.\n concurrent_research_interval_s: float = Field(default=1800.0, gt=0)\n # Per-eval-window backstop on repeated-research LLM calls (0 = cadence-only, no cap). The adaptive\n # cadence + convergence backoff are the primary budget control; this bounds a pathological\n # never-converging window. Past it the loop stops calling the LLM (the health monitor still runs).\n concurrent_research_max_calls: int = Field(default=40, ge=0)\n # Concurrent hypothesis-board CONSOLIDATION during the eval window. Repeated research keeps ADDING\n # near-duplicate directions to the open board (each memo registers up to 5 as open hypotheses), so a\n # long eval's board bloats before the between-nodes merge runs. This dedups/merges it on the SAME\n # background loop so the board stays clean for the next proposal. In Card-driven mode the same merge\n # changes ownership/readiness, so it is deferred to the joined main-task cadence instead. Needs the\n # reflect client + `track_hypotheses`.\n # Only active while `concurrent_research` runs the overlap loop. Library default off (== today).\n concurrent_consolidate: bool = True\n # Cadence for the agent-authored run report: regenerate the conclusion-first narrative every N\n # created nodes (0 = off; it still regenerates on a manual `report_refresh` from the UI). The\n # deterministic report always renders from the node set regardless of this knob.\n report_every: int = 3\n # Agent Skills (I18, ADR-9): dir of SKILL.md the Researcher can list/load as tools.\n skills_dir: str | None = None\n # Prompt store (I18, ADR-8): dir of editable, hot-reloaded role prompt .md files.\n prompt_dir: str | None = None\n # API key as a reference, never serialized as a value (ADR-11). Local servers\n # ignore it; default to a placeholder.\n llm_api_key: SecretStr | None = None\n\n @model_validator(mode=\"before\")\n @classmethod\n def _apply_profile(cls, data):\n \"\"\"Expand `profile` into a bundle of setting defaults BEFORE validation.\n\n pydantic-settings hands this validator the fully-merged input from ALL explicit sources\n (init kwargs + LOOPLAB_* env + .env), with fields the user left unset simply ABSENT (they\n fall to field defaults afterward). So a profile value is applied only when its key is not\n already present — i.e. any explicit file/CLI/env setting wins over the profile, and the\n profile wins over the bare field default. This makes `thorough` a true convenience preset,\n never an override of what the user asked for.\"\"\"\n if not isinstance(data, dict):\n return data\n prof = data.get(\"profile\", \"default\")\n # env may deliver the key as \"profile\" (env_prefix stripped) — normalize to a str\n prof = str(prof).strip().lower() if prof is not None else \"default\"\n overrides = PROFILES.get(prof)\n if overrides is None:\n raise ValueError(f\"unknown profile {prof!r}; known: {sorted(PROFILES)}\")\n for key, val in overrides.items():\n if key not in data: # explicit source (file/CLI/env) always wins\n data[key] = val\n return data\n\n @model_validator(mode=\"after\")\n def _check_trust_gate(self):\n if self.trust_gate not in (\"audit\", \"gate\", \"block\"):\n raise ValueError(\n f\"trust_gate must be audit|gate|block, got {self.trust_gate!r}\")\n if self.merge_mode not in (\"auto\", \"mean\", \"ensemble\"):\n raise ValueError(\n f\"merge_mode must be auto|mean|ensemble, got {self.merge_mode!r}\")\n # novelty_mode drives the dedup/novelty gate; an out-of-set value (e.g. a mis-cased\n # LOOPLAB_NOVELTY_MODE=LLM, or \"on\") used to fall through _apply_novelty_gate silently as a\n # NO-OP — turning the gate off with no diagnostic. Fail loudly at config time like trust_gate/\n # merge_mode do. (Other enum-ish string fields — seed_mode/eval_trust_mode/strategist_backend —\n # share the pattern; left unvalidated here only because their full value sets are less settled.)\n if self.novelty_mode not in (\"off\", \"algo\", \"llm\"):\n raise ValueError(\n f\"novelty_mode must be off|algo|llm, got {self.novelty_mode!r}\")\n # The remaining enum-ish string fields (arch-review §5 P3): a typo used to be accepted at\n # construction and only fail-safe/later-loud downstream (e.g. a mis-cased strategist_backend\n # silently ran the default). Fail loudly here like the fields above.\n if self.strategist_backend not in (\"off\", \"rule\", \"llm\", \"agent\"):\n raise ValueError(\n f\"strategist_backend must be off|rule|llm|agent, got {self.strategist_backend!r}\")\n if self.eval_trust_mode not in (\"ratify_freeze\", \"autonomous\", \"ratify_freeze_drift\"):\n raise ValueError(\n \"eval_trust_mode must be ratify_freeze|autonomous|ratify_freeze_drift, \"\n f\"got {self.eval_trust_mode!r}\")\n if self.seed_mode not in (\"auto\", \"tracked\", \"all\"):\n raise ValueError(f\"seed_mode must be auto|tracked|all, got {self.seed_mode!r}\")\n return self\n\n def masked_snapshot(self) -> dict:\n # Snapshots are a JSON contract (they are written verbatim to config.snapshot.json), not a\n # Python-object dump. In particular tuple-valued schema fields such as\n # ``inline_repair_reasons`` must become JSON arrays before callers hash the runtime envelope;\n # otherwise the strict speculation-scope canonicalizer sees a different/non-JSON preimage\n # from the one that ``json.dumps`` eventually persists.\n d = self.model_dump(mode=\"json\")\n if self.llm_api_key is not None:\n d[\"llm_api_key\"] = \"***\"\n else:\n d[\"llm_api_key\"] = None\n return d\n\n\n# Pre-versioned snapshots are full Settings dumps, so absence is a reliable per-field version marker.\n# Keep their historical effective behavior when newer product defaults become active: re-entry must not\n# silently add paid calls, interventions, concurrency or a different selection policy to an old run.\nLEGACY_CONFIG_SNAPSHOT_DEFAULTS: dict[str, object] = {\n \"parallel_build\": 1,\n \"eval_parallel\": None,\n \"llm_parallel\": None,\n \"train_monitor\": False,\n \"train_monitor_interval_s\": 600.0,\n \"train_monitor_kill\": False,\n \"train_monitor_kill_confidence\": 0.8,\n \"asha_live\": False,\n \"asha_live_kill\": False,\n \"asha_live_quantile\": 0.5,\n \"asha_live_min_siblings\": 3,\n # Before this operator ceiling existed, the sandbox's 24-hour process limit was authoritative.\n \"max_eval_timeout\": 24 * 3600.0,\n \"watchdog_reflection\": False,\n \"card_driven_selection\": False,\n \"speculation_depth\": 0,\n \"speculation_gate_receipt\": None,\n \"concurrent_research_repeat\": False,\n \"concurrent_research_interval_s\": 1800.0,\n # Zero means unlimited; 40 was the first bounded default, and is latent while repeat is disabled.\n \"concurrent_research_max_calls\": 40,\n \"concurrent_consolidate\": False,\n}\n\n\ndef migrate_config_snapshot(data: dict) -> dict:\n \"\"\"Return an effective copy of one legacy/current snapshot without rewriting evidence bytes.\"\"\"\n if not isinstance(data, dict):\n raise TypeError(\"config snapshot must be a JSON object\")\n migrated = dict(data)\n for key, value in LEGACY_CONFIG_SNAPSHOT_DEFAULTS.items():\n migrated.setdefault(key, value)\n return migrated\n\n\ndef settings_from_snapshot(data: dict) -> Settings:\n \"\"\"Build re-entry Settings from a masked snapshot under its historical missing-field contract.\"\"\"\n migrated = migrate_config_snapshot(data)\n migrated.pop(\"llm_api_key\", None)\n return Settings(**migrated)\n"}, "86a6407b9cdac9489ac1d26aaed244f8b373f44a": {"t": 1785768784.72555, "n": 95910, "h": "fc584db36fcbf3b1", "c": "\"\"\"Engine config: the schema (what settings exist). (I0, ADR-11)\n\nOne pydantic-settings class declaring every engine knob, its default, and its docs. The loader —\nhow a run file / CLI / env becomes a `Settings` — is `looplab.core.appconfig`, which also owns\nthe config-precedence order. A resolved, secret-masked snapshot is written next to each run for\nreproducibility. (No real secrets in P0, but the masking discipline is in place.)\n\"\"\"\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nfrom pydantic import Field, SecretStr, field_validator, model_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n# Single source of the LLM first-byte (response-headers) default — see core/llm.py.\nfrom looplab.core.llm import DEFAULT_HEADER_TIMEOUT_S\n\n# Default home for cross-run memory + the knowledge base (shared across every run). Both are ON by\n# default now (a user asked for it) so a fresh install accumulates + consults knowledge with no setup;\n# set the env var to \"\" to disable, or to a path to relocate. `~/.looplab/` keeps them out of any one\n# project + shared across projects.\n_LL_HOME = Path.home() / \".looplab\"\n\n# Autonomous roles that may be granted per-setting write access (see Settings.agent_control).\n# strategist = A7 meta-controller (run-wide); boss = run-chat operator-proxy (run-wide);\n# researcher = per-experiment proposer (per-node sizing, e.g. eval timeout for a heavy model).\nAGENT_ROLES = (\"strategist\", \"boss\", \"researcher\")\n\n# A foresight score is computed for every acted-on proposal. Keep this lower than the generic\n# verifier's emergency ceiling so a typo cannot multiply paid synchronous calls across a run.\nMAX_FORESIGHT_VERIFY_SAMPLES = 8\n\n\n# Layer-2 parallelism renamed two public settings without removing the durable legacy fields. Keep\n# the equivalence in one place: config-source precedence, governance migration, and live strategy\n# application must all agree which names describe the same authority/value.\nPARALLELISM_ALIASES: dict[str, str] = {\n \"max_parallel\": \"eval_parallel\",\n \"parallel_build\": \"llm_parallel\",\n}\n\n\ndef parallelism_aliases(setting: str) -> tuple[str, ...]:\n \"\"\"Return every spelling in ``setting``'s alias family, canonical name first.\"\"\"\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if setting in (legacy, canonical):\n return canonical, legacy\n return (setting,)\n\n\ndef canonicalize_parallelism_source(values: dict) -> dict:\n \"\"\"Promote legacy parallel keys *within one config-precedence source*.\n\n Applying this only after file/CLI/env were flattened would let a lower-priority canonical env\n value shadow a higher-priority legacy file value (or a file canonical value shadow a CLI legacy\n override), so callers with sub-layers normalize each layer independently.\n \"\"\"\n if not isinstance(values, dict):\n return values\n out = dict(values)\n for legacy, canonical in PARALLELISM_ALIASES.items():\n # CODEX AGENT: None is the durable legacy mode for llm_parallel, not a missing value. A normal\n # snapshot contains llm_parallel:null plus parallel_build:1; reloading it here promotes null to\n # 1 and unexpectedly enables the global one-call broker. Promote aliases only when the\n # canonical key is absent, and pin snapshot round-trip parity.\n if canonical not in out and out.get(legacy) is not None:\n # CODEX AGENT: canonicalize inside each precedence layer, never across the flattened\n # result. The alias migration must not invert CLI > file > env precedence.\n out[canonical] = out[legacy]\n return out\n\n# Settings whose semantics are committed by ``run_started`` and restored from the folded event log\n# on every re-entry. Changing one only in ``config.snapshot.json`` would mix incompatible evidence\n# (a different holdout split) or make the live producer disagree with replay's selection policy.\n# The HTTP config editor and the engine both consume this one contract; keep deliberately mutable\n# controls such as ``trust_gate`` out (that field has its own durable change event).\nRUN_START_PINNED_FIELDS = frozenset({\n \"card_driven_selection\",\n \"speculation_depth\",\n \"holdout_fraction\",\n \"holdout_select\",\n \"select_verifier\",\n \"select_verifier_samples\",\n \"verifier_ci_tie\",\n})\n\n\ndef run_start_pinned_settings(state) -> dict:\n \"\"\"Return the effective Settings names committed by a folded run-start record.\n\n Pre-pin legacy logs have no recorded holdout fraction, so their snapshot remains authoritative\n for both holdout knobs. Verifier fields, however, have always been re-pinned from the legacy-safe\n fold defaults whenever a valid ``run_started`` identity exists.\n \"\"\"\n if not getattr(state, \"run_id\", \"\"):\n return {}\n values = {\n # Layer 3 changes which queue owns macro-action selection. Once a run starts, replay and\n # resume must keep the same owner even if the snapshot or ambient default changes later.\n \"card_driven_selection\": bool(getattr(state, \"card_driven_selection\", False)),\n # Layer 5 overlaps Card production with evaluation only when this pinned treatment is\n # positive. Old logs omit it and therefore retain the exact serial build/eval spine.\n \"speculation_depth\": int(getattr(state, \"speculation_depth\", 0)),\n \"select_verifier\": bool(getattr(state, \"select_verifier_tiebreak\", False)),\n \"select_verifier_samples\": int(getattr(state, \"select_verifier_samples\", 3)),\n \"verifier_ci_tie\": bool(getattr(state, \"verifier_ci_tie\", False)),\n }\n holdout_fraction = getattr(state, \"holdout_fraction\", None)\n if holdout_fraction is not None:\n values.update({\n \"holdout_fraction\": holdout_fraction,\n \"holdout_select\": bool(getattr(state, \"holdout_select\", False)),\n })\n if not values.keys() <= RUN_START_PINNED_FIELDS:\n raise RuntimeError(\"folded run-start pinned settings contract drifted\")\n return values\n\n# --- Run profiles (config-first presets) -------------------------------------------------------\n# A `profile` is a NAMED BUNDLE of setting defaults — nothing more. It only fills fields the user\n# did NOT set explicitly (via file / --set / LOOPLAB_* env / init-kwargs), so any explicit knob\n# always wins (see `_apply_profile`). Most engine intelligence lives behind individual flags. The\n# PART IV/V cross-run + concept-graph machinery (concept_pivot, graded_novelty, cross_run_concepts /\n# _advisory / _structured_claims / _curation / _read_tools, fingerprint_universal, foresight_verify)\n# now ships ON in the product `Settings` default. That is an explicit experimental product choice,\n# not a claim that a frozen A/B or workload-wide cost/quality gate has cleared: the bundle can steer\n# prompts and candidate generation, change graded-novelty admission / pre-execution choices, and add\n# paid LLM work when its client and memory prerequisites are present. The bare-library `EngineOptions`\n# default stays lean (see tests/test_options_divergence.py — product side is the aggressive one) so a\n# toy `Engine(...)` in a test doesn't fire cross-run LLM work unasked. The trust/confirm/ablate QUALITY\n# bundle still ships OFF and is turned on by `thorough`. Every value here is reachable by hand — the\n# profile is a convenience, never a hidden mode.\n# CODEX AGENT: keep this rationale behavioral and evidence-based; do not relabel read-only storage\n# access or proposal-only governance as a no-op for agent behavior, cost, or proposal admission.\n#\n# `thorough` deliberately touches only QUALITY/TRUST machinery, not spend: it does NOT raise\n# max_nodes / eval_parallel (those are cost knobs the user owns). It enables multi-seed confirmation\n# (demote seed-lucky leaders), the reward-hack / leakage / critic monitors AND flips them from\n# advisory to *gating* (`trust_gate=\"gate\"` — a\n# flagged win can no longer be selected as best), ablation-driven refinement, and the prompt cues\n# (complexity / budget / failure-reflection / watchdog-reflection). Agentic novelty and the experimental\n# Part IV/V bundle are already product defaults, not profile changes. Effect is large; marginal cost is a\n# few extra evals.\nPROFILES: dict[str, dict] = {\n \"default\": {}, # explicit no-op alias for \"ship-as-is\" (== omitting profile)\n \"fast\": {}, # today's lean defaults, named for symmetry with `thorough`\n \"thorough\": {\n \"confirm_top_k\": 3,\n \"confirm_seeds\": 3,\n # novelty_gate intentionally NOT enabled here: duplicate-detection is the agentic Researcher's\n # job (it reads past experiments + find_analogous_across_runs and DECIDES), not an algorithmic\n # param-distance / embedding auto-reject that mis-fired (it nudged a good fresh idea onto a\n # twice-dead lineage — live node 61). Search stays as tools; the LLM judges novelty.\n \"operator_bandit\": True, # P4: adaptive operator mix over folded yields (GreedyTree)\n \"reward_hack_detect\": True,\n \"code_leakage_detect\": True,\n \"critic_check\": True,\n \"trust_gate\": \"gate\", # detectors stop gating a flagged node from WINNING (not just audit)\n \"ablate_every\": 3,\n \"complexity_cue\": True,\n \"budget_aware\": True,\n \"failure_reflection\": True,\n \"watchdog_reflection\": True, # feed recent live-watchdog (train-monitor/ASHA) flags to proposals\n \"reflection_priors\": True, # no-op unless memory_dir is set (cross-run priors)\n },\n}\n\n\n# The shipped agent-governance matrix (who may change which setting at runtime). Defined as a module\n# constant so the Engine's direct-construction default matches this shipped default exactly (the\n# EngineOptions \"Engine() == shipped defaults\" invariant) instead of diverging to an all-locked map.\nDEFAULT_AGENT_CONTROL: dict[str, list[str]] = {\n \"timeout\": [\"researcher\", \"strategist\"],\n # Layer-2 canonical names are the presented/default governance surface. `_agent_may` falls back\n # to an old snapshot's legacy grant only when its canonical counterpart is absent, so removing\n # duplicate defaults preserves resume authority while an explicit canonical revocation still wins.\n \"eval_parallel\": [\"strategist\"],\n \"llm_parallel\": [\"strategist\"],\n # Strategy-only pseudo-setting: the closed per-lane allocation inside the canonical LLM total.\n # It is not a launch Settings field; operators pin it through `set_strategy` and the Strategist\n # may reallocate it only while this grant is present.\n \"llm_lane_limits\": [\"strategist\"],\n \"max_nodes\": [\"strategist\", \"boss\"],\n \"max_eval_seconds\": [\"strategist\", \"boss\"],\n \"policy\": [\"strategist\"],\n # The policy NAME and its PARAMS are gated independently in `_apply_strategy`; grant both by default\n # so the Strategist that may switch to (say) MCTS may also apply the `c`/`eta` it decided — else its\n # tuned params are silently dropped and the recorded `active_strategy` diverges from the live engine.\n \"policy_params\": [\"strategist\"],\n \"n_seeds\": [\"strategist\"],\n \"ablate_every\": [\"strategist\"],\n \"merge_mode\": [\"strategist\"],\n \"complexity_cue\": [\"strategist\"],\n \"ablate_code_blocks\": [\"strategist\"],\n \"prefer_sweep\": [\"strategist\"],\n \"novelty_stance\": [\"strategist\"],\n \"developer\": [\"strategist\"],\n \"fidelity\": [\"strategist\", \"researcher\"],\n}\n\n\ndef default_agent_control() -> dict[str, list[str]]:\n \"\"\"A FRESH deep-ish copy of the shipped governance matrix (each role list copied so a caller can\n mutate its own map without touching the module constant). The SINGLE constructor of the default,\n called by both `Settings.agent_control`'s default_factory AND the Engine's direct-construction\n default — so \"Engine() == shipped defaults\" can't drift if the copy depth ever needs to change\n (e.g. nested values requiring copy.deepcopy), instead of two verbatim copy expressions.\"\"\"\n return {k: list(v) for k, v in DEFAULT_AGENT_CONTROL.items()}\n\n\nclass Settings(BaseSettings):\n \"\"\"The engine settings schema (every knob a run accepts).\n\n Timeout family — six distinct knobs, each owned by a different subsystem:\n - `timeout`: per-eval wall-clock budget for ONE experiment's evaluation (engine/eval).\n - `max_eval_timeout`: hard ceiling for a governed per-node Researcher timeout override.\n - `llm_timeout`: LLM request idle timeout — inter-token stall limit in stream mode\n (core.llm OpenAICompatibleClient).\n - `llm_header_timeout`: LLM first-byte (response-headers) window for stream attempts (core.llm).\n - `agent_time_budget_s`: wall-clock ceiling across a tool-using agent loop's turns (agents).\n - `dep_install_timeout`: per-package budget for auto-installing a missing dep (env self-prep).\n\n Config-source precedence is owned by `looplab.core.appconfig` (the loader) — see its module\n docstring for the one canonical order.\n \"\"\"\n # Config precedence: see appconfig.py — the loader owns the full order. The `.env` is read\n # so `looplab run`/`looplab ui` pick up LOOPLAB_LLM_BASE_URL etc. without exporting them by hand\n # (keys MUST carry the LOOPLAB_ prefix, same as the env vars). utf-8-sig tolerates a BOM from\n # Windows editors. The test suite disables this (tests/conftest.py) so a dev's real .env in the\n # repo root can't leak into assertions.\n model_config = SettingsConfigDict(\n env_prefix=\"LOOPLAB_\",\n env_file=\".env\",\n env_file_encoding=\"utf-8-sig\",\n extra=\"ignore\",\n allow_inf_nan=False,\n )\n\n @classmethod\n def settings_customise_sources(\n cls, settings_cls, init_settings, env_settings, dotenv_settings,\n file_secret_settings):\n \"\"\"Preserve source precedence across canonical/legacy parallelism names.\n\n Pydantic ranks init > env > dotenv correctly, but it ranks field names rather than semantic\n alias families. Promoting a legacy value inside each source before the merge makes an old\n explicit snapshot/file value beat a lower-priority canonical env value. ``appconfig`` also\n promotes its file/typed/--set sub-layers before combining them into the init source.\n \"\"\"\n def _with_parallel_aliases(source):\n return lambda: canonicalize_parallelism_source(source())\n\n return (\n _with_parallel_aliases(init_settings),\n _with_parallel_aliases(env_settings),\n _with_parallel_aliases(dotenv_settings),\n _with_parallel_aliases(file_secret_settings),\n )\n\n # Run profile (config-first preset, see PROFILES above). \"default\"/\"fast\" = today's lean\n # defaults; \"thorough\" turns the built quality/trust machinery ON in one word. A profile only\n # fills fields the user did NOT set — every knob it touches stays individually overridable.\n profile: str = \"default\"\n\n # Bounds (review C/⚪): a non-positive node/seed budget or timeout is never valid. Upper bounds are\n # equally load-bearing — the UI /start path writes these straight into the engine's env, and an\n # unbounded `eval_parallel` (or legacy `max_parallel`; e.g. 100000 from a crafted preflight) would\n # fan out that many sandboxes → resource exhaustion. Reject at config time, not mid-run. Ceilings\n # are generous; tests top out near 50.\n n_seeds: int = Field(default=3, ge=1, le=1024)\n max_nodes: int = Field(default=8, ge=1, le=1_000_000)\n # CODEX AGENT: legacy evaluation-width input. `eval_parallel` below is canonical and wins when set;\n # keep this field for old env/config/snapshots and direct Engine call sites. A width > 1 admits\n # concurrent evals, whose resource reservations may be CPU-only or span one/more GPUs — there is no\n # one-node/one-GPU promise. `max_parallel=0` is launch-time AUTO (detected GPU count, at least 1).\n max_parallel: int = Field(default=1, ge=0, le=1024)\n # CODEX AGENT: legacy build-width input. `llm_parallel` below is canonical and also controls the\n # shared provider-call total when positive. A width > 1 uses fresh pooled researcher/developer pairs;\n # it says nothing about the later eval's CPU/GPU reservation. Startup `0` derives build fan-out from\n # resolved eval width; a live Strategist/operator zero settles to 1. Without role_factory it clamps to 1.\n parallel_build: int = Field(default=1, ge=0, le=64)\n # Hypothesis-card Kanban re-architecture (docs/23, Layer 2). Two INDEPENDENT concurrency axes,\n # decoupled from each other so cost (LLM work) can be tuned separately from throughput (GPU evals).\n # These are the CANONICAL names going forward; `max_parallel`/`parallel_build` above are kept as thin\n # back-compat aliases (env vars / snapshots / ~100 Engine(...) call sites depend on the exact names,\n # so they cannot be renamed — CLAUDE.md). Resolution (orchestrator.__init__): a NEW name, when set,\n # WINS over its legacy alias; None => fall back to the legacy field => byte-identical to today.\n # `eval_parallel`: concurrent EVALS (the GPU/experiment consumer). None -> max_parallel; 0 = AUTO\n # (one experiment per detected GPU). This is the in-Run eval-concurrency ceiling; the resource\n # scheduler bin-packs declared footprints separately. Sibling local GPU-owning Runs serialize\n # through the conservative host-pool lease; external execution needs its own admission boundary.\n # `llm_parallel`: total concurrent LLM provider calls + BUILD fan-out. None -> legacy\n # parallel_build semantics without a shared total; 0 = startup AUTO (= resolved eval_parallel\n # for build fan-out, historical research overlap unbounded). A positive canonical value enables\n # the named-lane broker the Strategist can reallocate at cadence.\n eval_parallel: int | None = Field(default=None, ge=0, le=1024)\n llm_parallel: int | None = Field(default=None, ge=0, le=64)\n # Training-log monitor (I-series watchdog family): a per-eval background observer that tails the live\n # training log while a (often multi-hour) declared stage runs in a worker thread. ON in the product\n # surface (Settings). Its alert event is fold-ignored and cannot directly change lifecycle/champion;\n # CODEX AGENT: with watchdog_reflection on, that alert is nevertheless advice in a later Researcher\n # prompt and can change future proposals. It only fires on the command-eval path with an LLM client;\n # bare-library EngineOptions stays OFF. See test_options_divergence.\n # `_interval_s` is the BASE tick cadence (the effective one adapts to the per-experiment budget).\n train_monitor: bool = True\n train_monitor_interval_s: float = Field(default=600.0, gt=0)\n # Phase 3 — INTERVENTION (opt-in, separate from observation): let the monitor tree-kill a training the\n # LLM judges 'broken' (diverged / silent CPU fallback / not learning) EARLY, instead of burning the\n # whole budget. Off by default — the observer only WATCHES unless this is on. A kill only fires on a\n # 'broken' verdict (a plateau is 'watch', never killed) with confidence >= the threshold; the node then\n # fails normally (reason='monitor_broken'), so replay reconstructs it from that one terminal event.\n train_monitor_kill: bool = False\n train_monitor_kill_confidence: float = Field(default=0.8, ge=0.0, le=1.0)\n # ASHA live-curve watchdog (sibling of the training monitor): reads the latest INTERMEDIATE value of\n # the objective metric off the live log (reusing the eval's OWN metric reader). Finished-endpoint rank\n # remains advisory. An opt-in KILL additionally requires an operator-declared metric.resource_key and\n # enough sibling observations at exactly that resource; absent comparable evidence cannot stop a run.\n # Records a fold-ignored `asha_rank` diagnostic + trace span; the library default is OFF (off == today).\n asha_live: bool = True\n # Opt-in INTERVENTION (separate from the advisory signal): let the watchdog tree-kill a node whose\n # intermediate metric stays below the SAME-RESOURCE bar past a short grace window. Off by default —\n # it only surfaces endpoint rank unless this is on. A kill fails normally (asha_underperforming).\n asha_live_kill: bool = False\n # The bar sits at `asha_live_quantile` along a WORST->BEST ordering of the finished siblings' finals:\n # 0.5 = the median (a node worse than the median peer flags); a SMALLER value is more conservative —\n # it lowers the bar toward the WORST finished peer, so only a node worse than nearly all peers flags\n # (0.0 = worse than the worst). `min_siblings` is the floor of finished peers required before it ranks\n # at all, so it never acts on too little evidence.\n asha_live_quantile: float = Field(default=0.5, ge=0.0, le=1.0)\n asha_live_min_siblings: int = Field(default=3, ge=1)\n timeout: float = Field(default=30.0, gt=0)\n # Hard ceiling for the Researcher's governed per-node `Idea.eval_timeout`. One hour preserves the\n # existing heavy/neural-net authoring example while staying well below the sandbox's defensive\n # 24-hour subprocess ceiling. This is operator-owned run config: agents may request less or more,\n # but the accepted action is always clamped here after the `agent_control.timeout` permission gate.\n max_eval_timeout: float = Field(default=3600.0, gt=0, le=24 * 3600.0)\n # Intra-node sweep: a sweep node runs a whole grid in one process, so it gets this multiple of\n # `timeout` as its wall-clock budget (solution.py path; RepoTasks use their per-profile timeout).\n sweep_timeout_mult: float = Field(default=8.0, ge=1)\n # Sandbox tier (ADR-13): \"trusted_local\" (subprocess, no Docker) for the CLI;\n # \"untrusted\" (Docker --network none, shared-kernel runtime) for hosted/multi-tenant UI;\n # \"hostile\" (untrusted + a true-isolation OCI runtime, gVisor `runsc` by default / Kata) for\n # running untrusted code. See runtime/sandbox.py::make_sandbox for the tier selection.\n trust_mode: str = \"trusted_local\"\n # Image for the untrusted command-eval tier (RepoTask, Phase 4): the framework's deps\n # should be baked in (the container runs --network none, so a pip setup can't fetch).\n docker_image: str = \"python:3.12-slim\"\n # Resource caps for the untrusted/hostile Docker tier (passed to `docker run --memory/--cpus`).\n # The default 4g bound protects other tenants from an OOM, but a model-training MLE-bench eval\n # routinely needs more — raise it (e.g. \"16g\") for such runs. \"\" disables the cap (unbounded).\n # Ignored by the trusted_local (subprocess) tier.\n sandbox_memory: str = \"4g\"\n sandbox_cpus: str = \"\"\n # Best-effort host-OOM guard for the TRUSTED-LOCAL (subprocess) tier. When set (e.g. \"8g\"), each\n # eval child gets an RLIMIT_AS address-space cap so a runaway trainer hits MemoryError instead of\n # OOM-killing the whole host (and the engine). POSIX only; a no-op on Windows. Default \"\" = OFF:\n # RLIMIT_AS bounds VIRTUAL memory, and CUDA/torch reserve tens of GB of virtual without using it,\n # so a default cap would spuriously kill GPU evals — leave it off for those and use the Docker\n # tier's real --memory cgroup bound (sandbox_memory) instead. Ideal for CPU/numpy/sklearn runs.\n sandbox_memory_local: str = \"\"\n # Best-effort disk-fill guard for the TRUSTED-LOCAL tier (P1-5): an RLIMIT_FSIZE cap on the size of\n # any single file an eval child writes (e.g. \"2g\"), so a runaway that writes an unbounded file gets\n # SIGXFSZ instead of filling the host disk. POSIX only. Default \"\" = OFF (large model checkpoints\n # need big files); set it for tasks that write only small artifacts.\n sandbox_fsize_local: str = \"\"\n # Search policy (ADR-2): \"greedy\" | \"evolutionary\" | \"mcts\" | \"asha\".\n policy: str = \"greedy\"\n # Ablation-driven refinement (I7): every N improves, ablate the best to find the\n # highest-impact parameter and refine it. 0 = off. (GreedyTree only.)\n ablate_every: int = 0\n # A0a (MLE-STAR): when ablating, treat each generated pipeline *code block* (not just a\n # numeric param) as an ablation unit — neutralize a block, measure the metric delta, refine\n # the highest-impact block. Off => the classic numeric-param ablation. Config-first knob.\n ablate_code_blocks: bool = False\n # A0b: real merge/ensembling. \"mean\" = legacy mean-param merge; \"ensemble\" = the Developer\n # writes a code-recombination ensemble over the two parents' solutions (verified: agent-proposed\n # ensembling 37.9%->43.9%). \"auto\" (default) = ensemble whenever the Developer actually\n # generates code (LLM/agent backends; declared via `is_code_generating`), mean for the\n # templated/toy developers where a code ensemble is meaningless — the evidence says code\n # recombination is the strongest single merge (removing it costs ~9 pp), so it is the default\n # wherever it can work. Falls back to mean when the Developer can't ensemble.\n merge_mode: str = \"auto\"\n # A0d (AIRA): inject a dynamic complexity hint into the draft/improve prompt keyed on the\n # node's child count (few children -> keep minimal; many -> escalate to ensembling/HPO).\n complexity_cue: bool = False\n # I1 (CAAFE-style) LLM feature-engineering: instruct the proposer/developer to add\n # semantically-meaningful engineered features (ratios, interactions, aggregations) as code. The\n # existing cross-validation eval + best-selection is the CV GATE — a feature set that doesn't\n # improve CV is never selected (feature engineering is non-universal, so this gate is mandatory).\n # Tabular tasks (those exposing column data). Off by default.\n feature_engineering: bool = False\n # A5: surface the REMAINING eval-compute budget into the proposal prompt so the Researcher can\n # reason over how much compute is left (explore broad while flush, exploit/cheapen when low).\n # No-op unless a max_eval_seconds budget is set.\n budget_aware: bool = False\n # A4 (LATS-style): feed a summary of the most recent FAILED branches (operator + error reason)\n # back into the proposal prompt so the proposer reflects on and avoids repeating them. ON by\n # default: it is SELECTIVE by construction (injects only when recent failures exist — the\n # evidenced form of reflection; reflecting at every step shows no gains, arXiv:2506.12928).\n failure_reflection: bool = True\n # Feed the LIVE-WATCHDOG observations (train-monitor health verdicts + ASHA intermediate-rank flags)\n # of recent experiments back into the proposal prompt, so the Researcher avoids re-proposing a config\n # whose TRAINING was already observed to be weak — even when the watchdog kills are off (default) and\n # the node ran to completion (those diagnostics are fold-ignored, so failure_reflection never sees\n # them). ON by default and SELECTIVE like failure_reflection (injects only when a recent flag exists).\n watchdog_reflection: bool = True\n # C3 deep test-driven repair: hand the Developer the failure taxonomy + a structured \"reproduce\n # then fix\" directive on debug, not just the raw stderr tail. ON by default (product surface); the\n # conservative library default in EngineOptions stays off, per that module's contract.\n deep_repair: bool = True\n # === Inline crash repair ==============================================================\n # Hybrid in-node crash repair: when an LLM-generated node CRASHES at runtime (mechanical errors\n # — bad import, removed kwarg, typo), the agent triages it and may repair the code IN PLACE within\n # the same eval (cheap; does NOT consume max_nodes and does NOT add a node to the search tree).\n # Semantic failures still flow to a new idea/debug node via the policy. ON by default; set False\n # to restore the prior behavior (every crash waits for a budgeted inter-node debug node).\n inline_repair: bool = True\n # Max in-place repair attempts per node before it fails normally. Default 0 = UNLIMITED: the same\n # agent keeps fixing + re-evaluating IN THE SAME node until it produces a clean metric — no new\n # node just to retry. Runaway is prevented not by a count but by the anti-stuck guard\n # (`inline_repair_stuck_repeat`): when the SAME error signature repeats with no progress, or the\n # agent's crash-triage says \"abandon\", the node fails. Set a positive N to cap the retries instead.\n inline_repair_attempts: int = Field(default=0, ge=0)\n # Anti-stuck: abandon in-node repair when the SAME error recurs this many times in a row (no\n # progress). Keeps \"unlimited\" repair from looping forever on an unfixable error.\n inline_repair_stuck_repeat: int = Field(default=4, ge=2)\n # Which failure reasons (_failure_reason: crash|timeout|oom|setup|no_metric|drift) are eligible for\n # inline repair. Default: mechanical crashes, timeouts AND OOM-kills — a timeout/OOM means the code\n # was too slow / too memory-hungry for the budget (or the pod's cgroup limit), not that the idea is\n # wrong, so the agent repairs it by reducing compute/memory (fewer estimators/epochs/folds/seeds, a\n # lighter model, a smaller batch, subsampling) instead of letting the node die unrecovered. Env\n # override expects a JSON array.\n inline_repair_reasons: tuple[str, ...] = (\"crash\", \"timeout\", \"oom\")\n # Cost ceiling for in-node repair of a MULTI-STAGE eval: the max number of FULL pipeline re-runs\n # (i.e. re-trains from the first stage) the inline-repair loop may do before abandoning to the\n # inter-node debug operator. A late-stage failure (a broken `score`/eval script that didn't touch\n # `train`) REUSES the completed train checkpoint and re-runs only from the failed stage — that is\n # CHEAP and NOT counted here; only a repair that changes an EARLIER stage's code (train.py/loss.py/…)\n # forces a full re-train, which each costs the whole training time. Without this bound, a repair that\n # keeps changing training code could burn many full trains (the anti-stuck guard is error-signature\n # based, not cost based). 0 = unlimited (legacy behavior). Single-command evals ignore it.\n inline_repair_retrain_cap: int = Field(default=2, ge=0)\n # Environment self-prep: when a solution crashes purely because a KNOWN library isn't installed\n # (ModuleNotFoundError), the engine pip-installs it into the eval interpreter and re-runs — so a\n # torch/XGBoost/CatBoost idea (e.g. a GRU model) actually runs on a fresh box instead of being\n # thrown away as `idea_rejected`. Trusted_local tier only (the untrusted/hostile Docker tiers run\n # --network none). A typo'd or local helper module is NOT installed (that's a code bug to repair).\n auto_install_deps: bool = True\n # Per-package wall-clock budget for an auto-install (seconds). Generous: large wheels like torch\n # can take minutes to download/build.\n dep_install_timeout: float = Field(default=900.0, gt=0)\n # --- Agent governance: who may change a setting at runtime (per-setting allow-list of roles) ---\n # Roles: \"strategist\" (A7 meta-controller; run-wide), \"boss\" (run-chat operator-proxy; run-wide),\n # \"researcher\" (per-experiment proposer; PER-NODE — e.g. set a longer eval timeout for a neural-net\n # idea). A setting ABSENT from this map is LOCKED — no agent may change it, only a human via the\n # snapshot/UI. The sole conditional exception is Strategy-only `card_scoring`: Card mode grants\n # it implicitly without changing the default flag-off snapshot, while an explicit [] revokes it.\n # The gate is ENFORCED at every AGENT seam via `_agent_may` — the strategist's whole\n # control surface (policy/operators/novelty_stance/prefer_sweep/developer/fidelity/timeout/\n # eval_parallel/llm_parallel) is checked in `_apply_strategy`, so removing a role from a knob locks it,\n # not just greys out the UI pill. The boss/max_* grants document boss-relevant knobs, but a\n # `budget_extend` is a HUMAN control intent (the boss action-builder can only emit `add_nodes`), so\n # it is applied as-is — the human always wins via the UI/snapshot. Defaults are conservative: resource/\n # search-shape knobs the agents already reason about; provider infra (llm_model/base_url/api_key,\n # docker_image) stays locked. The UI shows S/B/R pills per setting. `timeout` granted to the\n # researcher IS the \"auto\" per-node mode — the researcher sizes each experiment's wall-clock\n # (Idea.eval_timeout); the config `timeout` is the fallback for nodes it doesn't size. Env override\n # expects a JSON object. (`fidelity`/`novelty_stance`/`prefer_sweep`/`developer` are governance keys\n # for the strategist's per-node dials — not 1:1 Settings fields, but gated the same way.) The default\n # lives in the module-level DEFAULT_AGENT_CONTROL so the Engine's direct-construction default matches.\n agent_control: dict[str, list[str]] = Field(default_factory=default_agent_control)\n # C1 (Agentless localization): for repo tasks, rank the source files most relevant to the most\n # recent failure (traceback + identifiers) and surface them in the proposal/repair prompt so the\n # Developer edits the right place. Off by default; repo tasks only.\n localize_faults: bool = False\n # A2 surrogate-guided proposal (BO-lite): fit a k-NN surrogate over (params->metric) and propose\n # by acquisition instead of random/hill-climb. Numeric-bounds tasks only; bootstraps via the\n # base Researcher. Off by default. `surrogate_explore` = UCB-style exploration weight.\n surrogate_proposer: bool = False\n surrogate_explore: float = 0.1\n # E2 researcher panel: generate K candidate ideas per proposal and keep the one ranked best by a\n # cheap empirical surrogate over the (params->metric) history (NOT an LLM-judge). 1 = off.\n researcher_panel: int = 1\n # A1 ASHA / successive-halving: reduction factor (keep top 1/eta per rung) and rung budget.\n asha_eta: int = Field(default=3, ge=2)\n # Rung-0 width for ASHA/BOHB (the wide base of cheap drafts). 0 = use n_seeds (default,\n # preserving prior behavior); set >0 to seed a wider/narrower base independent of n_seeds.\n asha_rung_nodes: int = Field(default=0, ge=0)\n # A6 proxy/predictive scoring: cheaply rank a candidate's potential from early-stage signals\n # and skip a full eval for the doomed bottom fraction. 0.0 = off (no candidate skipped).\n proxy_scoring: bool = False\n proxy_kill_fraction: float = Field(default=0.0, ge=0.0, le=0.9)\n # === Novelty / dedup ==================================================================\n # E1 novelty/dedup gate: reject near-duplicate proposals (normalized param-space distance below\n # `novelty_epsilon`) by deterministically nudging them off the duplicate — stops the search\n # wasting evals re-trying the same idea. Audit event `novelty_rejected`. Off by default.\n # Novelty/dedup MODE selector: how a fresh proposal is checked against already-tried experiments.\n # \"off\" — no explicit gate; the agentic Researcher's own read-the-history judgment is trusted.\n # \"algo\" — the deterministic gate: numeric param-distance (`novelty_epsilon`) + optional embedding\n # similarity (`novelty_semantic`). Cheap, no LLM call, but can't explain itself.\n # \"llm\" — an LLM adjudicates (reads the real experiments via tools) whether the idea is a\n # near-duplicate and, if so, asks the Researcher once more for something different. One\n # extra LLM call per proposal — highest quality, follows the \"let the LLM decide\" line.\n novelty_mode: str = \"llm\"\n # Legacy sub-toggles, honored by the \"algo\" mode (and back-compat: novelty_gate=True forces \"algo\"):\n novelty_gate: bool = False\n novelty_epsilon: float = 0.05\n # T5 semantic novelty (active whenever the deterministic gate runs — novelty_mode=algo,\n # novelty_gate=true which is the legacy alias for algo, OR the Strategist novelty stance is\n # \"explore\"; a no-op only under novelty_mode=llm/off with a non-explore stance): reject a proposal\n # whose idea TEXT embeds within\n # `novelty_semantic_threshold` cosine of an existing node's. OFF BY DEFAULT: novelty is the agentic\n # Researcher's call, made by READING the actual prior experiments (read_experiment /\n # find_analogous_across_runs / list_experiments), NOT an embedding-similarity auto-reject that can't\n # explain itself and mis-fires on paraphrases. Semantic/param search stays available as TOOLS in the\n # Researcher's context; the LLM decides whether an idea is a true duplicate.\n novelty_semantic: bool = False\n novelty_semantic_threshold: float = Field(default=0.92, ge=0.5, le=1.0)\n # T10: debug-lineage depth bound for every policy — how many error-feedback repairs a failing\n # lineage gets before it is abandoned. The old default (1) abandoned lineages after ONE repair;\n # multi-turn/deeper debugging is a verified lever (AIRA2 ReAct debug +5.5 percentile points).\n debug_depth: int = Field(default=2, ge=1)\n # P4 operator bandit (GreedyTree): replace the fixed merge/ablate cadences with a\n # deterministic UCB over per-operator yield (Δmetric per eval-second, folded from the run).\n # Off by default (the cadences are well-tested and the bandit has no direct published\n # ablation); `thorough` turns it on.\n operator_bandit: bool = False\n # P1 hypothesis ledger: ask the Researcher to state the one-line hypothesis each experiment tests,\n # register deep-research directions as hypotheses, and track them to a verdict on the board. ON by\n # default. CODEX AGENT: the board is shown to the Researcher and can be foresight-ordered, so it can\n # steer later proposals even though it never directly re-ranks already-evaluated nodes. Set False\n # to drop the prompt nudge + registration.\n track_hypotheses: bool = True\n # E4/M2/M3 reflection-memory -> priors (gradient-free cross-run meta-learning): at run end distill\n # the winner into `<memory_dir>/meta_notes.jsonl` AND structured lessons (incl. NEGATIVE results:\n # tested/abandoned hypotheses + failure themes) into `lessons.jsonl` with a task fingerprint; at run\n # start, inject exact-task notes + fingerprint-matched lessons from SIMILAR tasks into the proposal\n # prompt. ON by default — but a NO-OP until `memory_dir` is set (that's where cross-run memory lives).\n reflection_priors: bool = True\n # M6 comparative lessons (MARS \"comparative reflective memory\", doc 13 §7 item 2): distill\n # credit-assigned lessons from PAIRS of solutions — which SPECIFIC change made a child beat\n # (or regress from) its parent, and what fixed a failure — on top of the one-shot run-end\n # reflection. At most one extra LLM call per distillation (all pairs batched); offline a\n # deterministic param-diff credit stands in. Gated on reflection_priors + memory_dir like the\n # rest of cross-run memory — and since memory_dir has a default (~/.looplab/memory), this is\n # ACTIVE out of the box; clear memory_dir to disable cross-run memory wholesale.\n comparative_lessons: bool = True\n # M6 mid-run cadences (doc 13 §7 item 5 — the AgentRxiv live-share pattern): every N created\n # nodes, WRITE comparative lessons to the shared cross-run store during the run (lessons_every\n # — one batched LLM call per firing) and RE-READ the store so lessons distilled by CONCURRENT\n # runs reach this run's proposals (lessons_refresh_every; file re-read, no LLM call, skipped\n # when the store is unchanged). ON by default (every 4 nodes) and, like comparative_lessons\n # above, live out of the box because memory_dir defaults on; 0 = off — run-end write /\n # run-start read only, the pre-M6 behavior.\n lessons_every: int = Field(default=4, ge=0)\n lessons_refresh_every: int = Field(default=4, ge=0)\n # B3 output redaction: mask credentials (known key shapes + high-entropy tokens) in the\n # stdout/stderr tail before it is persisted to the event log / spans / UI — a leaked secret in a\n # print()/traceback must not land in the durable log. Off by default; recommend on for untrusted.\n redact_output: bool = False\n # B5 reward-hacking detector: a host-side monitor that flags suspicious wins (grader/answer-key\n # access, runtime writes to frozen files, suspiciously-perfect metrics) as a `reward_hack_suspected`\n # audit event in the Trust panel. Off by default. Whether a flag CHANGES selection is governed by\n # `trust_gate` below (default: audit-only, never changes selection).\n reward_hack_detect: bool = False\n # Trust enforcement (T2): what a reward-hack / data-leakage flag actually DOES to selection.\n # \"audit\" (default) = surface it in the Trust panel, never change selection (today's behavior);\n # \"gate\" = a flagged node can no longer be selected as BEST **and isn't bred/confirmed from**\n # (breed_excluded — see replay._apply_trust_gate / models.breedable_nodes), but stays\n # FEASIBLE so it still counts for diversity/audit — closes the \"a hacked/leaky node can\n # win OR seed its lineage\" hole;\n # \"block\" = additionally mark it fully INFEASIBLE (removed from feasible_nodes entirely).\n # Deliberately gates only high-precision CHEATING/LEAKAGE signals. Broad `critic:*` findings stay\n # advisory; CODEX AGENT: `critic:hardcoded_metric` is the one narrow high-precision critic exception\n # and gates because it requires a literal metric with no computed assignment anywhere.\n trust_gate: str = \"audit\"\n # I3 data-centric: static code-leakage scan of each evaluated solution (fit-before-split,\n # fit-on-test) surfaced into the Trust panel alongside reward-hack flags. Off by default.\n code_leakage_detect: bool = False\n # 4.4 sandbox instrumentation (needs reward_hack_detect): after each eval, flag a RUNTIME write\n # to a protected/frozen file (an answer key or grader tampered mid-run) — behavioral evidence a\n # static code scan misses. ON by default; only fires when the reward-hack detector is on.\n workdir_audit: bool = True\n # D8 decoupled research Verifier: check every Deep-Research memo's CLAIMS against their cited\n # evidence (node ids / urls) before recording — synthesis is the documented weak link (Kosmos\n # 57.9% accurate). Deterministic layer always; an LLM rubric pass when the DeepResearcher has a\n # client. The verdict cannot change this run's node/champion, but finalize uses aligned supported\n # verdicts as the evidence gate for persisted D8 cross-run claims; unavailable/misaligned stays\n # unverified. ON by default (no-op with no memo/claims).\n research_verify: bool = True\n # C4 independent critic: an execution-free critic of each solution (stub / hardcoded-metric /\n # params-ignored; on host-graded tasks the metric checks become a submission-output check)\n # surfaced in the Trust panel. Broad findings are advisory; `critic:hardcoded_metric` can gate under\n # trust_gate=gate/block. Off by default.\n critic_check: bool = False\n # A7 Strategist (NEW, user-requested): optional LLM/rule meta-controller that picks the search\n # policy/allocator + operator mix + fidelity (+ Developer backend) per situation. Config-first:\n # every choice the Strategist makes is also a direct knob. Defaults to \"agent\" so the agent adapts\n # the search policy/operators/fidelity per situation; set \"off\" for static config-driven search.\n # \"agent\" = a tool-using Strategist that READS the run/data/sibling-runs/KB/memory (B1-guarded)\n # before deciding, instead of the single-shot \"llm\" call over aggregate stats.\n strategist_backend: str = \"agent\" # \"off\" | \"rule\" | \"llm\" | \"agent\" — default AGENTIC: the\n # Strategist READS the run/data/sibling-runs/KB/memory with tools before deciding, not a single-shot\n # call over aggregate stats. \"llm\" = the old non-agentic single-shot; \"rule\"/\"off\" = no LLM.\n strategist_every: int = Field(default=3, ge=1) # consult cadence (created nodes)\n # PART V (F1): concept CLASSIFIER re-tag + consolidation cadence, DECOUPLED from strategist_every. The\n # LLM concept map is heavier and slower-moving than a strategy consult, so it refreshes on its own\n # (sparser) interval. Researcher-authored idea.concepts still fold immediately at node_created — this\n # only paces the classifier-evidence + consolidation refresh (and the concept-coverage pivot snapshot).\n concept_retag_every: int = Field(default=30, ge=1)\n # === Confirmation & holdout ===========================================================\n # Multi-seed confirmation (I12, ADR-15): confirm the top-k under N seeds before\n # finishing. 0 disables (default). Only meaningful when eval has variance.\n confirm_top_k: int = 0\n confirm_seeds: int = 0\n # D1 seed-holdout discipline: the FIRST seed the confirm phase uses. Search evals run with the\n # implicit seed 0 (LOOPLAB_EVAL_SEED unset -> \"0\"), so a base of 1 keeps every confirm split\n # DISJOINT from anything the search optimized against — the confirm metric is then a\n # generalization signal, not a re-measurement (AIRA: selecting on a signal the search saw\n # overfits by 9-13 pp). Set 0 to restore the legacy overlapping seeds 0..N-1.\n confirm_seed_base: int = Field(default=1, ge=0)\n # D1 holdout-gated promotion (B6, Arbor-style): for host-graded tasks, reserve this fraction of\n # the held-out labels as a FINAL holdout partition the search never sees — every search/confirm\n # eval is scored on the remaining rows only, and at finish the val-top-k are re-scored on the\n # holdout partition (free: the predictions already exist). 0.0 = off (legacy full-label scoring).\n holdout_fraction: float = Field(default=0.25, ge=0.0, le=0.9)\n # Champion selection prefers the HOLDOUT metric among the nodes that have one (the val-top-k).\n # Recorded in run_started so replay applies the same rule; old logs fold to False (legacy pick).\n # The per-node val-holdout `generalization_gap` folds into the Trust panel either way.\n holdout_select: bool = True\n # How many val-leaders get a holdout evaluation at finish (host-graded tasks).\n holdout_top_k: int = Field(default=3, ge=1)\n # R1-c / Part IV — calibrated §12-verifier tie-break in best-selection. When a fresh node's robust\n # metric EXACTLY TIES an already-eligible node, the engine runs the §12 verifier (grounded on each\n # node's realized result, `selection_criteria`) and records a soundness score per node. The tie-break\n # then applies across the WHOLE selection path: the mean pick breaks a robust_metric tie by the score,\n # and — when `holdout_select` is on — the holdout-slot ranking prefers the sounder node so it isn't\n # denied a holdout eval, and the final holdout override breaks a holdout_metric tie by it too. Strictly\n # a TIE-BREAK — it can NEVER move a node ahead of a strictly-better metric (§21.7 advisory-never-\n # overrides). Best-effort: verification is LAZY (only fires on an actual tie) and bounded per cadence,\n # so a large/late tie stays on deterministic metric+id order unless one atomic treatment scores every\n # member. Recorded in run_started so\n # replay applies the same rule; old logs fold to False (legacy pick). Opt-in (default off); needs a\n # reachable LLM client. Enable only after validating calibration offline with `verifier.calibrate`.\n # See trust/verifier.py + core/fitness.py.\n select_verifier: bool = False\n # R1-d (§21.19): widen the verifier tie-break from EXACT-metric to a conservative STATISTICAL tie.\n # The band is capped by both the metric leader's SE and the pooled SE-of-difference, so challenger\n # variance cannot widen it and a significant difference is never a tie (§21.7). Off -> exact only.\n verifier_ci_tie: bool = False\n # Verifier sample count for `select_verifier` (the §12 repeated-sampling expectation). 3 tames the\n # single-shot variance; 1 = single-shot (cheaper, noisier).\n select_verifier_samples: int = Field(default=3, ge=1, le=32)\n # Budget (I13): hard wall-clock ceiling; the run aborts cleanly when exceeded.\n max_seconds: float | None = None\n # Eval-compute budget (#2): hard ceiling on cumulative time spent INSIDE evals (training\n # runs), separate from wall-clock. Survives resume (summed from the event log). The real\n # guard against a silent multi-hour sweep when an eval is a minutes-hours training run.\n max_eval_seconds: float | None = None\n # Cross-run memory (I19, ADR-10): if set, the best result of each run is stored as\n # a case here, and the cases become retrievable knowledge for future runs.\n memory_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"memory\"))\n # HITL (I21, ADR-11): pause for human approval of the final best before finishing.\n require_approval: bool = False\n # Diversity archive (I22): niche bucket width in parameter space.\n archive_resolution: float = 1.0\n # Coverage read-model (narrowing signal): at the strategist cadence, compute a breadth summary\n # (themes / param-niches / theme entropy / dominant-theme fraction) from the folded run, record\n # it as a `coverage_snapshot` audit event, and surface it into the Strategist's decision context.\n # Deterministic, cheap, and purely additive context — no search-behavior change on its own. On by\n # default (like the rest of the always-on situational context). See search/coverage.py.\n coverage_context: bool = True\n # PART IV Phase 2a live steering (§21.11/§21.13). When on, the `concept_retag_every` cadence (NOT\n # `strategist_every` — the producer gates on `_should_consult_concepts`, which uses the seed boundary\n # + `concept_retag_every`, default 30) records a\n # concept-graph coverage + uncovered-region snapshot (deterministic heuristic tagger over the\n # task-type skeleton) and, on an `explore` stance, the Researcher's novelty hint names the exact\n # uncovered regions (\"0 coverage in {negatives/external-mining, distillation} — go there\") instead\n # of the vague \"broaden\". ON by default in the product Settings (ce4a379), EngineOptions off: it\n # only enriches an already-mode-gated explore hint, never forces exploration, and no-ops for a task\n # with no curated concept skeleton. The snapshot is auditable, but its prompt cue can change future\n # candidate generation; it does not directly rewrite best-metric selection. See search/concept_graph.py.\n # DESIGN NOTE (2026-07-17 critique): now ON unconditionally, but a toy / non-ML task yields EMPTY concept\n # tags while still paying the per-node LLM concept cost (~10 calls/node with the full Part IV/V bundle).\n # Consider AUTO-QUIESCING: if the heuristic tagger returns empty for the first N nodes (no concept signal),\n # skip the LLM concept work for the run — keeps it on for ML tasks, free on toy/degenerate ones.\n concept_pivot: bool = True\n # PART IV Phase 2b — D3 graded novelty into the LIVE novelty gate (§21.4/§21.13). When on and the\n # task has a curated concept skeleton, the novelty gate first grades a proposal over the concept\n # graph: a level-4 \"same direction, DIFFERENT implementation\" or a level-5 \"re-opens a\n # wrongly-abandoned FAILED direction\" proposal is ALLOWED through unchanged — the flat LLM/semantic\n # dedup gate can't tell \"this DCL tweak\" from \"the whole DCL branch\" and would wrongly reject a\n # legitimate variant or never re-open a sound-but-killed direction (the node_63 archetype). Levels\n # 1/2/3 (identical / near-dup / prior-run) still fall through to the existing gate. Deterministic\n # (heuristic tagger, no LLM), audit event `novelty_graded`, replay-safe. ON by default in the product\n # Settings (ce4a379), EngineOptions off; no-ops for a task with no skeleton. See search/graded_novelty.py.\n graded_novelty: bool = True\n # PART IV Phase 2b — D7 capability-expansion forced-jump DIRECTIVE (§21.8/§21.13, issue #7). When on\n # and the concept-graph cadence detects action-space LOCK-IN (the search has stayed inside one D5\n # branch for a long consecutive streak) on an `explore` stance, the Researcher's novelty hint\n # ESCALATES from \"broaden\" to \"expand the ACTION SPACE — build the missing infra (external mining,\n # a new data pipeline, a different eval), do NOT swap another {locked} lever.\" Prompt-cue only, on\n # top of the already-mode-gated explore hint; never adds a scored policy action and never touches\n # selection (the SCORED capability-expansion operator waits on the R1/SearchFitness gates, §21.13).\n # Reads the 2a concept-coverage snapshot, so it needs `concept_pivot` to record one. OPT-IN\n # (default off). See engine/proposal_cues.py + search/lock_in.py.\n capability_expansion: bool = False\n # PART IV cross-run Step 0 (§21.20.12/§21.20.13). Universal task-fingerprint tokenization: drop the\n # ASCII-only `[a-z0-9]` allowlist on goal keywords so a non-Latin goal (Russian, CJK, …) is NOT\n # silently dropped from its cross-run fingerprint (today such a goal keys only on kind/dir/metric and\n # never reaches SIMILAR-task priors/lessons/cases). Uses `[^\\W_]+`/`.casefold()` — same splitting as\n # before, any script. ON by default in the product Settings (ce4a379); the bare-library EngineOptions\n # default stays off, so a run pinned to it won't silently re-key a portfolio mid-flight. See engine/memory.py.\n fingerprint_universal: bool = True\n # PART IV cross-run Step 2 (§21.20). Fill graded-novelty's `prior_concepts` from the cross-run\n # ConceptCapsuleStore: at run end write a per-run concept capsule (the shipped `node_concepts` tags +\n # best outcome, keyed by task_fingerprint) to `memory_dir`; when a later SIMILAR run proposes an idea\n # whose concept was tried before, SURFACE the prior outcome as a `cross_run_prior` audit event (never\n # reject — D3 level 3). Audit-only, off the selection path; ON by default in the product Settings\n # (ce4a379), EngineOptions off. Needs a `memory_dir` to share capsules.\n # See engine/memory.py (ConceptCapsuleStore) + engine/novelty.py.\n # PREREQUISITES (CODEX — this flag is NOT standalone): the WRITE needs per-run concept tags\n # (`node_concepts`, produced by `concept_pivot`/F1 tagging) — no tags => no capsule; the SURFACE path\n # runs inside the graded-novelty precheck, so it also needs `graded_novelty` on. With only this flag +\n # `memory_dir` it persists/surfaces nothing. Enable `concept_pivot` + `graded_novelty` alongside it.\n # A non-Latin (Russian/CJK) portfolio should ALSO set `fingerprint_universal`, else the capsule's goal\n # tokens are dropped and its fingerprint over-matches on kind/dir/metric alone (the novelty direction\n # gate narrows this, but goal-word similarity is still lost).\n cross_run_concepts: bool = True\n # PART V (B): run-base + node-DELTA concept authoring. When on, once the first evaluated node has\n # authored concepts the engine seeds RunState.run_base_concepts from them (a one-shot EV_RUN_CONCEPTS,\n # idempotent because it only fires while the base is empty), and the Researcher's proposal hint tells it\n # to author only `concepts_added`/`concepts_removed` vs the run base + its parent — minimal per-node\n # annotations that inherit down the DAG (a swap propagates). Off -> every node authors its full concept\n # set as before. Product-on; the bare EngineOptions mirror defaults off (test_options_divergence).\n concept_run_base: bool = True\n # PART IV cross-run Step 5 advisory (§21.20.5). Fold the bounded cross-run CONTEXT PACK — evidence-\n # grounded claims with BOTH support and counter-evidence (Step 4) + a portfolio-coverage line (Step 3) —\n # into the Researcher's proposal prompt, exactly like the E4 cross-run prior note. Advisory ONLY: it is\n # prompt-grounding: it can change the Researcher's later candidates, but does not directly rewrite\n # best-metric selection (§21.7). Reads `memory_dir` (lessons.jsonl + concept_capsules.jsonl); \"\" when\n # empty. ON by explicit experimental product choice; no frozen A/B validation is claimed. The\n # bare-library EngineOptions default stays off (engine/options.py, frozen in\n # test_options_divergence). See engine/proposal_cues.py.\n cross_run_advisory: bool = True\n # PART IV cross-run §21.20.13 (full CR of the lean fuzzy claim merge). Switch the claim read-model to the\n # SCOPE+POLARITY-safe STRUCTURED claim key (engine/claim_key.py): claims from different tasks never\n # merge, opposite polarity (\"X helps\" vs \"X never helps\") is surfaced as a CONTRADICTION instead of being\n # collapsed, paraphrase/inflection variants group by exact structured key (no transitive over-merge), and\n # operator governance is scope-precise (a decision in task A cannot reach a same-worded claim in task B).\n # Affects the `cross_run_advisory` context pack only; ON by default in the product Settings (ce4a379);\n # the bare-library EngineOptions default stays off (engine/options.py). See engine/claims.py.\n cross_run_structured_claims: bool = True\n # PART IV cross-run §22.4 (AGENTIC taxonomy steward). At finalize, when an LLM client is available, let\n # the concept steward (engine/concept_steward.py) review the freshly-updated portfolio concept graph and\n # PROPOSE a curation — merge duplicate slugs / split conflated ones / purge noise. Proposals are LOGGED to\n # `concept_curation_log.jsonl` for operator ratification (via the serve/CLI governance surface); the LLM\n # never mutates fold state. The calls run synchronously during finalize, so they add latency and may add\n # paid inference; a caught steward failure does not prevent terminal completion or trigger a retry.\n # ON by explicit experimental product choice when `memory_dir` + an LLM backend are present; the\n # bare-library EngineOptions default stays off (engine/options.py). See engine/lessons.py.\n cross_run_curation: bool = True\n # Deprecated compatibility flag. Steward output is untrusted and finalize is proposal-only regardless\n # of this value; retained so old snapshots still validate and logs can disclose that auto was requested.\n cross_run_curation_auto: bool = False\n # Role backend (ADR-7/14): \"toy\" (offline optimizer) | \"llm\" (live model).\n backend: str = \"toy\"\n # Developer backend (ADR-7): \"default\" (templated/LLM from the task) or an external\n # CLI coding agent: \"opencode\" | \"aider\" | \"goose\" | \"continue\".\n developer_backend: str = \"default\"\n # C2 best-of-N: generate N candidate implementations per node and keep the best by an\n # execution-free reward (static validity + metric-print). 1 = off. In-house LLM developer only\n # (not external agents — ADR-7 cost rule). The top SWE-bench reliability lever for weak models.\n best_of_n: int = 1\n # D10 list-wise best-of-N selection: when the top static-scorers TIE, break the tie with a\n # comparative LLM selection over the candidates presented together (+~3 pts vs pointwise on\n # GAIA, arXiv:2506.12928). The static score stays the primary filter — the LLM is a weak\n # comparative prior, never the sole oracle. ON by default (no-op when best_of_n==1).\n best_of_n_listwise: bool = True\n # === Foresight / predict-before-execute ===============================================\n # FOREAGENT predict-before-execute (arXiv:2601.05930): use the LLM as an IMPLICIT WORLD MODEL to\n # predict — WITHOUT executing — which candidate / hypothesis will score best, primed with a\n # Verified Data Analysis Report (data profile / brief) + the accumulated experiment memory. Master\n # switch, ON by default; a no-op wherever there is nothing to rank (best_of_n==1 and\n # foresight_panel==1) or no LLM client. See `looplab/search/foresight.py`.\n foresight: bool = True\n # Hypothesis foresight breadth: generate K candidate ideas per proposal and keep the one PREDICTED\n # best pre-execution by the world model — it ranks the STRUCTURAL / text ideas the numeric k-NN\n # surrogate (researcher_panel) is blind to, primed with the data profile + memory (the synergy).\n # >1 enables it (LLM backend only; needs `foresight` on); 2 = on by default at modest cost, 1 = off.\n foresight_panel: int = 2\n # AGENTIC foresight: run the ranking (hypothesis-board prioritization + K-idea pick) as a TOOL-USING\n # loop that can pull actual experiment results / data facts before deciding, instead of a one-shot\n # prediction from a pre-baked report. ON by default (needs foresight + a client; falls back to the\n # one-shot predictor on any hiccup). A few extra LLM calls per proposal; set False for the cheaper\n # single-call ranker.\n foresight_agentic: bool = True\n # §1 foresight confidence gate: the minimum predicted confidence at which a predict-before-execute\n # pick is ACTED ON. Below it the ranker abstains — the K-idea panel falls back to the first\n # proposal, best-of-N falls through to the D10 tie-break — instead of committing a low-confidence\n # choice. 0.0 (default) = OFF (act on every pick, the historical behavior); raise toward ~0.5 to\n # make the world model defer when it isn't sure. Pairs with the foresight track record (§1) the\n # predictor is now primed with. Range 0.0-1.0.\n foresight_min_confidence: float = 0.0\n # PART IV Phase 2c — replace the world model's SELF-REPORTED confidence (measured Pearson≈0 with the\n # realized outcome, §21.12) with a CALIBRATED §12-verifier score. When on and a client is available,\n # after the K-idea ranker picks the predicted-best candidate the §12 verifier scores it (grounded +\n # repeated + criteria-decomposed: `foresight_criteria` — likely to improve the objective, and\n # sound/feasible) and THAT calibrated score becomes the confidence the `foresight_min_confidence` gate\n # and the telemetry use. Degrades to the self-reported confidence without a client or on any verifier\n # error (the telemetry records which source was used). ON by default in the product Settings (ce4a379),\n # EngineOptions off; a few extra LLM calls per acted-on proposal. See looplab/trust/verifier.py + search/foresight.py.\n # REVISIT (cost/quality, not yet resolved): a live classification run measured ~10 LLM calls & ~3.8 min/node\n # on deepseek-v4-flash with the full Part IV/V default bundle; the calibrated verifier is part of that per-node\n # tax. Kept ON deliberately — it raises decision quality (self-reported foresight confidence has ≈0 Pearson\n # with realized outcome), and lowering quality to save calls is the wrong trade. TODO: run a frozen quality/cost\n # A/B (verifier-on vs -off) to quantify the recall@objective gain per extra call before considering a default flip.\n foresight_verify: bool = True\n # Verifier sample count for `foresight_verify` (the §12 repeated-sampling expectation on a no-logprob\n # backend). 3 tames the single-shot variance §21.12 measured; 1 = single-shot (cheaper, noisier).\n # RESUME-critical: this value is persisted in config.snapshot.json, so a hard `le` schema bound would\n # make an old run started with a larger LOOPLAB_FORESIGHT_VERIFY_SAMPLES un-resumable (Settings(**snapshot)\n # would raise on `looplab resume` / finalize-recovery / serve replay). CLAMP into [1, MAX] via a validator\n # instead of rejecting — the runtime cap is preserved without breaking the snapshots/env-compat rule.\n foresight_verify_samples: int = 3\n\n @field_validator(\"foresight_verify_samples\")\n @classmethod\n def _clamp_foresight_verify_samples(cls, v: int) -> int:\n # Coerce a persisted/env value into [1, MAX] rather than reject (see field note above).\n try:\n v = int(v)\n except (TypeError, ValueError):\n return 3\n return max(1, min(v, MAX_FORESIGHT_VERIFY_SAMPLES))\n\n # T7 LLM response cache: serve an identical DETERMINISTIC (temperature 0) request from an\n # in-process content-addressed cache instead of re-hitting the model — cuts cost on\n # retry/panel/verify flows. NEVER caches sampling calls (temp>0: best-of-N / panel / novelty\n # re-propose must vary). Off by default (most role calls run at temperature>0 anyway).\n llm_cache: bool = False\n agent_cmd: str | None = None # override the agent's launcher (path / wrapper)\n # External-agent validation (ADR-7): wrap a CLI-agent Developer with a validator that\n # audits each output (no-op/syntax/crash/timeout), retries with feedback, and falls\n # back to the in-house LLM Developer. Off -> use the raw agent output unchecked.\n validate_agent: bool = True\n agent_max_retries: int = 1 # re-prompts of the agent on an invalid result\n # Patch-gated multi-file agent (ADR-7 Rule 3): run the CLI agent in a git worktree and\n # accept only edits whose paths match `agent_surface` (reject-not-strip out-of-surface\n # touches). Lets the agent create helper modules, not just solution.py. Degrades to\n # whole-file readback if git is unavailable.\n agent_patch_gate: bool = True\n agent_surface: list[str] = [\"*.py\"] # edit-surface allow-list (globs)\n # Trust policy for an agent-authored eval/metric adapter (RepoTask onboarding, Phase 3):\n # \"ratify_freeze\" waits for human approval, \"autonomous\" accepts the proposed spec without that\n # gate, and \"ratify_freeze_drift\" also enforces the configured independent drift cross-check.\n eval_trust_mode: str = \"ratify_freeze\"\n # RepoTask node seeding policy (run-wide fallback; a task/editable `seed_mode` overrides):\n # \"auto\" (default) copies git-tracked files when the editable is a git repo (so a tree bloated\n # with untracked artifacts isn't deep-copied per node), else a full copy; \"tracked\" forces\n # code-only; \"all\" forces a full recursive copy (legacy). Genesis authors per-task from the\n # user's words; this is the global default when unspecified.\n seed_mode: str = \"auto\"\n llm_model: str = \"qwen3:8b\"\n # === LLM / transport ==================================================================\n llm_base_url: str = \"http://localhost:11434/v1\" # Ollama OpenAI-compatible endpoint\n llm_temperature: float = 0.6\n # H3 per-role model presets (5090 recipe): optionally run the Researcher and Developer on\n # DIFFERENT models/endpoints (e.g. Developer=Qwen3-Coder-30B for code, a fast model for breadth).\n # Blank = use the shared llm_model/llm_base_url. Generalizes the role-backend swap to per-role.\n researcher_model: str | None = None\n developer_model: str | None = None\n researcher_base_url: str | None = None\n developer_base_url: str | None = None\n # Per-role sampling temperature (§4.1): the Researcher wants breadth (higher temp = more diverse\n # ideas), the Developer wants determinism (lower temp = fewer syntax slips), the Strategist a\n # steady hand. Each overrides the shared `llm_temperature` for that role only; None = use the\n # shared value (byte-identical to before). Flat + nullable, mirroring the per-role model fields.\n researcher_temperature: float | None = None\n developer_temperature: float | None = None\n strategist_temperature: float | None = None\n # Unified self-driving agent: one LLM identity that plays Researcher + Developer (+ Strategist)\n # across pipeline stages, choosing its own model/toolset per stage. ON by default — the agent\n # drives the loop (incl. crash triage: repair/abandon/reject_idea) and, via\n # `agent_drives_actions`, picks the next macro action within a pure legal-action gate. Set both\n # to False for the legacy byte-identical split-role behavior. (No-op unless backend=\"llm\".)\n unified_agent: bool = True\n agent_drives_actions: bool = True\n # Layer 3 Card queue ownership. False preserves the existing policy/unified-pilot action path.\n # True is pinned in run_started because changing the selector on resume would mix two search\n # treatments in one run. When both this and agent_drives_actions are true, Card selection wins.\n card_driven_selection: bool = False\n # Layer 5 bounded speculative Card buffer. Zero is a hard OFF switch and preserves the\n # historical alternating build/eval spine; positive values are pinned by run_started so a\n # resume cannot silently mix search treatments after a snapshot/default edit.\n speculation_depth: int = Field(default=0, ge=0, le=64)\n # Local evidence receipt produced by ``looplab speculation-gate``. This is deliberately absent\n # from the curated Settings UI: positive-depth execution is a calibrated rollout decision, not a\n # casual tuning knob. The Engine validates the receipt against the current scorer,\n # implementation, GPU inventory and raw paired-run evidence before it enables speculation.\n speculation_gate_receipt: str | None = Field(default=None, max_length=4096)\n # Per-stage model/endpoint overrides for the unified agent. Recognized keys (see\n # tasks.build_unified_agent): `propose` (researcher), `implement`/`repair` (developer),\n # `strategy`, `pilot`. Unlisted keys are ignored. Empty = the per-role researcher_*/developer_*\n # models, then the shared llm_model. Explicit map wins. No-op unless `unified_agent` is true.\n agent_stage_models: dict = {}\n agent_stage_base_urls: dict = {}\n llm_parser: str = \"tool_call\"\n # Reasoning/thinking toggle sent IN the request (providers differ — see `llm.reasoning_body`):\n # Qwen3 on vLLM/SGLang -> chat_template_kwargs.enable_thinking (bool)\n # OpenAI/Ollama-v1/DeepSeek -> reasoning_effort (low|medium|high|none)\n # `llm_reasoning`: \"\" = send nothing (server default — current behavior); off = actively disable;\n # on = enable at default depth; low|medium|high = enable at that effort.\n # Defaults to \"high\" so the agent reasons before proposing/repairing/triaging (better code, fewer\n # crashes); set \"\" to send nothing (server default) or \"off\" to disable. The model's chain-of-thought\n # is CAPTURED either way (reasoning_content field or inline <think>). NOTE: to get a SEPARATE\n # reasoning_content field from SGLang/vLLM, the server also needs `--reasoning-parser qwen3`.\n llm_reasoning: str = \"high\"\n llm_reasoning_style: str = \"auto\" # auto | qwen | effort | none — how to shape the request param\n llm_reasoning_extra: dict = {} # raw fields merged into the body (escape hatch, e.g. Anthropic thinking)\n # H1: drive structured calls with the endpoint's constrained decoding (vLLM/SGLang guided_json +\n # response_format json_schema) so weak models can't emit invalid JSON. Off for Ollama (default).\n llm_guided_json: bool = False\n # Stream every LLM request (SSE) and reassemble it, so the request `timeout` is an INTER-TOKEN\n # idle timeout rather than a whole-request deadline: a long-but-alive generation is never cut off\n # (tokens keep resetting the timer), while a genuinely STALLED endpoint (no token for `timeout` s)\n # is detected and retried on a fresh connection — the fix for silent multi-minute hangs. Set False\n # to use one blocking read (old per-op timeout semantics) if an endpoint streams badly.\n llm_stream: bool = True\n # LLM IDLE timeout (seconds). Stream mode: the INTER-TOKEN stall limit — a steady generation is\n # never cut off, only a silent endpoint. Non-stream: bounds the whole read. Raise for endpoints\n # with a long prefill on huge prompts; lower to fail fast on a flaky shared endpoint.\n llm_timeout: float = Field(default=180.0, gt=0)\n # First-byte (response-headers) window for STREAM attempts, seconds: an SSE response sends its\n # headers on admission, so no-headers ≈ a black-holed request — fail over to a fresh connection\n # fast instead of waiting the full idle timeout. Clamped to llm_timeout. Non-stream attempts are\n # NOT bounded by this (their headers arrive only after the whole generation). The default mirrors\n # `DEFAULT_HEADER_TIMEOUT_S` in looplab/core/llm.py is the single source (imported above).\n llm_header_timeout: float = Field(default=DEFAULT_HEADER_TIMEOUT_S, gt=0)\n # Honour HTTP(S)_PROXY / NO_PROXY / SSL_CERT_FILE env vars for LLM calls. Default False: the\n # transport connects DIRECTLY (many internal/OLAP endpoints are reachable only by bypassing an\n # ambient proxy — a picked-up proxy yields \"connection refused\"). Set true when the endpoint is\n # reachable ONLY via a corporate proxy or needs a custom CA bundle from the environment.\n llm_trust_env: bool = False\n # H4/C2: compact the growing agentic tool-call history once it exceeds this many chars (auto_summary\n # LLM-summarizes the stale middle; else middle-truncate). Sized to the model's real context window so\n # files read earlier STAY in context instead of being compacted away and re-read: ~1,000,000 chars ≈\n # 250k tokens (≈4 chars/token), matching the configured deepseek-v4-flash (≥200k tokens confirmed).\n # The old 120k-char (~30k-token) default was ~8× too aggressive and drove a read-thrash loop. 0 = off.\n context_budget_chars: int = 1_000_000\n # M5: the Researcher's always-on experiments digest budget (chars). 0 = AUTO — scales with the\n # run size (60 chars/node, bounded [1200, 6000]) instead of one flat cap for an 8-node toy run\n # and a 200-node benchmark run alike. Set a positive value to pin it.\n digest_char_cap: int = Field(default=0, ge=0)\n # D11 compression model slot (open_deep_research's four-slot pattern): a dedicated CHEAP model\n # for agent-history summarization (C2 auto-summary), so compression doesn't pay the main\n # model's price. Blank = use each loop's own model (legacy). `compressor_base_url` blank =\n # the shared llm_base_url.\n compressor_model: str | None = None\n compressor_base_url: str | None = None\n # === Agentic tool-loop ================================================================\n # Agentic tool-loop limits — apply to EVERY tool-using agent (the LLM Researcher, the unified\n # agent's pilot + crash-triage, Deep-Research, the run-chat Boss, the genesis repo-scout, and the\n # cross-run report synthesizer). The loop lets the model call read-only tools across turns before\n # emitting its structured result. Both default to UNLIMITED so the agent takes as many turns / as\n # long as it needs — it is never cut off mid-reasoning; set a positive value here (or per-run, or\n # in the UI settings) to bound latency/cost. These used to be hardcoded per call site (e.g. the\n # boss at max_turns=3 / 45s), which silently dropped a slow reasoning model to a no-op reply.\n agent_max_turns: int = 0 # max tool turns before the emit is forced (0 = unlimited)\n agent_time_budget_s: float = 0.0 # wall-clock ceiling across the loop's turns (0 = no cap)\n # G · Soft convergence SAFETY NET (not a research cap). Two thresholds on the agent's tool-turn count:\n # at `agent_emit_after` the loop NUDGES the agent once (\"you've investigated enough — emit now\"); at\n # `agent_emit_force` it FORCES the emit from what was gathered. Set GENEROUSLY — a focused researcher\n # may legitimately read many files/experiments before it can propose a grounded idea; these only catch\n # a genuine runaway that keeps issuing DIFFERENT calls forever (which stuck-detection, keyed on\n # repeats, misses). NB: the old 499-read runaway (node 63) was 79% REDUNDANT re-reads caused by a\n # broken read_file — fixing read_file (pagination) is the real cure; this is just insurance. 0 = off.\n agent_emit_after: int = 300\n agent_emit_force: int = 500\n # B1 · No-progress / stuck detection — the safety net that makes \"unlimited turns\" safe. The\n # turn/time ceilings above are only BACKSTOPS; this is what actually stops a runaway loop, on the\n # cheapest signal: when the model repeats the SAME tool call (or ping-pongs between two, or keeps\n # hitting the SAME error) with no progress, the loop forces the final emit and finishes. ON by\n # default; reading DIFFERENT files or running ONE long command never trips it. (OpenHands-style.)\n agent_stuck_detection: bool = True\n agent_stuck_repeat: int = 4 # identical calls in a row that count as \"stuck\" (>=2)\n agent_stuck_alternate: int = 4 # ping-pong cycles between two calls that count as \"stuck\" (>=2)\n # C1 · Self-plan (TodoWrite-style): expose an `update_plan` tool so a long-running agent keeps its\n # OWN working TODO and re-surfaces it every `agent_plan_reinject_every` turns — keeps the goal in\n # view across a long loop so the agent \"writes its own context\" (Claude Code style). ON by default.\n agent_self_plan: bool = True\n agent_plan_reinject_every: int = 5\n # C2 · Auto-summary (ON by default): when the tool-loop history grows long, LLM-summarize the\n # stale middle instead of only middle-truncating it. The trigger is `context_budget_chars` if set,\n # else a built-in high-water mark (context_budget.DEFAULT_SUMMARY_CHARS ≈ 120k chars) so short\n # loops are untouched. Falls back to truncation on any summarizer error; one extra model call per\n # over-budget turn.\n agent_auto_summary: bool = True\n # C4 · Repo-developer plan decomposition. A big feature attempted in ONE developer session made a\n # non-converging reasoning model loop indefinitely — it kept writing + exploring without ever\n # emitting `done` (10k+ LLM calls / hours; agentic stuck-detection can't catch VARIED exploration,\n # only literal repeats). Fix: the developer first proposes an ordered plan of ATOMIC steps; a\n # multi-step plan is executed step-by-step, each in a FRESH bounded session with only that step's\n # scope + the files accumulated so far, syntax-validated per write. Every session stays\n # small-context and convergent. ON by default for repo tasks (a 1-step plan == the old single pass).\n developer_plan_decompose: bool = True\n developer_plan_min_steps: int = 2 # a proposed plan with >= this many steps runs step-by-step\n developer_plan_max_steps: int = 8 # cap on plan length (a runaway planner can't spawn 100 steps)\n # Hard per-session backstop for the repo developer — the ONE write-heavy agent that can run away\n # even with stuck-detection on (varied writes/reads never trip the repeat/ping-pong signal). Bounds\n # the plan phase, EACH step, and the single-session fallback. Unlike the global agent_* limits\n # (0 = unlimited, meant for read-only agents), the developer always gets a FINITE ceiling so a\n # model that never emits `done` fails cleanly with the code it wrote so far, instead of looping.\n developer_session_max_turns: int = 500\n developer_session_time_budget_s: float = 1200.0 # 20 min wall-clock per developer session\n # Phase-handoff summaries. Each LLM phase in a node build (Researcher·propose → Developer·stages →\n # plan → implement) ends with ONE extra LLM call that distills its transcript — the repo structure\n # it mapped, files/data confirmed, decisions made — into a brief injected into the NEXT phase (even\n # across the role boundary). The next phase TRUSTS that instead of re-reading the same files, which\n # cuts the biggest source of tool-call thrash (stages/plan/implement each re-exploring the repo).\n # One summary call per phase in exchange for many fewer read calls downstream. ON by default.\n phase_handoff_summary: bool = True\n # Observability (ADR-17): capture each LLM call's full prompt + completion into the active\n # span (spans.jsonl) so the UI can show exactly what the model read and wrote per node.\n # Diagnostics only — `replay.fold` never reads spans. Default on for local single-user; set\n # LOOPLAB_TRACE_LLM_IO=0 to suppress (e.g. to keep spans.jsonl small or avoid storing prompts).\n trace_llm_io: bool = True\n # Run-introspection tools (ON by default): make the LLM Researcher (and DeepResearcher) a\n # tool-using agent that can read its OWN experiments (list/read/find-analogous/themes/code) and\n # the task data (schema/profile/asset) mid-loop, instead of seeing only best+parent. Advisory —\n # never changes best-selection. Off = the legacy single-shot Researcher (richer digest still added).\n researcher_tools: bool = True\n # Cross-run introspection: give the agentic Researcher / pilot read-only tools to look at\n # SIBLING runs (same task_id, same run-root) — list them, read an experiment / its code, and\n # find analogous configs across runs — so a run can build on what neighbouring runs already\n # learned instead of rediscovering it. Advisory; never changes best-selection. Needs the run's\n # own dir wired through (no-op for unit-built roles). Off = the legacy single-run view only.\n cross_run_tools: bool = True\n # Read-only access to EVERY run on this machine ACROSS ALL TASKS (not just same-task siblings):\n # the Developer/Researcher get list_all_runs + read_run_code + read_run_experiment so they can read\n # the code + result of ANY past experiment anywhere and reuse an approach. Broader than\n # `cross_run_tools` (same-task only); the agent decides when a foreign run is relevant. Advisory;\n # never changes best-selection. Needs the run's own dir wired through (no-op for unit-built roles).\n all_runs_tools: bool = True\n # PART V §22 — read-only CROSS-RUN KNOWLEDGE tool for the reasoning roles (Researcher, Strategist,\n # deep-research, and a Developer-scoped variant). Adds `cross_run_prior_attempts` / `cross_run_claims`\n # / `cross_run_atlas` over the §21.20 read-models (concept overview + claim assessments + atlas), read\n # from `memory_dir` (lessons.jsonl + concept_capsules.jsonl). Read-only is a storage-authority\n # boundary, not a behavioral no-op: retrieved content can steer later proposals, and a tool-using\n # reasoning loop can consume additional model calls. The tools never mutate cross-run truth (facts\n # are engine-written, verdicts operator-ratified, §22.4) or directly select a winner. ON by explicit\n # experimental product choice, EngineOptions off; stores only exist once Part-IV features have run.\n cross_run_read_tools: bool = True\n # Agentic retrieval (ADR-16): if set, the LLM Researcher gets grep/kb_search/read\n # tools over this directory of markdown notes and chooses when to use them.\n knowledge_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"knowledge\"))\n # T4 real embeddings: model id for an OpenAI-compatible `/embeddings` endpoint used for\n # kb_search / case retrieval. Blank (default) = the dependency-free lexical `hash_embed` (today's\n # behavior). Set e.g. \"nomic-embed-text\" (Ollama) or \"text-embedding-3-small\" for SEMANTIC\n # retrieval; a misconfigured/offline endpoint degrades back to hash_embed at call time (never\n # crashes a run). `embed_base_url` blank = reuse `llm_base_url`; the shared `llm_api_key` is used.\n embed_model: str | None = None\n embed_base_url: str | None = None\n # Harmonic memory (Memora, ICML'26 — idea import). When ON, cross-run cases and knowledge notes are\n # indexed by a short ABSTRACTION + cue ANCHORS instead of their raw text, near-duplicate memories are\n # CONSOLIDATED into one entry under a matching abstraction, and `kb_search`/case retrieval EXPAND\n # through the top hits' anchors to surface related-but-not-similar memories. ON by default. Set\n # `memora=false` to restore the pre-Memora raw-text index. Never a source of truth — abstractions\n # live only in the derived, rebuildable retrieval index (never the event log or canonical cases.jsonl).\n memora: bool = True\n # Write abstractions with the wired chat model (richer than the deterministic lexical fallback). ON\n # by default; results are CACHED by content hash (see `memora_cache`) so a re-built index doesn't\n # re-call the model on unchanged notes/cases, and any endpoint failure degrades to lexical at call\n # time — so an offline box just gets lexical abstractions, never a crash. Set false to force lexical.\n memora_llm: bool = True\n # === Cross-run memory / Memora ========================================================\n memora_anchors: int = 6 # max cue anchors kept per memory\n # Cosine similarity at/above which a new case is CONSOLIDATED into an existing one (same evolving\n # topic) rather than stored as a separate entry. Only used when `memora` is on.\n memora_consolidate_threshold: float = 0.86\n # Where the LLM-abstraction cache lives (JSON). Blank (default) derives it from `memory_dir`\n # (`<memory_dir>/memora_cache.json`) or else `knowledge_dir` (`<knowledge_dir>/.memora_cache.json`);\n # with neither set the cache is in-memory only (per process). No-op unless `memora_llm` is on.\n memora_cache: str | None = None\n # E3 literature-grounded ideation (network-OPTIONAL): give the agentic Researcher an arXiv search\n # tool to ground ideas in real techniques. Off by default (egress is unreliable on some boxes);\n # fails gracefully if the network is blocked.\n literature_search: bool = False\n # Deep-Research stage (network-OPTIONAL): give the DeepResearcher a general web search/fetch\n # tool (DuckDuckGo) on top of arXiv to read across results + the web before steering the next\n # batch. Off by default (egress is unreliable on some boxes); fails gracefully when blocked.\n web_search: bool = False\n # Cadence for the Deep-Research stage: run it automatically every N created nodes. Zero disables\n # only the cadence; manual `deep_research` and Strategist `request_research` triggers still fire.\n # Default 3: deep research analyzes the accumulating results and steers the next batch (its\n # directions become hints + open hypotheses); paired with `concurrent_research` it overlaps the\n # think with the GPU-bound eval, so the cadence costs LLM tokens but no wall-clock on the search.\n deep_research_every: int = 3\n # Overlap a DUE deep-research \"think\" with the GPU-bound eval instead of running it in its own\n # serial step (the agent is otherwise idle while a node trains). research() computes from a state\n # snapshot; CODEX AGENT: the joined background task records only allowlisted, order-tolerant\n # research/hint/hypothesis events as soon as the memo finishes. ON by default: the LLM is typically\n # remote (no GPU contention with eval), so overlapping hides latency behind long training.\n concurrent_research: bool = True\n # Concurrent-research REPEAT — don't idle a multi-DAY eval. The overlapped \"think\" fires ONCE per\n # window by default; ON here, it RE-RUNS on an adaptive time cadence for the WHOLE eval window\n # (a two-day training must not leave the reasoning agents idle after one memo). Self-paced: it\n # only records a memo whose CONTENT is new (identical re-runs are skipped, so the log/board don't\n # bloat) and backs off geometrically as the analysis converges, capped so it always re-checks.\n # The appended records never rewrite the current metric champion, but their hints/open hypotheses\n # deliberately steer later proposals and replay reconstructs that advice. The library default is\n # one-shot (== today, byte-identical); this product default turns the repetition on.\n concurrent_research_repeat: bool = True\n # Base cadence (seconds) between repeated concurrent-research passes. Research is EXPENSIVE\n # (multi-turn LLM + web/arXiv), so this is a FLOOR, not a ceiling: the effective cadence is\n # max(this, ~5% of the per-experiment time budget) — a two-day eval is re-researched about hourly,\n # a short eval not at all (the first tick outlasts it). Only matters when the repeat is on.\n concurrent_research_interval_s: float = Field(default=1800.0, gt=0)\n # Per-eval-window backstop on repeated-research LLM calls (0 = cadence-only, no cap). The adaptive\n # cadence + convergence backoff are the primary budget control; this bounds a pathological\n # never-converging window. Past it the loop stops calling the LLM (the health monitor still runs).\n concurrent_research_max_calls: int = Field(default=40, ge=0)\n # Concurrent hypothesis-board CONSOLIDATION during the eval window. Repeated research keeps ADDING\n # near-duplicate directions to the open board (each memo registers up to 5 as open hypotheses), so a\n # long eval's board bloats before the between-nodes merge runs. This dedups/merges it on the SAME\n # background loop so the board stays clean for the next proposal. In Card-driven mode the same merge\n # changes ownership/readiness, so it is deferred to the joined main-task cadence instead. Needs the\n # reflect client + `track_hypotheses`.\n # Only active while `concurrent_research` runs the overlap loop. Library default off (== today).\n concurrent_consolidate: bool = True\n # Cadence for the agent-authored run report: regenerate the conclusion-first narrative every N\n # created nodes (0 = off; it still regenerates on a manual `report_refresh` from the UI). The\n # deterministic report always renders from the node set regardless of this knob.\n report_every: int = 3\n # Agent Skills (I18, ADR-9): dir of SKILL.md the Researcher can list/load as tools.\n skills_dir: str | None = None\n # Prompt store (I18, ADR-8): dir of editable, hot-reloaded role prompt .md files.\n prompt_dir: str | None = None\n # API key as a reference, never serialized as a value (ADR-11). Local servers\n # ignore it; default to a placeholder.\n llm_api_key: SecretStr | None = None\n\n @model_validator(mode=\"before\")\n @classmethod\n def _apply_profile(cls, data):\n \"\"\"Expand `profile` into a bundle of setting defaults BEFORE validation.\n\n pydantic-settings hands this validator the fully-merged input from ALL explicit sources\n (init kwargs + LOOPLAB_* env + .env), with fields the user left unset simply ABSENT (they\n fall to field defaults afterward). So a profile value is applied only when its key is not\n already present — i.e. any explicit file/CLI/env setting wins over the profile, and the\n profile wins over the bare field default. This makes `thorough` a true convenience preset,\n never an override of what the user asked for.\"\"\"\n if not isinstance(data, dict):\n return data\n prof = data.get(\"profile\", \"default\")\n # env may deliver the key as \"profile\" (env_prefix stripped) — normalize to a str\n prof = str(prof).strip().lower() if prof is not None else \"default\"\n overrides = PROFILES.get(prof)\n if overrides is None:\n raise ValueError(f\"unknown profile {prof!r}; known: {sorted(PROFILES)}\")\n for key, val in overrides.items():\n if key not in data: # explicit source (file/CLI/env) always wins\n data[key] = val\n return data\n\n @model_validator(mode=\"after\")\n def _check_trust_gate(self):\n if self.trust_gate not in (\"audit\", \"gate\", \"block\"):\n raise ValueError(\n f\"trust_gate must be audit|gate|block, got {self.trust_gate!r}\")\n if self.merge_mode not in (\"auto\", \"mean\", \"ensemble\"):\n raise ValueError(\n f\"merge_mode must be auto|mean|ensemble, got {self.merge_mode!r}\")\n # novelty_mode drives the dedup/novelty gate; an out-of-set value (e.g. a mis-cased\n # LOOPLAB_NOVELTY_MODE=LLM, or \"on\") used to fall through _apply_novelty_gate silently as a\n # NO-OP — turning the gate off with no diagnostic. Fail loudly at config time like trust_gate/\n # merge_mode do. (Other enum-ish string fields — seed_mode/eval_trust_mode/strategist_backend —\n # share the pattern; left unvalidated here only because their full value sets are less settled.)\n if self.novelty_mode not in (\"off\", \"algo\", \"llm\"):\n raise ValueError(\n f\"novelty_mode must be off|algo|llm, got {self.novelty_mode!r}\")\n # The remaining enum-ish string fields (arch-review §5 P3): a typo used to be accepted at\n # construction and only fail-safe/later-loud downstream (e.g. a mis-cased strategist_backend\n # silently ran the default). Fail loudly here like the fields above.\n if self.strategist_backend not in (\"off\", \"rule\", \"llm\", \"agent\"):\n raise ValueError(\n f\"strategist_backend must be off|rule|llm|agent, got {self.strategist_backend!r}\")\n if self.eval_trust_mode not in (\"ratify_freeze\", \"autonomous\", \"ratify_freeze_drift\"):\n raise ValueError(\n \"eval_trust_mode must be ratify_freeze|autonomous|ratify_freeze_drift, \"\n f\"got {self.eval_trust_mode!r}\")\n if self.seed_mode not in (\"auto\", \"tracked\", \"all\"):\n raise ValueError(f\"seed_mode must be auto|tracked|all, got {self.seed_mode!r}\")\n return self\n\n def masked_snapshot(self) -> dict:\n # Snapshots are a JSON contract (they are written verbatim to config.snapshot.json), not a\n # Python-object dump. In particular tuple-valued schema fields such as\n # ``inline_repair_reasons`` must become JSON arrays before callers hash the runtime envelope;\n # otherwise the strict speculation-scope canonicalizer sees a different/non-JSON preimage\n # from the one that ``json.dumps`` eventually persists.\n d = self.model_dump(mode=\"json\")\n if self.llm_api_key is not None:\n d[\"llm_api_key\"] = \"***\"\n else:\n d[\"llm_api_key\"] = None\n return d\n\n\n# Pre-versioned snapshots are full Settings dumps, so absence is a reliable per-field version marker.\n# Keep their historical effective behavior when newer product defaults become active: re-entry must not\n# silently add paid calls, interventions, concurrency or a different selection policy to an old run.\nLEGACY_CONFIG_SNAPSHOT_DEFAULTS: dict[str, object] = {\n \"parallel_build\": 1,\n \"eval_parallel\": None,\n \"llm_parallel\": None,\n \"train_monitor\": False,\n \"train_monitor_interval_s\": 600.0,\n \"train_monitor_kill\": False,\n \"train_monitor_kill_confidence\": 0.8,\n \"asha_live\": False,\n \"asha_live_kill\": False,\n \"asha_live_quantile\": 0.5,\n \"asha_live_min_siblings\": 3,\n # Before this operator ceiling existed, the sandbox's 24-hour process limit was authoritative.\n \"max_eval_timeout\": 24 * 3600.0,\n \"watchdog_reflection\": False,\n \"card_driven_selection\": False,\n \"speculation_depth\": 0,\n \"speculation_gate_receipt\": None,\n \"concurrent_research_repeat\": False,\n \"concurrent_research_interval_s\": 1800.0,\n # Zero means unlimited; 40 was the first bounded default, and is latent while repeat is disabled.\n \"concurrent_research_max_calls\": 40,\n \"concurrent_consolidate\": False,\n}\n\n\ndef migrate_config_snapshot(data: dict) -> dict:\n \"\"\"Return an effective copy of one legacy/current snapshot without rewriting evidence bytes.\"\"\"\n if not isinstance(data, dict):\n raise TypeError(\"config snapshot must be a JSON object\")\n migrated = dict(data)\n for key, value in LEGACY_CONFIG_SNAPSHOT_DEFAULTS.items():\n migrated.setdefault(key, value)\n return migrated\n\n\ndef settings_from_snapshot(data: dict) -> Settings:\n \"\"\"Build re-entry Settings from a masked snapshot under its historical missing-field contract.\"\"\"\n migrated = migrate_config_snapshot(data)\n migrated.pop(\"llm_api_key\", None)\n return Settings(**migrated)\n"}, "923e9219de311133116e3ed8f6382f965ab8e540": {"t": 1785768786.8599093, "n": 95754, "h": "dadf6bbe262c7673", "c": "\"\"\"Engine config: the schema (what settings exist). (I0, ADR-11)\n\nOne pydantic-settings class declaring every engine knob, its default, and its docs. The loader —\nhow a run file / CLI / env becomes a `Settings` — is `looplab.core.appconfig`, which also owns\nthe config-precedence order. A resolved, secret-masked snapshot is written next to each run for\nreproducibility. (No real secrets in P0, but the masking discipline is in place.)\n\"\"\"\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nfrom pydantic import Field, SecretStr, field_validator, model_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n# Single source of the LLM first-byte (response-headers) default — see core/llm.py.\nfrom looplab.core.llm import DEFAULT_HEADER_TIMEOUT_S\n\n# Default home for cross-run memory + the knowledge base (shared across every run). Both are ON by\n# default now (a user asked for it) so a fresh install accumulates + consults knowledge with no setup;\n# set the env var to \"\" to disable, or to a path to relocate. `~/.looplab/` keeps them out of any one\n# project + shared across projects.\n_LL_HOME = Path.home() / \".looplab\"\n\n# Autonomous roles that may be granted per-setting write access (see Settings.agent_control).\n# strategist = A7 meta-controller (run-wide); boss = run-chat operator-proxy (run-wide);\n# researcher = per-experiment proposer (per-node sizing, e.g. eval timeout for a heavy model).\nAGENT_ROLES = (\"strategist\", \"boss\", \"researcher\")\n\n# A foresight score is computed for every acted-on proposal. Keep this lower than the generic\n# verifier's emergency ceiling so a typo cannot multiply paid synchronous calls across a run.\nMAX_FORESIGHT_VERIFY_SAMPLES = 8\n\n\n# Layer-2 parallelism renamed two public settings without removing the durable legacy fields. Keep\n# the equivalence in one place: config-source precedence, governance migration, and live strategy\n# application must all agree which names describe the same authority/value.\nPARALLELISM_ALIASES: dict[str, str] = {\n \"max_parallel\": \"eval_parallel\",\n \"parallel_build\": \"llm_parallel\",\n}\n\n\ndef parallelism_aliases(setting: str) -> tuple[str, ...]:\n \"\"\"Return every spelling in ``setting``'s alias family, canonical name first.\"\"\"\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if setting in (legacy, canonical):\n return canonical, legacy\n return (setting,)\n\n\ndef canonicalize_parallelism_source(values: dict) -> dict:\n \"\"\"Promote legacy parallel keys *within one config-precedence source*.\n\n Applying this only after file/CLI/env were flattened would let a lower-priority canonical env\n value shadow a higher-priority legacy file value (or a file canonical value shadow a CLI legacy\n override), so callers with sub-layers normalize each layer independently.\n \"\"\"\n if not isinstance(values, dict):\n return values\n out = dict(values)\n for legacy, canonical in PARALLELISM_ALIASES.items():\n # CODEX AGENT: None is the durable legacy mode for llm_parallel, not a missing value. A normal\n # snapshot contains llm_parallel:null plus parallel_build:1; reloading it here promotes null to\n # 1 and unexpectedly enables the global one-call broker. Promote aliases only when the\n # canonical key is absent, and pin snapshot round-trip parity.\n if canonical not in out and out.get(legacy) is not None:\n # CODEX AGENT: canonicalize inside each precedence layer, never across the flattened\n # result. The alias migration must not invert CLI > file > env precedence.\n out[canonical] = out[legacy]\n return out\n\n# Settings whose semantics are committed by ``run_started`` and restored from the folded event log\n# on every re-entry. Changing one only in ``config.snapshot.json`` would mix incompatible evidence\n# (a different holdout split) or make the live producer disagree with replay's selection policy.\n# The HTTP config editor and the engine both consume this one contract; keep deliberately mutable\n# controls such as ``trust_gate`` out (that field has its own durable change event).\nRUN_START_PINNED_FIELDS = frozenset({\n \"card_driven_selection\",\n \"speculation_depth\",\n \"holdout_fraction\",\n \"holdout_select\",\n \"select_verifier\",\n \"select_verifier_samples\",\n \"verifier_ci_tie\",\n})\n\n\ndef run_start_pinned_settings(state) -> dict:\n \"\"\"Return the effective Settings names committed by a folded run-start record.\n\n Pre-pin legacy logs have no recorded holdout fraction, so their snapshot remains authoritative\n for both holdout knobs. Verifier fields, however, have always been re-pinned from the legacy-safe\n fold defaults whenever a valid ``run_started`` identity exists.\n \"\"\"\n if not getattr(state, \"run_id\", \"\"):\n return {}\n values = {\n # Layer 3 changes which queue owns macro-action selection. Once a run starts, replay and\n # resume must keep the same owner even if the snapshot or ambient default changes later.\n \"card_driven_selection\": bool(getattr(state, \"card_driven_selection\", False)),\n # Layer 5 overlaps Card production with evaluation only when this pinned treatment is\n # positive. Old logs omit it and therefore retain the exact serial build/eval spine.\n \"speculation_depth\": int(getattr(state, \"speculation_depth\", 0)),\n \"select_verifier\": bool(getattr(state, \"select_verifier_tiebreak\", False)),\n \"select_verifier_samples\": int(getattr(state, \"select_verifier_samples\", 3)),\n \"verifier_ci_tie\": bool(getattr(state, \"verifier_ci_tie\", False)),\n }\n holdout_fraction = getattr(state, \"holdout_fraction\", None)\n if holdout_fraction is not None:\n values.update({\n \"holdout_fraction\": holdout_fraction,\n \"holdout_select\": bool(getattr(state, \"holdout_select\", False)),\n })\n if not values.keys() <= RUN_START_PINNED_FIELDS:\n raise RuntimeError(\"folded run-start pinned settings contract drifted\")\n return values\n\n# --- Run profiles (config-first presets) -------------------------------------------------------\n# A `profile` is a NAMED BUNDLE of setting defaults — nothing more. It only fills fields the user\n# did NOT set explicitly (via file / --set / LOOPLAB_* env / init-kwargs), so any explicit knob\n# always wins (see `_apply_profile`). Most engine intelligence lives behind individual flags. The\n# PART IV/V cross-run + concept-graph machinery (concept_pivot, graded_novelty, cross_run_concepts /\n# _advisory / _structured_claims / _curation / _read_tools, fingerprint_universal, foresight_verify)\n# now ships ON in the product `Settings` default. That is an explicit experimental product choice,\n# not a claim that a frozen A/B or workload-wide cost/quality gate has cleared: the bundle can steer\n# prompts and candidate generation, change graded-novelty admission / pre-execution choices, and add\n# paid LLM work when its client and memory prerequisites are present. The bare-library `EngineOptions`\n# default stays lean (see tests/test_options_divergence.py — product side is the aggressive one) so a\n# toy `Engine(...)` in a test doesn't fire cross-run LLM work unasked. The trust/confirm/ablate QUALITY\n# bundle still ships OFF and is turned on by `thorough`. Every value here is reachable by hand — the\n# profile is a convenience, never a hidden mode.\n# CODEX AGENT: keep this rationale behavioral and evidence-based; do not relabel read-only storage\n# access or proposal-only governance as a no-op for agent behavior, cost, or proposal admission.\n#\n# `thorough` deliberately touches only QUALITY/TRUST machinery, not spend: it does NOT raise\n# max_nodes / eval_parallel (those are cost knobs the user owns). It enables multi-seed confirmation\n# (demote seed-lucky leaders), the reward-hack / leakage / critic monitors AND flips them from\n# advisory to *gating* (`trust_gate=\"gate\"` — a\n# flagged win can no longer be selected as best), ablation-driven refinement, and the prompt cues\n# (complexity / budget / failure-reflection / watchdog-reflection). Agentic novelty and the experimental\n# Part IV/V bundle are already product defaults, not profile changes. Effect is large; marginal cost is a\n# few extra evals.\nPROFILES: dict[str, dict] = {\n \"default\": {}, # explicit no-op alias for \"ship-as-is\" (== omitting profile)\n \"fast\": {}, # today's lean defaults, named for symmetry with `thorough`\n \"thorough\": {\n \"confirm_top_k\": 3,\n \"confirm_seeds\": 3,\n # novelty_gate intentionally NOT enabled here: duplicate-detection is the agentic Researcher's\n # job (it reads past experiments + find_analogous_across_runs and DECIDES), not an algorithmic\n # param-distance / embedding auto-reject that mis-fired (it nudged a good fresh idea onto a\n # twice-dead lineage — live node 61). Search stays as tools; the LLM judges novelty.\n \"operator_bandit\": True, # P4: adaptive operator mix over folded yields (GreedyTree)\n \"reward_hack_detect\": True,\n \"code_leakage_detect\": True,\n \"critic_check\": True,\n \"trust_gate\": \"gate\", # detectors stop gating a flagged node from WINNING (not just audit)\n \"ablate_every\": 3,\n \"complexity_cue\": True,\n \"budget_aware\": True,\n \"failure_reflection\": True,\n \"watchdog_reflection\": True, # feed recent live-watchdog (train-monitor/ASHA) flags to proposals\n \"reflection_priors\": True, # no-op unless memory_dir is set (cross-run priors)\n },\n}\n\n\n# The shipped agent-governance matrix (who may change which setting at runtime). Defined as a module\n# constant so the Engine's direct-construction default matches this shipped default exactly (the\n# EngineOptions \"Engine() == shipped defaults\" invariant) instead of diverging to an all-locked map.\nDEFAULT_AGENT_CONTROL: dict[str, list[str]] = {\n \"timeout\": [\"researcher\", \"strategist\"],\n # Layer-2 canonical names are the presented/default governance surface. `_agent_may` falls back\n # to an old snapshot's legacy grant only when its canonical counterpart is absent, so removing\n # duplicate defaults preserves resume authority while an explicit canonical revocation still wins.\n \"eval_parallel\": [\"strategist\"],\n \"llm_parallel\": [\"strategist\"],\n # Strategy-only pseudo-setting: the closed per-lane allocation inside the canonical LLM total.\n # It is not a launch Settings field; operators pin it through `set_strategy` and the Strategist\n # may reallocate it only while this grant is present.\n \"llm_lane_limits\": [\"strategist\"],\n \"max_nodes\": [\"strategist\", \"boss\"],\n \"max_eval_seconds\": [\"strategist\", \"boss\"],\n \"policy\": [\"strategist\"],\n # The policy NAME and its PARAMS are gated independently in `_apply_strategy`; grant both by default\n # so the Strategist that may switch to (say) MCTS may also apply the `c`/`eta` it decided — else its\n # tuned params are silently dropped and the recorded `active_strategy` diverges from the live engine.\n \"policy_params\": [\"strategist\"],\n \"n_seeds\": [\"strategist\"],\n \"ablate_every\": [\"strategist\"],\n \"merge_mode\": [\"strategist\"],\n \"complexity_cue\": [\"strategist\"],\n \"ablate_code_blocks\": [\"strategist\"],\n \"prefer_sweep\": [\"strategist\"],\n \"novelty_stance\": [\"strategist\"],\n \"developer\": [\"strategist\"],\n \"fidelity\": [\"strategist\", \"researcher\"],\n}\n\n\ndef default_agent_control() -> dict[str, list[str]]:\n \"\"\"A FRESH deep-ish copy of the shipped governance matrix (each role list copied so a caller can\n mutate its own map without touching the module constant). The SINGLE constructor of the default,\n called by both `Settings.agent_control`'s default_factory AND the Engine's direct-construction\n default — so \"Engine() == shipped defaults\" can't drift if the copy depth ever needs to change\n (e.g. nested values requiring copy.deepcopy), instead of two verbatim copy expressions.\"\"\"\n return {k: list(v) for k, v in DEFAULT_AGENT_CONTROL.items()}\n\n\nclass Settings(BaseSettings):\n \"\"\"The engine settings schema (every knob a run accepts).\n\n Timeout family — six distinct knobs, each owned by a different subsystem:\n - `timeout`: per-eval wall-clock budget for ONE experiment's evaluation (engine/eval).\n - `max_eval_timeout`: hard ceiling for a governed per-node Researcher timeout override.\n - `llm_timeout`: LLM request idle timeout — inter-token stall limit in stream mode\n (core.llm OpenAICompatibleClient).\n - `llm_header_timeout`: LLM first-byte (response-headers) window for stream attempts (core.llm).\n - `agent_time_budget_s`: wall-clock ceiling across a tool-using agent loop's turns (agents).\n - `dep_install_timeout`: per-package budget for auto-installing a missing dep (env self-prep).\n\n Config-source precedence is owned by `looplab.core.appconfig` (the loader) — see its module\n docstring for the one canonical order.\n \"\"\"\n # Config precedence: see appconfig.py — the loader owns the full order. The `.env` is read\n # so `looplab run`/`looplab ui` pick up LOOPLAB_LLM_BASE_URL etc. without exporting them by hand\n # (keys MUST carry the LOOPLAB_ prefix, same as the env vars). utf-8-sig tolerates a BOM from\n # Windows editors. The test suite disables this (tests/conftest.py) so a dev's real .env in the\n # repo root can't leak into assertions.\n model_config = SettingsConfigDict(\n env_prefix=\"LOOPLAB_\",\n env_file=\".env\",\n env_file_encoding=\"utf-8-sig\",\n extra=\"ignore\",\n allow_inf_nan=False,\n )\n\n @classmethod\n def settings_customise_sources(\n cls, settings_cls, init_settings, env_settings, dotenv_settings,\n file_secret_settings):\n \"\"\"Preserve source precedence across canonical/legacy parallelism names.\n\n Pydantic ranks init > env > dotenv correctly, but it ranks field names rather than semantic\n alias families. Promoting a legacy value inside each source before the merge makes an old\n explicit snapshot/file value beat a lower-priority canonical env value. ``appconfig`` also\n promotes its file/typed/--set sub-layers before combining them into the init source.\n \"\"\"\n def _with_parallel_aliases(source):\n return lambda: canonicalize_parallelism_source(source())\n\n return (\n _with_parallel_aliases(init_settings),\n _with_parallel_aliases(env_settings),\n _with_parallel_aliases(dotenv_settings),\n _with_parallel_aliases(file_secret_settings),\n )\n\n # Run profile (config-first preset, see PROFILES above). \"default\"/\"fast\" = today's lean\n # defaults; \"thorough\" turns the built quality/trust machinery ON in one word. A profile only\n # fills fields the user did NOT set — every knob it touches stays individually overridable.\n profile: str = \"default\"\n\n # Bounds (review C/⚪): a non-positive node/seed budget or timeout is never valid. Upper bounds are\n # equally load-bearing — the UI /start path writes these straight into the engine's env, and an\n # unbounded `eval_parallel` (or legacy `max_parallel`; e.g. 100000 from a crafted preflight) would\n # fan out that many sandboxes → resource exhaustion. Reject at config time, not mid-run. Ceilings\n # are generous; tests top out near 50.\n n_seeds: int = Field(default=3, ge=1, le=1024)\n max_nodes: int = Field(default=8, ge=1, le=1_000_000)\n # CODEX AGENT: legacy evaluation-width input. `eval_parallel` below is canonical and wins when set;\n # keep this field for old env/config/snapshots and direct Engine call sites. A width > 1 admits\n # concurrent evals, whose resource reservations may be CPU-only or span one/more GPUs — there is no\n # one-node/one-GPU promise. `max_parallel=0` is launch-time AUTO (detected GPU count, at least 1).\n max_parallel: int = Field(default=1, ge=0, le=1024)\n # CODEX AGENT: legacy build-width input. `llm_parallel` below is canonical and also controls the\n # shared provider-call total when positive. A width > 1 uses fresh pooled researcher/developer pairs;\n # it says nothing about the later eval's CPU/GPU reservation. Startup `0` derives build fan-out from\n # resolved eval width; a live Strategist/operator zero settles to 1. Without role_factory it clamps to 1.\n parallel_build: int = Field(default=1, ge=0, le=64)\n # Hypothesis-card Kanban re-architecture (docs/23, Layer 2). Two INDEPENDENT concurrency axes,\n # decoupled from each other so cost (LLM work) can be tuned separately from throughput (GPU evals).\n # These are the CANONICAL names going forward; `max_parallel`/`parallel_build` above are kept as thin\n # back-compat aliases (env vars / snapshots / ~100 Engine(...) call sites depend on the exact names,\n # so they cannot be renamed — CLAUDE.md). Resolution (orchestrator.__init__): a NEW name, when set,\n # WINS over its legacy alias; None => fall back to the legacy field => byte-identical to today.\n # `eval_parallel`: concurrent EVALS (the GPU/experiment consumer). None -> max_parallel; 0 = AUTO\n # (one experiment per detected GPU). This is the in-Run eval-concurrency ceiling; the resource\n # scheduler bin-packs declared footprints separately. Sibling local GPU-owning Runs serialize\n # through the conservative host-pool lease; external execution needs its own admission boundary.\n # `llm_parallel`: total concurrent LLM provider calls + BUILD fan-out. None -> legacy\n # parallel_build semantics without a shared total; 0 = startup AUTO (= resolved eval_parallel\n # for build fan-out, historical research overlap unbounded). A positive canonical value enables\n # the named-lane broker the Strategist can reallocate at cadence.\n eval_parallel: int | None = Field(default=None, ge=0, le=1024)\n llm_parallel: int | None = Field(default=None, ge=0, le=64)\n # Training-log monitor (I-series watchdog family): a per-eval background observer that tails the live\n # training log while a (often multi-hour) declared stage runs in a worker thread. ON in the product\n # surface (Settings). Its alert event is fold-ignored and cannot directly change lifecycle/champion;\n # CODEX AGENT: with watchdog_reflection on, that alert is nevertheless advice in a later Researcher\n # prompt and can change future proposals. It only fires on the command-eval path with an LLM client;\n # bare-library EngineOptions stays OFF. See test_options_divergence.\n # `_interval_s` is the BASE tick cadence (the effective one adapts to the per-experiment budget).\n train_monitor: bool = True\n train_monitor_interval_s: float = Field(default=600.0, gt=0)\n # Phase 3 — INTERVENTION (opt-in, separate from observation): let the monitor tree-kill a training the\n # LLM judges 'broken' (diverged / silent CPU fallback / not learning) EARLY, instead of burning the\n # whole budget. Off by default — the observer only WATCHES unless this is on. A kill only fires on a\n # 'broken' verdict (a plateau is 'watch', never killed) with confidence >= the threshold; the node then\n # fails normally (reason='monitor_broken'), so replay reconstructs it from that one terminal event.\n train_monitor_kill: bool = False\n train_monitor_kill_confidence: float = Field(default=0.8, ge=0.0, le=1.0)\n # ASHA live-curve watchdog (sibling of the training monitor): reads the latest INTERMEDIATE value of\n # the objective metric off the live log (reusing the eval's OWN metric reader). Finished-endpoint rank\n # remains advisory. An opt-in KILL additionally requires an operator-declared metric.resource_key and\n # enough sibling observations at exactly that resource; absent comparable evidence cannot stop a run.\n # Records a fold-ignored `asha_rank` diagnostic + trace span; the library default is OFF (off == today).\n asha_live: bool = True\n # Opt-in INTERVENTION (separate from the advisory signal): let the watchdog tree-kill a node whose\n # intermediate metric stays below the SAME-RESOURCE bar past a short grace window. Off by default —\n # it only surfaces endpoint rank unless this is on. A kill fails normally (asha_underperforming).\n asha_live_kill: bool = False\n # The bar sits at `asha_live_quantile` along a WORST->BEST ordering of the finished siblings' finals:\n # 0.5 = the median (a node worse than the median peer flags); a SMALLER value is more conservative —\n # it lowers the bar toward the WORST finished peer, so only a node worse than nearly all peers flags\n # (0.0 = worse than the worst). `min_siblings` is the floor of finished peers required before it ranks\n # at all, so it never acts on too little evidence.\n asha_live_quantile: float = Field(default=0.5, ge=0.0, le=1.0)\n asha_live_min_siblings: int = Field(default=3, ge=1)\n timeout: float = Field(default=30.0, gt=0)\n # Hard ceiling for the Researcher's governed per-node `Idea.eval_timeout`. One hour preserves the\n # existing heavy/neural-net authoring example while staying well below the sandbox's defensive\n # 24-hour subprocess ceiling. This is operator-owned run config: agents may request less or more,\n # but the accepted action is always clamped here after the `agent_control.timeout` permission gate.\n max_eval_timeout: float = Field(default=3600.0, gt=0, le=24 * 3600.0)\n # Intra-node sweep: a sweep node runs a whole grid in one process, so it gets this multiple of\n # `timeout` as its wall-clock budget (solution.py path; RepoTasks use their per-profile timeout).\n sweep_timeout_mult: float = Field(default=8.0, ge=1)\n # Sandbox tier (ADR-13): \"trusted_local\" (subprocess, no Docker) for the CLI;\n # \"untrusted\" (Docker --network none, shared-kernel runtime) for hosted/multi-tenant UI;\n # \"hostile\" (untrusted + a true-isolation OCI runtime, gVisor `runsc` by default / Kata) for\n # running untrusted code. See runtime/sandbox.py::make_sandbox for the tier selection.\n trust_mode: str = \"trusted_local\"\n # Image for the untrusted command-eval tier (RepoTask, Phase 4): the framework's deps\n # should be baked in (the container runs --network none, so a pip setup can't fetch).\n docker_image: str = \"python:3.12-slim\"\n # Resource caps for the untrusted/hostile Docker tier (passed to `docker run --memory/--cpus`).\n # The default 4g bound protects other tenants from an OOM, but a model-training MLE-bench eval\n # routinely needs more — raise it (e.g. \"16g\") for such runs. \"\" disables the cap (unbounded).\n # Ignored by the trusted_local (subprocess) tier.\n sandbox_memory: str = \"4g\"\n sandbox_cpus: str = \"\"\n # Best-effort host-OOM guard for the TRUSTED-LOCAL (subprocess) tier. When set (e.g. \"8g\"), each\n # eval child gets an RLIMIT_AS address-space cap so a runaway trainer hits MemoryError instead of\n # OOM-killing the whole host (and the engine). POSIX only; a no-op on Windows. Default \"\" = OFF:\n # RLIMIT_AS bounds VIRTUAL memory, and CUDA/torch reserve tens of GB of virtual without using it,\n # so a default cap would spuriously kill GPU evals — leave it off for those and use the Docker\n # tier's real --memory cgroup bound (sandbox_memory) instead. Ideal for CPU/numpy/sklearn runs.\n sandbox_memory_local: str = \"\"\n # Best-effort disk-fill guard for the TRUSTED-LOCAL tier (P1-5): an RLIMIT_FSIZE cap on the size of\n # any single file an eval child writes (e.g. \"2g\"), so a runaway that writes an unbounded file gets\n # SIGXFSZ instead of filling the host disk. POSIX only. Default \"\" = OFF (large model checkpoints\n # need big files); set it for tasks that write only small artifacts.\n sandbox_fsize_local: str = \"\"\n # Search policy (ADR-2): \"greedy\" | \"evolutionary\" | \"mcts\" | \"asha\".\n policy: str = \"greedy\"\n # Ablation-driven refinement (I7): every N improves, ablate the best to find the\n # highest-impact parameter and refine it. 0 = off. (GreedyTree only.)\n ablate_every: int = 0\n # A0a (MLE-STAR): when ablating, treat each generated pipeline *code block* (not just a\n # numeric param) as an ablation unit — neutralize a block, measure the metric delta, refine\n # the highest-impact block. Off => the classic numeric-param ablation. Config-first knob.\n ablate_code_blocks: bool = False\n # A0b: real merge/ensembling. \"mean\" = legacy mean-param merge; \"ensemble\" = the Developer\n # writes a code-recombination ensemble over the two parents' solutions (verified: agent-proposed\n # ensembling 37.9%->43.9%). \"auto\" (default) = ensemble whenever the Developer actually\n # generates code (LLM/agent backends; declared via `is_code_generating`), mean for the\n # templated/toy developers where a code ensemble is meaningless — the evidence says code\n # recombination is the strongest single merge (removing it costs ~9 pp), so it is the default\n # wherever it can work. Falls back to mean when the Developer can't ensemble.\n merge_mode: str = \"auto\"\n # A0d (AIRA): inject a dynamic complexity hint into the draft/improve prompt keyed on the\n # node's child count (few children -> keep minimal; many -> escalate to ensembling/HPO).\n complexity_cue: bool = False\n # I1 (CAAFE-style) LLM feature-engineering: instruct the proposer/developer to add\n # semantically-meaningful engineered features (ratios, interactions, aggregations) as code. The\n # existing cross-validation eval + best-selection is the CV GATE — a feature set that doesn't\n # improve CV is never selected (feature engineering is non-universal, so this gate is mandatory).\n # Tabular tasks (those exposing column data). Off by default.\n feature_engineering: bool = False\n # A5: surface the REMAINING eval-compute budget into the proposal prompt so the Researcher can\n # reason over how much compute is left (explore broad while flush, exploit/cheapen when low).\n # No-op unless a max_eval_seconds budget is set.\n budget_aware: bool = False\n # A4 (LATS-style): feed a summary of the most recent FAILED branches (operator + error reason)\n # back into the proposal prompt so the proposer reflects on and avoids repeating them. ON by\n # default: it is SELECTIVE by construction (injects only when recent failures exist — the\n # evidenced form of reflection; reflecting at every step shows no gains, arXiv:2506.12928).\n failure_reflection: bool = True\n # Feed the LIVE-WATCHDOG observations (train-monitor health verdicts + ASHA intermediate-rank flags)\n # of recent experiments back into the proposal prompt, so the Researcher avoids re-proposing a config\n # whose TRAINING was already observed to be weak — even when the watchdog kills are off (default) and\n # the node ran to completion (those diagnostics are fold-ignored, so failure_reflection never sees\n # them). ON by default and SELECTIVE like failure_reflection (injects only when a recent flag exists).\n watchdog_reflection: bool = True\n # C3 deep test-driven repair: hand the Developer the failure taxonomy + a structured \"reproduce\n # then fix\" directive on debug, not just the raw stderr tail. ON by default (product surface); the\n # conservative library default in EngineOptions stays off, per that module's contract.\n deep_repair: bool = True\n # === Inline crash repair ==============================================================\n # Hybrid in-node crash repair: when an LLM-generated node CRASHES at runtime (mechanical errors\n # — bad import, removed kwarg, typo), the agent triages it and may repair the code IN PLACE within\n # the same eval (cheap; does NOT consume max_nodes and does NOT add a node to the search tree).\n # Semantic failures still flow to a new idea/debug node via the policy. ON by default; set False\n # to restore the prior behavior (every crash waits for a budgeted inter-node debug node).\n inline_repair: bool = True\n # Max in-place repair attempts per node before it fails normally. Default 0 = UNLIMITED: the same\n # agent keeps fixing + re-evaluating IN THE SAME node until it produces a clean metric — no new\n # node just to retry. Runaway is prevented not by a count but by the anti-stuck guard\n # (`inline_repair_stuck_repeat`): when the SAME error signature repeats with no progress, or the\n # agent's crash-triage says \"abandon\", the node fails. Set a positive N to cap the retries instead.\n inline_repair_attempts: int = Field(default=0, ge=0)\n # Anti-stuck: abandon in-node repair when the SAME error recurs this many times in a row (no\n # progress). Keeps \"unlimited\" repair from looping forever on an unfixable error.\n inline_repair_stuck_repeat: int = Field(default=4, ge=2)\n # Which failure reasons (_failure_reason: crash|timeout|oom|setup|no_metric|drift) are eligible for\n # inline repair. Default: mechanical crashes, timeouts AND OOM-kills — a timeout/OOM means the code\n # was too slow / too memory-hungry for the budget (or the pod's cgroup limit), not that the idea is\n # wrong, so the agent repairs it by reducing compute/memory (fewer estimators/epochs/folds/seeds, a\n # lighter model, a smaller batch, subsampling) instead of letting the node die unrecovered. Env\n # override expects a JSON array.\n inline_repair_reasons: tuple[str, ...] = (\"crash\", \"timeout\", \"oom\")\n # Cost ceiling for in-node repair of a MULTI-STAGE eval: the max number of FULL pipeline re-runs\n # (i.e. re-trains from the first stage) the inline-repair loop may do before abandoning to the\n # inter-node debug operator. A late-stage failure (a broken `score`/eval script that didn't touch\n # `train`) REUSES the completed train checkpoint and re-runs only from the failed stage — that is\n # CHEAP and NOT counted here; only a repair that changes an EARLIER stage's code (train.py/loss.py/…)\n # forces a full re-train, which each costs the whole training time. Without this bound, a repair that\n # keeps changing training code could burn many full trains (the anti-stuck guard is error-signature\n # based, not cost based). 0 = unlimited (legacy behavior). Single-command evals ignore it.\n inline_repair_retrain_cap: int = Field(default=2, ge=0)\n # Environment self-prep: when a solution crashes purely because a KNOWN library isn't installed\n # (ModuleNotFoundError), the engine pip-installs it into the eval interpreter and re-runs — so a\n # torch/XGBoost/CatBoost idea (e.g. a GRU model) actually runs on a fresh box instead of being\n # thrown away as `idea_rejected`. Trusted_local tier only (the untrusted/hostile Docker tiers run\n # --network none). A typo'd or local helper module is NOT installed (that's a code bug to repair).\n auto_install_deps: bool = True\n # Per-package wall-clock budget for an auto-install (seconds). Generous: large wheels like torch\n # can take minutes to download/build.\n dep_install_timeout: float = Field(default=900.0, gt=0)\n # --- Agent governance: who may change a setting at runtime (per-setting allow-list of roles) ---\n # Roles: \"strategist\" (A7 meta-controller; run-wide), \"boss\" (run-chat operator-proxy; run-wide),\n # \"researcher\" (per-experiment proposer; PER-NODE — e.g. set a longer eval timeout for a neural-net\n # idea). A setting ABSENT from this map is LOCKED — no agent may change it, only a human via the\n # snapshot/UI. The sole conditional exception is Strategy-only `card_scoring`: Card mode grants\n # it implicitly without changing the default flag-off snapshot, while an explicit [] revokes it.\n # The gate is ENFORCED at every AGENT seam via `_agent_may` — the strategist's whole\n # control surface (policy/operators/novelty_stance/prefer_sweep/developer/fidelity/timeout/\n # eval_parallel/llm_parallel) is checked in `_apply_strategy`, so removing a role from a knob locks it,\n # not just greys out the UI pill. The boss/max_* grants document boss-relevant knobs, but a\n # `budget_extend` is a HUMAN control intent (the boss action-builder can only emit `add_nodes`), so\n # it is applied as-is — the human always wins via the UI/snapshot. Defaults are conservative: resource/\n # search-shape knobs the agents already reason about; provider infra (llm_model/base_url/api_key,\n # docker_image) stays locked. The UI shows S/B/R pills per setting. `timeout` granted to the\n # researcher IS the \"auto\" per-node mode — the researcher sizes each experiment's wall-clock\n # (Idea.eval_timeout); the config `timeout` is the fallback for nodes it doesn't size. Env override\n # expects a JSON object. (`fidelity`/`novelty_stance`/`prefer_sweep`/`developer` are governance keys\n # for the strategist's per-node dials — not 1:1 Settings fields, but gated the same way.) The default\n # lives in the module-level DEFAULT_AGENT_CONTROL so the Engine's direct-construction default matches.\n agent_control: dict[str, list[str]] = Field(default_factory=default_agent_control)\n # C1 (Agentless localization): for repo tasks, rank the source files most relevant to the most\n # recent failure (traceback + identifiers) and surface them in the proposal/repair prompt so the\n # Developer edits the right place. Off by default; repo tasks only.\n localize_faults: bool = False\n # A2 surrogate-guided proposal (BO-lite): fit a k-NN surrogate over (params->metric) and propose\n # by acquisition instead of random/hill-climb. Numeric-bounds tasks only; bootstraps via the\n # base Researcher. Off by default. `surrogate_explore` = UCB-style exploration weight.\n surrogate_proposer: bool = False\n surrogate_explore: float = 0.1\n # E2 researcher panel: generate K candidate ideas per proposal and keep the one ranked best by a\n # cheap empirical surrogate over the (params->metric) history (NOT an LLM-judge). 1 = off.\n researcher_panel: int = 1\n # A1 ASHA / successive-halving: reduction factor (keep top 1/eta per rung) and rung budget.\n asha_eta: int = Field(default=3, ge=2)\n # Rung-0 width for ASHA/BOHB (the wide base of cheap drafts). 0 = use n_seeds (default,\n # preserving prior behavior); set >0 to seed a wider/narrower base independent of n_seeds.\n asha_rung_nodes: int = Field(default=0, ge=0)\n # A6 proxy/predictive scoring: cheaply rank a candidate's potential from early-stage signals\n # and skip a full eval for the doomed bottom fraction. 0.0 = off (no candidate skipped).\n proxy_scoring: bool = False\n proxy_kill_fraction: float = Field(default=0.0, ge=0.0, le=0.9)\n # === Novelty / dedup ==================================================================\n # E1 novelty/dedup gate: reject near-duplicate proposals (normalized param-space distance below\n # `novelty_epsilon`) by deterministically nudging them off the duplicate — stops the search\n # wasting evals re-trying the same idea. Audit event `novelty_rejected`. Off by default.\n # Novelty/dedup MODE selector: how a fresh proposal is checked against already-tried experiments.\n # \"off\" — no explicit gate; the agentic Researcher's own read-the-history judgment is trusted.\n # \"algo\" — the deterministic gate: numeric param-distance (`novelty_epsilon`) + optional embedding\n # similarity (`novelty_semantic`). Cheap, no LLM call, but can't explain itself.\n # \"llm\" — an LLM adjudicates (reads the real experiments via tools) whether the idea is a\n # near-duplicate and, if so, asks the Researcher once more for something different. One\n # extra LLM call per proposal — highest quality, follows the \"let the LLM decide\" line.\n novelty_mode: str = \"llm\"\n # Legacy sub-toggles, honored by the \"algo\" mode (and back-compat: novelty_gate=True forces \"algo\"):\n novelty_gate: bool = False\n novelty_epsilon: float = 0.05\n # T5 semantic novelty (active whenever the deterministic gate runs — novelty_mode=algo,\n # novelty_gate=true which is the legacy alias for algo, OR the Strategist novelty stance is\n # \"explore\"; a no-op only under novelty_mode=llm/off with a non-explore stance): reject a proposal\n # whose idea TEXT embeds within\n # `novelty_semantic_threshold` cosine of an existing node's. OFF BY DEFAULT: novelty is the agentic\n # Researcher's call, made by READING the actual prior experiments (read_experiment /\n # find_analogous_across_runs / list_experiments), NOT an embedding-similarity auto-reject that can't\n # explain itself and mis-fires on paraphrases. Semantic/param search stays available as TOOLS in the\n # Researcher's context; the LLM decides whether an idea is a true duplicate.\n novelty_semantic: bool = False\n novelty_semantic_threshold: float = Field(default=0.92, ge=0.5, le=1.0)\n # T10: debug-lineage depth bound for every policy — how many error-feedback repairs a failing\n # lineage gets before it is abandoned. The old default (1) abandoned lineages after ONE repair;\n # multi-turn/deeper debugging is a verified lever (AIRA2 ReAct debug +5.5 percentile points).\n debug_depth: int = Field(default=2, ge=1)\n # P4 operator bandit (GreedyTree): replace the fixed merge/ablate cadences with a\n # deterministic UCB over per-operator yield (Δmetric per eval-second, folded from the run).\n # Off by default (the cadences are well-tested and the bandit has no direct published\n # ablation); `thorough` turns it on.\n operator_bandit: bool = False\n # P1 hypothesis ledger: ask the Researcher to state the one-line hypothesis each experiment tests,\n # register deep-research directions as hypotheses, and track them to a verdict on the board. ON by\n # default (audit-only — never changes selection). Set False to drop the prompt nudge + registration.\n track_hypotheses: bool = True\n # E4/M2/M3 reflection-memory -> priors (gradient-free cross-run meta-learning): at run end distill\n # the winner into `<memory_dir>/meta_notes.jsonl` AND structured lessons (incl. NEGATIVE results:\n # tested/abandoned hypotheses + failure themes) into `lessons.jsonl` with a task fingerprint; at run\n # start, inject exact-task notes + fingerprint-matched lessons from SIMILAR tasks into the proposal\n # prompt. ON by default — but a NO-OP until `memory_dir` is set (that's where cross-run memory lives).\n reflection_priors: bool = True\n # M6 comparative lessons (MARS \"comparative reflective memory\", doc 13 §7 item 2): distill\n # credit-assigned lessons from PAIRS of solutions — which SPECIFIC change made a child beat\n # (or regress from) its parent, and what fixed a failure — on top of the one-shot run-end\n # reflection. At most one extra LLM call per distillation (all pairs batched); offline a\n # deterministic param-diff credit stands in. Gated on reflection_priors + memory_dir like the\n # rest of cross-run memory — and since memory_dir has a default (~/.looplab/memory), this is\n # ACTIVE out of the box; clear memory_dir to disable cross-run memory wholesale.\n comparative_lessons: bool = True\n # M6 mid-run cadences (doc 13 §7 item 5 — the AgentRxiv live-share pattern): every N created\n # nodes, WRITE comparative lessons to the shared cross-run store during the run (lessons_every\n # — one batched LLM call per firing) and RE-READ the store so lessons distilled by CONCURRENT\n # runs reach this run's proposals (lessons_refresh_every; file re-read, no LLM call, skipped\n # when the store is unchanged). ON by default (every 4 nodes) and, like comparative_lessons\n # above, live out of the box because memory_dir defaults on; 0 = off — run-end write /\n # run-start read only, the pre-M6 behavior.\n lessons_every: int = Field(default=4, ge=0)\n lessons_refresh_every: int = Field(default=4, ge=0)\n # B3 output redaction: mask credentials (known key shapes + high-entropy tokens) in the\n # stdout/stderr tail before it is persisted to the event log / spans / UI — a leaked secret in a\n # print()/traceback must not land in the durable log. Off by default; recommend on for untrusted.\n redact_output: bool = False\n # B5 reward-hacking detector: a host-side monitor that flags suspicious wins (grader/answer-key\n # access, runtime writes to frozen files, suspiciously-perfect metrics) as a `reward_hack_suspected`\n # audit event in the Trust panel. Off by default. Whether a flag CHANGES selection is governed by\n # `trust_gate` below (default: audit-only, never changes selection).\n reward_hack_detect: bool = False\n # Trust enforcement (T2): what a reward-hack / data-leakage flag actually DOES to selection.\n # \"audit\" (default) = surface it in the Trust panel, never change selection (today's behavior);\n # \"gate\" = a flagged node can no longer be selected as BEST **and isn't bred/confirmed from**\n # (breed_excluded — see replay._apply_trust_gate / models.breedable_nodes), but stays\n # FEASIBLE so it still counts for diversity/audit — closes the \"a hacked/leaky node can\n # win OR seed its lineage\" hole;\n # \"block\" = additionally mark it fully INFEASIBLE (removed from feasible_nodes entirely).\n # Deliberately gates only high-precision CHEATING/LEAKAGE signals. Broad `critic:*` findings stay\n # advisory; CODEX AGENT: `critic:hardcoded_metric` is the one narrow high-precision critic exception\n # and gates because it requires a literal metric with no computed assignment anywhere.\n trust_gate: str = \"audit\"\n # I3 data-centric: static code-leakage scan of each evaluated solution (fit-before-split,\n # fit-on-test) surfaced into the Trust panel alongside reward-hack flags. Off by default.\n code_leakage_detect: bool = False\n # 4.4 sandbox instrumentation (needs reward_hack_detect): after each eval, flag a RUNTIME write\n # to a protected/frozen file (an answer key or grader tampered mid-run) — behavioral evidence a\n # static code scan misses. ON by default; only fires when the reward-hack detector is on.\n workdir_audit: bool = True\n # D8 decoupled research Verifier: check every Deep-Research memo's CLAIMS against their cited\n # evidence (node ids / urls) before recording — synthesis is the documented weak link (Kosmos\n # 57.9% accurate). Deterministic layer always; an LLM rubric pass when the DeepResearcher has a\n # client. The verdict cannot change this run's node/champion, but finalize uses aligned supported\n # verdicts as the evidence gate for persisted D8 cross-run claims; unavailable/misaligned stays\n # unverified. ON by default (no-op with no memo/claims).\n research_verify: bool = True\n # C4 independent critic: an execution-free critic of each solution (stub / hardcoded-metric /\n # params-ignored; on host-graded tasks the metric checks become a submission-output check)\n # surfaced in the Trust panel. Broad findings are advisory; `critic:hardcoded_metric` can gate under\n # trust_gate=gate/block. Off by default.\n critic_check: bool = False\n # A7 Strategist (NEW, user-requested): optional LLM/rule meta-controller that picks the search\n # policy/allocator + operator mix + fidelity (+ Developer backend) per situation. Config-first:\n # every choice the Strategist makes is also a direct knob. Defaults to \"agent\" so the agent adapts\n # the search policy/operators/fidelity per situation; set \"off\" for static config-driven search.\n # \"agent\" = a tool-using Strategist that READS the run/data/sibling-runs/KB/memory (B1-guarded)\n # before deciding, instead of the single-shot \"llm\" call over aggregate stats.\n strategist_backend: str = \"agent\" # \"off\" | \"rule\" | \"llm\" | \"agent\" — default AGENTIC: the\n # Strategist READS the run/data/sibling-runs/KB/memory with tools before deciding, not a single-shot\n # call over aggregate stats. \"llm\" = the old non-agentic single-shot; \"rule\"/\"off\" = no LLM.\n strategist_every: int = Field(default=3, ge=1) # consult cadence (created nodes)\n # PART V (F1): concept CLASSIFIER re-tag + consolidation cadence, DECOUPLED from strategist_every. The\n # LLM concept map is heavier and slower-moving than a strategy consult, so it refreshes on its own\n # (sparser) interval. Researcher-authored idea.concepts still fold immediately at node_created — this\n # only paces the classifier-evidence + consolidation refresh (and the concept-coverage pivot snapshot).\n concept_retag_every: int = Field(default=30, ge=1)\n # === Confirmation & holdout ===========================================================\n # Multi-seed confirmation (I12, ADR-15): confirm the top-k under N seeds before\n # finishing. 0 disables (default). Only meaningful when eval has variance.\n confirm_top_k: int = 0\n confirm_seeds: int = 0\n # D1 seed-holdout discipline: the FIRST seed the confirm phase uses. Search evals run with the\n # implicit seed 0 (LOOPLAB_EVAL_SEED unset -> \"0\"), so a base of 1 keeps every confirm split\n # DISJOINT from anything the search optimized against — the confirm metric is then a\n # generalization signal, not a re-measurement (AIRA: selecting on a signal the search saw\n # overfits by 9-13 pp). Set 0 to restore the legacy overlapping seeds 0..N-1.\n confirm_seed_base: int = Field(default=1, ge=0)\n # D1 holdout-gated promotion (B6, Arbor-style): for host-graded tasks, reserve this fraction of\n # the held-out labels as a FINAL holdout partition the search never sees — every search/confirm\n # eval is scored on the remaining rows only, and at finish the val-top-k are re-scored on the\n # holdout partition (free: the predictions already exist). 0.0 = off (legacy full-label scoring).\n holdout_fraction: float = Field(default=0.25, ge=0.0, le=0.9)\n # Champion selection prefers the HOLDOUT metric among the nodes that have one (the val-top-k).\n # Recorded in run_started so replay applies the same rule; old logs fold to False (legacy pick).\n # The per-node val-holdout `generalization_gap` folds into the Trust panel either way.\n holdout_select: bool = True\n # How many val-leaders get a holdout evaluation at finish (host-graded tasks).\n holdout_top_k: int = Field(default=3, ge=1)\n # R1-c / Part IV — calibrated §12-verifier tie-break in best-selection. When a fresh node's robust\n # metric EXACTLY TIES an already-eligible node, the engine runs the §12 verifier (grounded on each\n # node's realized result, `selection_criteria`) and records a soundness score per node. The tie-break\n # then applies across the WHOLE selection path: the mean pick breaks a robust_metric tie by the score,\n # and — when `holdout_select` is on — the holdout-slot ranking prefers the sounder node so it isn't\n # denied a holdout eval, and the final holdout override breaks a holdout_metric tie by it too. Strictly\n # a TIE-BREAK — it can NEVER move a node ahead of a strictly-better metric (§21.7 advisory-never-\n # overrides). Best-effort: verification is LAZY (only fires on an actual tie) and bounded per cadence,\n # so a large/late tie stays on deterministic metric+id order unless one atomic treatment scores every\n # member. Recorded in run_started so\n # replay applies the same rule; old logs fold to False (legacy pick). Opt-in (default off); needs a\n # reachable LLM client. Enable only after validating calibration offline with `verifier.calibrate`.\n # See trust/verifier.py + core/fitness.py.\n select_verifier: bool = False\n # R1-d (§21.19): widen the verifier tie-break from EXACT-metric to a conservative STATISTICAL tie.\n # The band is capped by both the metric leader's SE and the pooled SE-of-difference, so challenger\n # variance cannot widen it and a significant difference is never a tie (§21.7). Off -> exact only.\n verifier_ci_tie: bool = False\n # Verifier sample count for `select_verifier` (the §12 repeated-sampling expectation). 3 tames the\n # single-shot variance; 1 = single-shot (cheaper, noisier).\n select_verifier_samples: int = Field(default=3, ge=1, le=32)\n # Budget (I13): hard wall-clock ceiling; the run aborts cleanly when exceeded.\n max_seconds: float | None = None\n # Eval-compute budget (#2): hard ceiling on cumulative time spent INSIDE evals (training\n # runs), separate from wall-clock. Survives resume (summed from the event log). The real\n # guard against a silent multi-hour sweep when an eval is a minutes-hours training run.\n max_eval_seconds: float | None = None\n # Cross-run memory (I19, ADR-10): if set, the best result of each run is stored as\n # a case here, and the cases become retrievable knowledge for future runs.\n memory_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"memory\"))\n # HITL (I21, ADR-11): pause for human approval of the final best before finishing.\n require_approval: bool = False\n # Diversity archive (I22): niche bucket width in parameter space.\n archive_resolution: float = 1.0\n # Coverage read-model (narrowing signal): at the strategist cadence, compute a breadth summary\n # (themes / param-niches / theme entropy / dominant-theme fraction) from the folded run, record\n # it as a `coverage_snapshot` audit event, and surface it into the Strategist's decision context.\n # Deterministic, cheap, and purely additive context — no search-behavior change on its own. On by\n # default (like the rest of the always-on situational context). See search/coverage.py.\n coverage_context: bool = True\n # PART IV Phase 2a live steering (§21.11/§21.13). When on, the `concept_retag_every` cadence (NOT\n # `strategist_every` — the producer gates on `_should_consult_concepts`, which uses the seed boundary\n # + `concept_retag_every`, default 30) records a\n # concept-graph coverage + uncovered-region snapshot (deterministic heuristic tagger over the\n # task-type skeleton) and, on an `explore` stance, the Researcher's novelty hint names the exact\n # uncovered regions (\"0 coverage in {negatives/external-mining, distillation} — go there\") instead\n # of the vague \"broaden\". ON by default in the product Settings (ce4a379), EngineOptions off: it\n # only enriches an already-mode-gated explore hint, never forces exploration, and no-ops for a task\n # with no curated concept skeleton. The snapshot is auditable, but its prompt cue can change future\n # candidate generation; it does not directly rewrite best-metric selection. See search/concept_graph.py.\n # DESIGN NOTE (2026-07-17 critique): now ON unconditionally, but a toy / non-ML task yields EMPTY concept\n # tags while still paying the per-node LLM concept cost (~10 calls/node with the full Part IV/V bundle).\n # Consider AUTO-QUIESCING: if the heuristic tagger returns empty for the first N nodes (no concept signal),\n # skip the LLM concept work for the run — keeps it on for ML tasks, free on toy/degenerate ones.\n concept_pivot: bool = True\n # PART IV Phase 2b — D3 graded novelty into the LIVE novelty gate (§21.4/§21.13). When on and the\n # task has a curated concept skeleton, the novelty gate first grades a proposal over the concept\n # graph: a level-4 \"same direction, DIFFERENT implementation\" or a level-5 \"re-opens a\n # wrongly-abandoned FAILED direction\" proposal is ALLOWED through unchanged — the flat LLM/semantic\n # dedup gate can't tell \"this DCL tweak\" from \"the whole DCL branch\" and would wrongly reject a\n # legitimate variant or never re-open a sound-but-killed direction (the node_63 archetype). Levels\n # 1/2/3 (identical / near-dup / prior-run) still fall through to the existing gate. Deterministic\n # (heuristic tagger, no LLM), audit event `novelty_graded`, replay-safe. ON by default in the product\n # Settings (ce4a379), EngineOptions off; no-ops for a task with no skeleton. See search/graded_novelty.py.\n graded_novelty: bool = True\n # PART IV Phase 2b — D7 capability-expansion forced-jump DIRECTIVE (§21.8/§21.13, issue #7). When on\n # and the concept-graph cadence detects action-space LOCK-IN (the search has stayed inside one D5\n # branch for a long consecutive streak) on an `explore` stance, the Researcher's novelty hint\n # ESCALATES from \"broaden\" to \"expand the ACTION SPACE — build the missing infra (external mining,\n # a new data pipeline, a different eval), do NOT swap another {locked} lever.\" Prompt-cue only, on\n # top of the already-mode-gated explore hint; never adds a scored policy action and never touches\n # selection (the SCORED capability-expansion operator waits on the R1/SearchFitness gates, §21.13).\n # Reads the 2a concept-coverage snapshot, so it needs `concept_pivot` to record one. OPT-IN\n # (default off). See engine/proposal_cues.py + search/lock_in.py.\n capability_expansion: bool = False\n # PART IV cross-run Step 0 (§21.20.12/§21.20.13). Universal task-fingerprint tokenization: drop the\n # ASCII-only `[a-z0-9]` allowlist on goal keywords so a non-Latin goal (Russian, CJK, …) is NOT\n # silently dropped from its cross-run fingerprint (today such a goal keys only on kind/dir/metric and\n # never reaches SIMILAR-task priors/lessons/cases). Uses `[^\\W_]+`/`.casefold()` — same splitting as\n # before, any script. ON by default in the product Settings (ce4a379); the bare-library EngineOptions\n # default stays off, so a run pinned to it won't silently re-key a portfolio mid-flight. See engine/memory.py.\n fingerprint_universal: bool = True\n # PART IV cross-run Step 2 (§21.20). Fill graded-novelty's `prior_concepts` from the cross-run\n # ConceptCapsuleStore: at run end write a per-run concept capsule (the shipped `node_concepts` tags +\n # best outcome, keyed by task_fingerprint) to `memory_dir`; when a later SIMILAR run proposes an idea\n # whose concept was tried before, SURFACE the prior outcome as a `cross_run_prior` audit event (never\n # reject — D3 level 3). Audit-only, off the selection path; ON by default in the product Settings\n # (ce4a379), EngineOptions off. Needs a `memory_dir` to share capsules.\n # See engine/memory.py (ConceptCapsuleStore) + engine/novelty.py.\n # PREREQUISITES (CODEX — this flag is NOT standalone): the WRITE needs per-run concept tags\n # (`node_concepts`, produced by `concept_pivot`/F1 tagging) — no tags => no capsule; the SURFACE path\n # runs inside the graded-novelty precheck, so it also needs `graded_novelty` on. With only this flag +\n # `memory_dir` it persists/surfaces nothing. Enable `concept_pivot` + `graded_novelty` alongside it.\n # A non-Latin (Russian/CJK) portfolio should ALSO set `fingerprint_universal`, else the capsule's goal\n # tokens are dropped and its fingerprint over-matches on kind/dir/metric alone (the novelty direction\n # gate narrows this, but goal-word similarity is still lost).\n cross_run_concepts: bool = True\n # PART V (B): run-base + node-DELTA concept authoring. When on, once the first evaluated node has\n # authored concepts the engine seeds RunState.run_base_concepts from them (a one-shot EV_RUN_CONCEPTS,\n # idempotent because it only fires while the base is empty), and the Researcher's proposal hint tells it\n # to author only `concepts_added`/`concepts_removed` vs the run base + its parent — minimal per-node\n # annotations that inherit down the DAG (a swap propagates). Off -> every node authors its full concept\n # set as before. Product-on; the bare EngineOptions mirror defaults off (test_options_divergence).\n concept_run_base: bool = True\n # PART IV cross-run Step 5 advisory (§21.20.5). Fold the bounded cross-run CONTEXT PACK — evidence-\n # grounded claims with BOTH support and counter-evidence (Step 4) + a portfolio-coverage line (Step 3) —\n # into the Researcher's proposal prompt, exactly like the E4 cross-run prior note. Advisory ONLY: it is\n # prompt-grounding: it can change the Researcher's later candidates, but does not directly rewrite\n # best-metric selection (§21.7). Reads `memory_dir` (lessons.jsonl + concept_capsules.jsonl); \"\" when\n # empty. ON by explicit experimental product choice; no frozen A/B validation is claimed. The\n # bare-library EngineOptions default stays off (engine/options.py, frozen in\n # test_options_divergence). See engine/proposal_cues.py.\n cross_run_advisory: bool = True\n # PART IV cross-run §21.20.13 (full CR of the lean fuzzy claim merge). Switch the claim read-model to the\n # SCOPE+POLARITY-safe STRUCTURED claim key (engine/claim_key.py): claims from different tasks never\n # merge, opposite polarity (\"X helps\" vs \"X never helps\") is surfaced as a CONTRADICTION instead of being\n # collapsed, paraphrase/inflection variants group by exact structured key (no transitive over-merge), and\n # operator governance is scope-precise (a decision in task A cannot reach a same-worded claim in task B).\n # Affects the `cross_run_advisory` context pack only; ON by default in the product Settings (ce4a379);\n # the bare-library EngineOptions default stays off (engine/options.py). See engine/claims.py.\n cross_run_structured_claims: bool = True\n # PART IV cross-run §22.4 (AGENTIC taxonomy steward). At finalize, when an LLM client is available, let\n # the concept steward (engine/concept_steward.py) review the freshly-updated portfolio concept graph and\n # PROPOSE a curation — merge duplicate slugs / split conflated ones / purge noise. Proposals are LOGGED to\n # `concept_curation_log.jsonl` for operator ratification (via the serve/CLI governance surface); the LLM\n # never mutates fold state. The calls run synchronously during finalize, so they add latency and may add\n # paid inference; a caught steward failure does not prevent terminal completion or trigger a retry.\n # ON by explicit experimental product choice when `memory_dir` + an LLM backend are present; the\n # bare-library EngineOptions default stays off (engine/options.py). See engine/lessons.py.\n cross_run_curation: bool = True\n # Deprecated compatibility flag. Steward output is untrusted and finalize is proposal-only regardless\n # of this value; retained so old snapshots still validate and logs can disclose that auto was requested.\n cross_run_curation_auto: bool = False\n # Role backend (ADR-7/14): \"toy\" (offline optimizer) | \"llm\" (live model).\n backend: str = \"toy\"\n # Developer backend (ADR-7): \"default\" (templated/LLM from the task) or an external\n # CLI coding agent: \"opencode\" | \"aider\" | \"goose\" | \"continue\".\n developer_backend: str = \"default\"\n # C2 best-of-N: generate N candidate implementations per node and keep the best by an\n # execution-free reward (static validity + metric-print). 1 = off. In-house LLM developer only\n # (not external agents — ADR-7 cost rule). The top SWE-bench reliability lever for weak models.\n best_of_n: int = 1\n # D10 list-wise best-of-N selection: when the top static-scorers TIE, break the tie with a\n # comparative LLM selection over the candidates presented together (+~3 pts vs pointwise on\n # GAIA, arXiv:2506.12928). The static score stays the primary filter — the LLM is a weak\n # comparative prior, never the sole oracle. ON by default (no-op when best_of_n==1).\n best_of_n_listwise: bool = True\n # === Foresight / predict-before-execute ===============================================\n # FOREAGENT predict-before-execute (arXiv:2601.05930): use the LLM as an IMPLICIT WORLD MODEL to\n # predict — WITHOUT executing — which candidate / hypothesis will score best, primed with a\n # Verified Data Analysis Report (data profile / brief) + the accumulated experiment memory. Master\n # switch, ON by default; a no-op wherever there is nothing to rank (best_of_n==1 and\n # foresight_panel==1) or no LLM client. See `looplab/search/foresight.py`.\n foresight: bool = True\n # Hypothesis foresight breadth: generate K candidate ideas per proposal and keep the one PREDICTED\n # best pre-execution by the world model — it ranks the STRUCTURAL / text ideas the numeric k-NN\n # surrogate (researcher_panel) is blind to, primed with the data profile + memory (the synergy).\n # >1 enables it (LLM backend only; needs `foresight` on); 2 = on by default at modest cost, 1 = off.\n foresight_panel: int = 2\n # AGENTIC foresight: run the ranking (hypothesis-board prioritization + K-idea pick) as a TOOL-USING\n # loop that can pull actual experiment results / data facts before deciding, instead of a one-shot\n # prediction from a pre-baked report. ON by default (needs foresight + a client; falls back to the\n # one-shot predictor on any hiccup). A few extra LLM calls per proposal; set False for the cheaper\n # single-call ranker.\n foresight_agentic: bool = True\n # §1 foresight confidence gate: the minimum predicted confidence at which a predict-before-execute\n # pick is ACTED ON. Below it the ranker abstains — the K-idea panel falls back to the first\n # proposal, best-of-N falls through to the D10 tie-break — instead of committing a low-confidence\n # choice. 0.0 (default) = OFF (act on every pick, the historical behavior); raise toward ~0.5 to\n # make the world model defer when it isn't sure. Pairs with the foresight track record (§1) the\n # predictor is now primed with. Range 0.0-1.0.\n foresight_min_confidence: float = 0.0\n # PART IV Phase 2c — replace the world model's SELF-REPORTED confidence (measured Pearson≈0 with the\n # realized outcome, §21.12) with a CALIBRATED §12-verifier score. When on and a client is available,\n # after the K-idea ranker picks the predicted-best candidate the §12 verifier scores it (grounded +\n # repeated + criteria-decomposed: `foresight_criteria` — likely to improve the objective, and\n # sound/feasible) and THAT calibrated score becomes the confidence the `foresight_min_confidence` gate\n # and the telemetry use. Degrades to the self-reported confidence without a client or on any verifier\n # error (the telemetry records which source was used). ON by default in the product Settings (ce4a379),\n # EngineOptions off; a few extra LLM calls per acted-on proposal. See looplab/trust/verifier.py + search/foresight.py.\n # REVISIT (cost/quality, not yet resolved): a live classification run measured ~10 LLM calls & ~3.8 min/node\n # on deepseek-v4-flash with the full Part IV/V default bundle; the calibrated verifier is part of that per-node\n # tax. Kept ON deliberately — it raises decision quality (self-reported foresight confidence has ≈0 Pearson\n # with realized outcome), and lowering quality to save calls is the wrong trade. TODO: run a frozen quality/cost\n # A/B (verifier-on vs -off) to quantify the recall@objective gain per extra call before considering a default flip.\n foresight_verify: bool = True\n # Verifier sample count for `foresight_verify` (the §12 repeated-sampling expectation on a no-logprob\n # backend). 3 tames the single-shot variance §21.12 measured; 1 = single-shot (cheaper, noisier).\n # RESUME-critical: this value is persisted in config.snapshot.json, so a hard `le` schema bound would\n # make an old run started with a larger LOOPLAB_FORESIGHT_VERIFY_SAMPLES un-resumable (Settings(**snapshot)\n # would raise on `looplab resume` / finalize-recovery / serve replay). CLAMP into [1, MAX] via a validator\n # instead of rejecting — the runtime cap is preserved without breaking the snapshots/env-compat rule.\n foresight_verify_samples: int = 3\n\n @field_validator(\"foresight_verify_samples\")\n @classmethod\n def _clamp_foresight_verify_samples(cls, v: int) -> int:\n # Coerce a persisted/env value into [1, MAX] rather than reject (see field note above).\n try:\n v = int(v)\n except (TypeError, ValueError):\n return 3\n return max(1, min(v, MAX_FORESIGHT_VERIFY_SAMPLES))\n\n # T7 LLM response cache: serve an identical DETERMINISTIC (temperature 0) request from an\n # in-process content-addressed cache instead of re-hitting the model — cuts cost on\n # retry/panel/verify flows. NEVER caches sampling calls (temp>0: best-of-N / panel / novelty\n # re-propose must vary). Off by default (most role calls run at temperature>0 anyway).\n llm_cache: bool = False\n agent_cmd: str | None = None # override the agent's launcher (path / wrapper)\n # External-agent validation (ADR-7): wrap a CLI-agent Developer with a validator that\n # audits each output (no-op/syntax/crash/timeout), retries with feedback, and falls\n # back to the in-house LLM Developer. Off -> use the raw agent output unchecked.\n validate_agent: bool = True\n agent_max_retries: int = 1 # re-prompts of the agent on an invalid result\n # Patch-gated multi-file agent (ADR-7 Rule 3): run the CLI agent in a git worktree and\n # accept only edits whose paths match `agent_surface` (reject-not-strip out-of-surface\n # touches). Lets the agent create helper modules, not just solution.py. Degrades to\n # whole-file readback if git is unavailable.\n agent_patch_gate: bool = True\n agent_surface: list[str] = [\"*.py\"] # edit-surface allow-list (globs)\n # Trust policy for an agent-authored eval/metric adapter (RepoTask onboarding, Phase 3):\n # \"ratify_freeze\" waits for human approval, \"autonomous\" accepts the proposed spec without that\n # gate, and \"ratify_freeze_drift\" also enforces the configured independent drift cross-check.\n eval_trust_mode: str = \"ratify_freeze\"\n # RepoTask node seeding policy (run-wide fallback; a task/editable `seed_mode` overrides):\n # \"auto\" (default) copies git-tracked files when the editable is a git repo (so a tree bloated\n # with untracked artifacts isn't deep-copied per node), else a full copy; \"tracked\" forces\n # code-only; \"all\" forces a full recursive copy (legacy). Genesis authors per-task from the\n # user's words; this is the global default when unspecified.\n seed_mode: str = \"auto\"\n llm_model: str = \"qwen3:8b\"\n # === LLM / transport ==================================================================\n llm_base_url: str = \"http://localhost:11434/v1\" # Ollama OpenAI-compatible endpoint\n llm_temperature: float = 0.6\n # H3 per-role model presets (5090 recipe): optionally run the Researcher and Developer on\n # DIFFERENT models/endpoints (e.g. Developer=Qwen3-Coder-30B for code, a fast model for breadth).\n # Blank = use the shared llm_model/llm_base_url. Generalizes the role-backend swap to per-role.\n researcher_model: str | None = None\n developer_model: str | None = None\n researcher_base_url: str | None = None\n developer_base_url: str | None = None\n # Per-role sampling temperature (§4.1): the Researcher wants breadth (higher temp = more diverse\n # ideas), the Developer wants determinism (lower temp = fewer syntax slips), the Strategist a\n # steady hand. Each overrides the shared `llm_temperature` for that role only; None = use the\n # shared value (byte-identical to before). Flat + nullable, mirroring the per-role model fields.\n researcher_temperature: float | None = None\n developer_temperature: float | None = None\n strategist_temperature: float | None = None\n # Unified self-driving agent: one LLM identity that plays Researcher + Developer (+ Strategist)\n # across pipeline stages, choosing its own model/toolset per stage. ON by default — the agent\n # drives the loop (incl. crash triage: repair/abandon/reject_idea) and, via\n # `agent_drives_actions`, picks the next macro action within a pure legal-action gate. Set both\n # to False for the legacy byte-identical split-role behavior. (No-op unless backend=\"llm\".)\n unified_agent: bool = True\n agent_drives_actions: bool = True\n # Layer 3 Card queue ownership. False preserves the existing policy/unified-pilot action path.\n # True is pinned in run_started because changing the selector on resume would mix two search\n # treatments in one run. When both this and agent_drives_actions are true, Card selection wins.\n card_driven_selection: bool = False\n # Layer 5 bounded speculative Card buffer. Zero is a hard OFF switch and preserves the\n # historical alternating build/eval spine; positive values are pinned by run_started so a\n # resume cannot silently mix search treatments after a snapshot/default edit.\n speculation_depth: int = Field(default=0, ge=0, le=64)\n # Local evidence receipt produced by ``looplab speculation-gate``. This is deliberately absent\n # from the curated Settings UI: positive-depth execution is a calibrated rollout decision, not a\n # casual tuning knob. The Engine validates the receipt against the current scorer,\n # implementation, GPU inventory and raw paired-run evidence before it enables speculation.\n speculation_gate_receipt: str | None = Field(default=None, max_length=4096)\n # Per-stage model/endpoint overrides for the unified agent. Recognized keys (see\n # tasks.build_unified_agent): `propose` (researcher), `implement`/`repair` (developer),\n # `strategy`, `pilot`. Unlisted keys are ignored. Empty = the per-role researcher_*/developer_*\n # models, then the shared llm_model. Explicit map wins. No-op unless `unified_agent` is true.\n agent_stage_models: dict = {}\n agent_stage_base_urls: dict = {}\n llm_parser: str = \"tool_call\"\n # Reasoning/thinking toggle sent IN the request (providers differ — see `llm.reasoning_body`):\n # Qwen3 on vLLM/SGLang -> chat_template_kwargs.enable_thinking (bool)\n # OpenAI/Ollama-v1/DeepSeek -> reasoning_effort (low|medium|high|none)\n # `llm_reasoning`: \"\" = send nothing (server default — current behavior); off = actively disable;\n # on = enable at default depth; low|medium|high = enable at that effort.\n # Defaults to \"high\" so the agent reasons before proposing/repairing/triaging (better code, fewer\n # crashes); set \"\" to send nothing (server default) or \"off\" to disable. The model's chain-of-thought\n # is CAPTURED either way (reasoning_content field or inline <think>). NOTE: to get a SEPARATE\n # reasoning_content field from SGLang/vLLM, the server also needs `--reasoning-parser qwen3`.\n llm_reasoning: str = \"high\"\n llm_reasoning_style: str = \"auto\" # auto | qwen | effort | none — how to shape the request param\n llm_reasoning_extra: dict = {} # raw fields merged into the body (escape hatch, e.g. Anthropic thinking)\n # H1: drive structured calls with the endpoint's constrained decoding (vLLM/SGLang guided_json +\n # response_format json_schema) so weak models can't emit invalid JSON. Off for Ollama (default).\n llm_guided_json: bool = False\n # Stream every LLM request (SSE) and reassemble it, so the request `timeout` is an INTER-TOKEN\n # idle timeout rather than a whole-request deadline: a long-but-alive generation is never cut off\n # (tokens keep resetting the timer), while a genuinely STALLED endpoint (no token for `timeout` s)\n # is detected and retried on a fresh connection — the fix for silent multi-minute hangs. Set False\n # to use one blocking read (old per-op timeout semantics) if an endpoint streams badly.\n llm_stream: bool = True\n # LLM IDLE timeout (seconds). Stream mode: the INTER-TOKEN stall limit — a steady generation is\n # never cut off, only a silent endpoint. Non-stream: bounds the whole read. Raise for endpoints\n # with a long prefill on huge prompts; lower to fail fast on a flaky shared endpoint.\n llm_timeout: float = Field(default=180.0, gt=0)\n # First-byte (response-headers) window for STREAM attempts, seconds: an SSE response sends its\n # headers on admission, so no-headers ≈ a black-holed request — fail over to a fresh connection\n # fast instead of waiting the full idle timeout. Clamped to llm_timeout. Non-stream attempts are\n # NOT bounded by this (their headers arrive only after the whole generation). The default mirrors\n # `DEFAULT_HEADER_TIMEOUT_S` in looplab/core/llm.py is the single source (imported above).\n llm_header_timeout: float = Field(default=DEFAULT_HEADER_TIMEOUT_S, gt=0)\n # Honour HTTP(S)_PROXY / NO_PROXY / SSL_CERT_FILE env vars for LLM calls. Default False: the\n # transport connects DIRECTLY (many internal/OLAP endpoints are reachable only by bypassing an\n # ambient proxy — a picked-up proxy yields \"connection refused\"). Set true when the endpoint is\n # reachable ONLY via a corporate proxy or needs a custom CA bundle from the environment.\n llm_trust_env: bool = False\n # H4/C2: compact the growing agentic tool-call history once it exceeds this many chars (auto_summary\n # LLM-summarizes the stale middle; else middle-truncate). Sized to the model's real context window so\n # files read earlier STAY in context instead of being compacted away and re-read: ~1,000,000 chars ≈\n # 250k tokens (≈4 chars/token), matching the configured deepseek-v4-flash (≥200k tokens confirmed).\n # The old 120k-char (~30k-token) default was ~8× too aggressive and drove a read-thrash loop. 0 = off.\n context_budget_chars: int = 1_000_000\n # M5: the Researcher's always-on experiments digest budget (chars). 0 = AUTO — scales with the\n # run size (60 chars/node, bounded [1200, 6000]) instead of one flat cap for an 8-node toy run\n # and a 200-node benchmark run alike. Set a positive value to pin it.\n digest_char_cap: int = Field(default=0, ge=0)\n # D11 compression model slot (open_deep_research's four-slot pattern): a dedicated CHEAP model\n # for agent-history summarization (C2 auto-summary), so compression doesn't pay the main\n # model's price. Blank = use each loop's own model (legacy). `compressor_base_url` blank =\n # the shared llm_base_url.\n compressor_model: str | None = None\n compressor_base_url: str | None = None\n # === Agentic tool-loop ================================================================\n # Agentic tool-loop limits — apply to EVERY tool-using agent (the LLM Researcher, the unified\n # agent's pilot + crash-triage, Deep-Research, the run-chat Boss, the genesis repo-scout, and the\n # cross-run report synthesizer). The loop lets the model call read-only tools across turns before\n # emitting its structured result. Both default to UNLIMITED so the agent takes as many turns / as\n # long as it needs — it is never cut off mid-reasoning; set a positive value here (or per-run, or\n # in the UI settings) to bound latency/cost. These used to be hardcoded per call site (e.g. the\n # boss at max_turns=3 / 45s), which silently dropped a slow reasoning model to a no-op reply.\n agent_max_turns: int = 0 # max tool turns before the emit is forced (0 = unlimited)\n agent_time_budget_s: float = 0.0 # wall-clock ceiling across the loop's turns (0 = no cap)\n # G · Soft convergence SAFETY NET (not a research cap). Two thresholds on the agent's tool-turn count:\n # at `agent_emit_after` the loop NUDGES the agent once (\"you've investigated enough — emit now\"); at\n # `agent_emit_force` it FORCES the emit from what was gathered. Set GENEROUSLY — a focused researcher\n # may legitimately read many files/experiments before it can propose a grounded idea; these only catch\n # a genuine runaway that keeps issuing DIFFERENT calls forever (which stuck-detection, keyed on\n # repeats, misses). NB: the old 499-read runaway (node 63) was 79% REDUNDANT re-reads caused by a\n # broken read_file — fixing read_file (pagination) is the real cure; this is just insurance. 0 = off.\n agent_emit_after: int = 300\n agent_emit_force: int = 500\n # B1 · No-progress / stuck detection — the safety net that makes \"unlimited turns\" safe. The\n # turn/time ceilings above are only BACKSTOPS; this is what actually stops a runaway loop, on the\n # cheapest signal: when the model repeats the SAME tool call (or ping-pongs between two, or keeps\n # hitting the SAME error) with no progress, the loop forces the final emit and finishes. ON by\n # default; reading DIFFERENT files or running ONE long command never trips it. (OpenHands-style.)\n agent_stuck_detection: bool = True\n agent_stuck_repeat: int = 4 # identical calls in a row that count as \"stuck\" (>=2)\n agent_stuck_alternate: int = 4 # ping-pong cycles between two calls that count as \"stuck\" (>=2)\n # C1 · Self-plan (TodoWrite-style): expose an `update_plan` tool so a long-running agent keeps its\n # OWN working TODO and re-surfaces it every `agent_plan_reinject_every` turns — keeps the goal in\n # view across a long loop so the agent \"writes its own context\" (Claude Code style). ON by default.\n agent_self_plan: bool = True\n agent_plan_reinject_every: int = 5\n # C2 · Auto-summary (ON by default): when the tool-loop history grows long, LLM-summarize the\n # stale middle instead of only middle-truncating it. The trigger is `context_budget_chars` if set,\n # else a built-in high-water mark (context_budget.DEFAULT_SUMMARY_CHARS ≈ 120k chars) so short\n # loops are untouched. Falls back to truncation on any summarizer error; one extra model call per\n # over-budget turn.\n agent_auto_summary: bool = True\n # C4 · Repo-developer plan decomposition. A big feature attempted in ONE developer session made a\n # non-converging reasoning model loop indefinitely — it kept writing + exploring without ever\n # emitting `done` (10k+ LLM calls / hours; agentic stuck-detection can't catch VARIED exploration,\n # only literal repeats). Fix: the developer first proposes an ordered plan of ATOMIC steps; a\n # multi-step plan is executed step-by-step, each in a FRESH bounded session with only that step's\n # scope + the files accumulated so far, syntax-validated per write. Every session stays\n # small-context and convergent. ON by default for repo tasks (a 1-step plan == the old single pass).\n developer_plan_decompose: bool = True\n developer_plan_min_steps: int = 2 # a proposed plan with >= this many steps runs step-by-step\n developer_plan_max_steps: int = 8 # cap on plan length (a runaway planner can't spawn 100 steps)\n # Hard per-session backstop for the repo developer — the ONE write-heavy agent that can run away\n # even with stuck-detection on (varied writes/reads never trip the repeat/ping-pong signal). Bounds\n # the plan phase, EACH step, and the single-session fallback. Unlike the global agent_* limits\n # (0 = unlimited, meant for read-only agents), the developer always gets a FINITE ceiling so a\n # model that never emits `done` fails cleanly with the code it wrote so far, instead of looping.\n developer_session_max_turns: int = 500\n developer_session_time_budget_s: float = 1200.0 # 20 min wall-clock per developer session\n # Phase-handoff summaries. Each LLM phase in a node build (Researcher·propose → Developer·stages →\n # plan → implement) ends with ONE extra LLM call that distills its transcript — the repo structure\n # it mapped, files/data confirmed, decisions made — into a brief injected into the NEXT phase (even\n # across the role boundary). The next phase TRUSTS that instead of re-reading the same files, which\n # cuts the biggest source of tool-call thrash (stages/plan/implement each re-exploring the repo).\n # One summary call per phase in exchange for many fewer read calls downstream. ON by default.\n phase_handoff_summary: bool = True\n # Observability (ADR-17): capture each LLM call's full prompt + completion into the active\n # span (spans.jsonl) so the UI can show exactly what the model read and wrote per node.\n # Diagnostics only — `replay.fold` never reads spans. Default on for local single-user; set\n # LOOPLAB_TRACE_LLM_IO=0 to suppress (e.g. to keep spans.jsonl small or avoid storing prompts).\n trace_llm_io: bool = True\n # Run-introspection tools (ON by default): make the LLM Researcher (and DeepResearcher) a\n # tool-using agent that can read its OWN experiments (list/read/find-analogous/themes/code) and\n # the task data (schema/profile/asset) mid-loop, instead of seeing only best+parent. Advisory —\n # never changes best-selection. Off = the legacy single-shot Researcher (richer digest still added).\n researcher_tools: bool = True\n # Cross-run introspection: give the agentic Researcher / pilot read-only tools to look at\n # SIBLING runs (same task_id, same run-root) — list them, read an experiment / its code, and\n # find analogous configs across runs — so a run can build on what neighbouring runs already\n # learned instead of rediscovering it. Advisory; never changes best-selection. Needs the run's\n # own dir wired through (no-op for unit-built roles). Off = the legacy single-run view only.\n cross_run_tools: bool = True\n # Read-only access to EVERY run on this machine ACROSS ALL TASKS (not just same-task siblings):\n # the Developer/Researcher get list_all_runs + read_run_code + read_run_experiment so they can read\n # the code + result of ANY past experiment anywhere and reuse an approach. Broader than\n # `cross_run_tools` (same-task only); the agent decides when a foreign run is relevant. Advisory;\n # never changes best-selection. Needs the run's own dir wired through (no-op for unit-built roles).\n all_runs_tools: bool = True\n # PART V §22 — read-only CROSS-RUN KNOWLEDGE tool for the reasoning roles (Researcher, Strategist,\n # deep-research, and a Developer-scoped variant). Adds `cross_run_prior_attempts` / `cross_run_claims`\n # / `cross_run_atlas` over the §21.20 read-models (concept overview + claim assessments + atlas), read\n # from `memory_dir` (lessons.jsonl + concept_capsules.jsonl). Read-only is a storage-authority\n # boundary, not a behavioral no-op: retrieved content can steer later proposals, and a tool-using\n # reasoning loop can consume additional model calls. The tools never mutate cross-run truth (facts\n # are engine-written, verdicts operator-ratified, §22.4) or directly select a winner. ON by explicit\n # experimental product choice, EngineOptions off; stores only exist once Part-IV features have run.\n cross_run_read_tools: bool = True\n # Agentic retrieval (ADR-16): if set, the LLM Researcher gets grep/kb_search/read\n # tools over this directory of markdown notes and chooses when to use them.\n knowledge_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"knowledge\"))\n # T4 real embeddings: model id for an OpenAI-compatible `/embeddings` endpoint used for\n # kb_search / case retrieval. Blank (default) = the dependency-free lexical `hash_embed` (today's\n # behavior). Set e.g. \"nomic-embed-text\" (Ollama) or \"text-embedding-3-small\" for SEMANTIC\n # retrieval; a misconfigured/offline endpoint degrades back to hash_embed at call time (never\n # crashes a run). `embed_base_url` blank = reuse `llm_base_url`; the shared `llm_api_key` is used.\n embed_model: str | None = None\n embed_base_url: str | None = None\n # Harmonic memory (Memora, ICML'26 — idea import). When ON, cross-run cases and knowledge notes are\n # indexed by a short ABSTRACTION + cue ANCHORS instead of their raw text, near-duplicate memories are\n # CONSOLIDATED into one entry under a matching abstraction, and `kb_search`/case retrieval EXPAND\n # through the top hits' anchors to surface related-but-not-similar memories. ON by default. Set\n # `memora=false` to restore the pre-Memora raw-text index. Never a source of truth — abstractions\n # live only in the derived, rebuildable retrieval index (never the event log or canonical cases.jsonl).\n memora: bool = True\n # Write abstractions with the wired chat model (richer than the deterministic lexical fallback). ON\n # by default; results are CACHED by content hash (see `memora_cache`) so a re-built index doesn't\n # re-call the model on unchanged notes/cases, and any endpoint failure degrades to lexical at call\n # time — so an offline box just gets lexical abstractions, never a crash. Set false to force lexical.\n memora_llm: bool = True\n # === Cross-run memory / Memora ========================================================\n memora_anchors: int = 6 # max cue anchors kept per memory\n # Cosine similarity at/above which a new case is CONSOLIDATED into an existing one (same evolving\n # topic) rather than stored as a separate entry. Only used when `memora` is on.\n memora_consolidate_threshold: float = 0.86\n # Where the LLM-abstraction cache lives (JSON). Blank (default) derives it from `memory_dir`\n # (`<memory_dir>/memora_cache.json`) or else `knowledge_dir` (`<knowledge_dir>/.memora_cache.json`);\n # with neither set the cache is in-memory only (per process). No-op unless `memora_llm` is on.\n memora_cache: str | None = None\n # E3 literature-grounded ideation (network-OPTIONAL): give the agentic Researcher an arXiv search\n # tool to ground ideas in real techniques. Off by default (egress is unreliable on some boxes);\n # fails gracefully if the network is blocked.\n literature_search: bool = False\n # Deep-Research stage (network-OPTIONAL): give the DeepResearcher a general web search/fetch\n # tool (DuckDuckGo) on top of arXiv to read across results + the web before steering the next\n # batch. Off by default (egress is unreliable on some boxes); fails gracefully when blocked.\n web_search: bool = False\n # Cadence for the Deep-Research stage: run it automatically every N created nodes (0 = off; it\n # still fires on a manual `deep_research` control event or a Strategist `request_research`).\n # Default 3: deep research analyzes the accumulating results and steers the next batch (its\n # directions become hints + open hypotheses); paired with `concurrent_research` it overlaps the\n # think with the GPU-bound eval, so the cadence costs LLM tokens but no wall-clock on the search.\n deep_research_every: int = 3\n # Overlap a DUE deep-research \"think\" with the GPU-bound eval instead of running it in its own\n # serial step (the agent is otherwise idle while a node trains). research() computes from a state\n # snapshot; CODEX AGENT: the joined background task records only allowlisted, order-tolerant\n # research/hint/hypothesis events as soon as the memo finishes. ON by default: the LLM is typically\n # remote (no GPU contention with eval), so overlapping hides latency behind long training.\n concurrent_research: bool = True\n # Concurrent-research REPEAT — don't idle a multi-DAY eval. The overlapped \"think\" fires ONCE per\n # window by default; ON here, it RE-RUNS on an adaptive time cadence for the WHOLE eval window\n # (a two-day training must not leave the reasoning agents idle after one memo). Self-paced: it\n # only records a memo whose CONTENT is new (identical re-runs are skipped, so the log/board don't\n # bloat) and backs off geometrically as the analysis converges, capped so it always re-checks.\n # The appended records never rewrite the current metric champion, but their hints/open hypotheses\n # deliberately steer later proposals and replay reconstructs that advice. The library default is\n # one-shot (== today, byte-identical); this product default turns the repetition on.\n concurrent_research_repeat: bool = True\n # Base cadence (seconds) between repeated concurrent-research passes. Research is EXPENSIVE\n # (multi-turn LLM + web/arXiv), so this is a FLOOR, not a ceiling: the effective cadence is\n # max(this, ~5% of the per-experiment time budget) — a two-day eval is re-researched about hourly,\n # a short eval not at all (the first tick outlasts it). Only matters when the repeat is on.\n concurrent_research_interval_s: float = Field(default=1800.0, gt=0)\n # Per-eval-window backstop on repeated-research LLM calls (0 = cadence-only, no cap). The adaptive\n # cadence + convergence backoff are the primary budget control; this bounds a pathological\n # never-converging window. Past it the loop stops calling the LLM (the health monitor still runs).\n concurrent_research_max_calls: int = Field(default=40, ge=0)\n # Concurrent hypothesis-board CONSOLIDATION during the eval window. Repeated research keeps ADDING\n # near-duplicate directions to the open board (each memo registers up to 5 as open hypotheses), so a\n # long eval's board bloats before the between-nodes merge runs. This dedups/merges it on the SAME\n # background loop so the board stays clean for the next proposal. In Card-driven mode the same merge\n # changes ownership/readiness, so it is deferred to the joined main-task cadence instead. Needs the\n # reflect client + `track_hypotheses`.\n # Only active while `concurrent_research` runs the overlap loop. Library default off (== today).\n concurrent_consolidate: bool = True\n # Cadence for the agent-authored run report: regenerate the conclusion-first narrative every N\n # created nodes (0 = off; it still regenerates on a manual `report_refresh` from the UI). The\n # deterministic report always renders from the node set regardless of this knob.\n report_every: int = 3\n # Agent Skills (I18, ADR-9): dir of SKILL.md the Researcher can list/load as tools.\n skills_dir: str | None = None\n # Prompt store (I18, ADR-8): dir of editable, hot-reloaded role prompt .md files.\n prompt_dir: str | None = None\n # API key as a reference, never serialized as a value (ADR-11). Local servers\n # ignore it; default to a placeholder.\n llm_api_key: SecretStr | None = None\n\n @model_validator(mode=\"before\")\n @classmethod\n def _apply_profile(cls, data):\n \"\"\"Expand `profile` into a bundle of setting defaults BEFORE validation.\n\n pydantic-settings hands this validator the fully-merged input from ALL explicit sources\n (init kwargs + LOOPLAB_* env + .env), with fields the user left unset simply ABSENT (they\n fall to field defaults afterward). So a profile value is applied only when its key is not\n already present — i.e. any explicit file/CLI/env setting wins over the profile, and the\n profile wins over the bare field default. This makes `thorough` a true convenience preset,\n never an override of what the user asked for.\"\"\"\n if not isinstance(data, dict):\n return data\n prof = data.get(\"profile\", \"default\")\n # env may deliver the key as \"profile\" (env_prefix stripped) — normalize to a str\n prof = str(prof).strip().lower() if prof is not None else \"default\"\n overrides = PROFILES.get(prof)\n if overrides is None:\n raise ValueError(f\"unknown profile {prof!r}; known: {sorted(PROFILES)}\")\n for key, val in overrides.items():\n if key not in data: # explicit source (file/CLI/env) always wins\n data[key] = val\n return data\n\n @model_validator(mode=\"after\")\n def _check_trust_gate(self):\n if self.trust_gate not in (\"audit\", \"gate\", \"block\"):\n raise ValueError(\n f\"trust_gate must be audit|gate|block, got {self.trust_gate!r}\")\n if self.merge_mode not in (\"auto\", \"mean\", \"ensemble\"):\n raise ValueError(\n f\"merge_mode must be auto|mean|ensemble, got {self.merge_mode!r}\")\n # novelty_mode drives the dedup/novelty gate; an out-of-set value (e.g. a mis-cased\n # LOOPLAB_NOVELTY_MODE=LLM, or \"on\") used to fall through _apply_novelty_gate silently as a\n # NO-OP — turning the gate off with no diagnostic. Fail loudly at config time like trust_gate/\n # merge_mode do. (Other enum-ish string fields — seed_mode/eval_trust_mode/strategist_backend —\n # share the pattern; left unvalidated here only because their full value sets are less settled.)\n if self.novelty_mode not in (\"off\", \"algo\", \"llm\"):\n raise ValueError(\n f\"novelty_mode must be off|algo|llm, got {self.novelty_mode!r}\")\n # The remaining enum-ish string fields (arch-review §5 P3): a typo used to be accepted at\n # construction and only fail-safe/later-loud downstream (e.g. a mis-cased strategist_backend\n # silently ran the default). Fail loudly here like the fields above.\n if self.strategist_backend not in (\"off\", \"rule\", \"llm\", \"agent\"):\n raise ValueError(\n f\"strategist_backend must be off|rule|llm|agent, got {self.strategist_backend!r}\")\n if self.eval_trust_mode not in (\"ratify_freeze\", \"autonomous\", \"ratify_freeze_drift\"):\n raise ValueError(\n \"eval_trust_mode must be ratify_freeze|autonomous|ratify_freeze_drift, \"\n f\"got {self.eval_trust_mode!r}\")\n if self.seed_mode not in (\"auto\", \"tracked\", \"all\"):\n raise ValueError(f\"seed_mode must be auto|tracked|all, got {self.seed_mode!r}\")\n return self\n\n def masked_snapshot(self) -> dict:\n # Snapshots are a JSON contract (they are written verbatim to config.snapshot.json), not a\n # Python-object dump. In particular tuple-valued schema fields such as\n # ``inline_repair_reasons`` must become JSON arrays before callers hash the runtime envelope;\n # otherwise the strict speculation-scope canonicalizer sees a different/non-JSON preimage\n # from the one that ``json.dumps`` eventually persists.\n d = self.model_dump(mode=\"json\")\n if self.llm_api_key is not None:\n d[\"llm_api_key\"] = \"***\"\n else:\n d[\"llm_api_key\"] = None\n return d\n\n\n# Pre-versioned snapshots are full Settings dumps, so absence is a reliable per-field version marker.\n# Keep their historical effective behavior when newer product defaults become active: re-entry must not\n# silently add paid calls, interventions, concurrency or a different selection policy to an old run.\nLEGACY_CONFIG_SNAPSHOT_DEFAULTS: dict[str, object] = {\n \"parallel_build\": 1,\n \"eval_parallel\": None,\n \"llm_parallel\": None,\n \"train_monitor\": False,\n \"train_monitor_interval_s\": 600.0,\n \"train_monitor_kill\": False,\n \"train_monitor_kill_confidence\": 0.8,\n \"asha_live\": False,\n \"asha_live_kill\": False,\n \"asha_live_quantile\": 0.5,\n \"asha_live_min_siblings\": 3,\n # Before this operator ceiling existed, the sandbox's 24-hour process limit was authoritative.\n \"max_eval_timeout\": 24 * 3600.0,\n \"watchdog_reflection\": False,\n \"card_driven_selection\": False,\n \"speculation_depth\": 0,\n \"speculation_gate_receipt\": None,\n \"concurrent_research_repeat\": False,\n \"concurrent_research_interval_s\": 1800.0,\n # Zero means unlimited; 40 was the first bounded default, and is latent while repeat is disabled.\n \"concurrent_research_max_calls\": 40,\n \"concurrent_consolidate\": False,\n}\n\n\ndef migrate_config_snapshot(data: dict) -> dict:\n \"\"\"Return an effective copy of one legacy/current snapshot without rewriting evidence bytes.\"\"\"\n if not isinstance(data, dict):\n raise TypeError(\"config snapshot must be a JSON object\")\n migrated = dict(data)\n for key, value in LEGACY_CONFIG_SNAPSHOT_DEFAULTS.items():\n migrated.setdefault(key, value)\n return migrated\n\n\ndef settings_from_snapshot(data: dict) -> Settings:\n \"\"\"Build re-entry Settings from a masked snapshot under its historical missing-field contract.\"\"\"\n migrated = migrate_config_snapshot(data)\n migrated.pop(\"llm_api_key\", None)\n return Settings(**migrated)\n"}, "b16fa27a3ea27dbd4d07d7f9fe0c71670f1ad568": {"t": 1785768788.7355611, "n": 95317, "h": "3307c9ad8ebb68df", "c": "\"\"\"Engine config: the schema (what settings exist). (I0, ADR-11)\n\nOne pydantic-settings class declaring every engine knob, its default, and its docs. The loader —\nhow a run file / CLI / env becomes a `Settings` — is `looplab.core.appconfig`, which also owns\nthe config-precedence order. A resolved, secret-masked snapshot is written next to each run for\nreproducibility. (No real secrets in P0, but the masking discipline is in place.)\n\"\"\"\nfrom __future__ import annotations\n\nfrom pathlib import Path\n\nfrom pydantic import Field, SecretStr, field_validator, model_validator\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\n# Single source of the LLM first-byte (response-headers) default — see core/llm.py.\nfrom looplab.core.llm import DEFAULT_HEADER_TIMEOUT_S\n\n# Default home for cross-run memory + the knowledge base (shared across every run). Both are ON by\n# default now (a user asked for it) so a fresh install accumulates + consults knowledge with no setup;\n# set the env var to \"\" to disable, or to a path to relocate. `~/.looplab/` keeps them out of any one\n# project + shared across projects.\n_LL_HOME = Path.home() / \".looplab\"\n\n# Autonomous roles that may be granted per-setting write access (see Settings.agent_control).\n# strategist = A7 meta-controller (run-wide); boss = run-chat operator-proxy (run-wide);\n# researcher = per-experiment proposer (per-node sizing, e.g. eval timeout for a heavy model).\nAGENT_ROLES = (\"strategist\", \"boss\", \"researcher\")\n\n# A foresight score is computed for every acted-on proposal. Keep this lower than the generic\n# verifier's emergency ceiling so a typo cannot multiply paid synchronous calls across a run.\nMAX_FORESIGHT_VERIFY_SAMPLES = 8\n\n\n# Layer-2 parallelism renamed two public settings without removing the durable legacy fields. Keep\n# the equivalence in one place: config-source precedence, governance migration, and live strategy\n# application must all agree which names describe the same authority/value.\nPARALLELISM_ALIASES: dict[str, str] = {\n \"max_parallel\": \"eval_parallel\",\n \"parallel_build\": \"llm_parallel\",\n}\n\n\ndef parallelism_aliases(setting: str) -> tuple[str, ...]:\n \"\"\"Return every spelling in ``setting``'s alias family, canonical name first.\"\"\"\n for legacy, canonical in PARALLELISM_ALIASES.items():\n if setting in (legacy, canonical):\n return canonical, legacy\n return (setting,)\n\n\ndef canonicalize_parallelism_source(values: dict) -> dict:\n \"\"\"Promote legacy parallel keys *within one config-precedence source*.\n\n Applying this only after file/CLI/env were flattened would let a lower-priority canonical env\n value shadow a higher-priority legacy file value (or a file canonical value shadow a CLI legacy\n override), so callers with sub-layers normalize each layer independently.\n \"\"\"\n if not isinstance(values, dict):\n return values\n out = dict(values)\n for legacy, canonical in PARALLELISM_ALIASES.items():\n # CODEX AGENT: None is the durable legacy mode for llm_parallel, not a missing value. A normal\n # snapshot contains llm_parallel:null plus parallel_build:1; reloading it here promotes null to\n # 1 and unexpectedly enables the global one-call broker. Promote aliases only when the\n # canonical key is absent, and pin snapshot round-trip parity.\n if canonical not in out and out.get(legacy) is not None:\n # CODEX AGENT: canonicalize inside each precedence layer, never across the flattened\n # result. The alias migration must not invert CLI > file > env precedence.\n out[canonical] = out[legacy]\n return out\n\n# Settings whose semantics are committed by ``run_started`` and restored from the folded event log\n# on every re-entry. Changing one only in ``config.snapshot.json`` would mix incompatible evidence\n# (a different holdout split) or make the live producer disagree with replay's selection policy.\n# The HTTP config editor and the engine both consume this one contract; keep deliberately mutable\n# controls such as ``trust_gate`` out (that field has its own durable change event).\nRUN_START_PINNED_FIELDS = frozenset({\n \"card_driven_selection\",\n \"speculation_depth\",\n \"holdout_fraction\",\n \"holdout_select\",\n \"select_verifier\",\n \"select_verifier_samples\",\n \"verifier_ci_tie\",\n})\n\n\ndef run_start_pinned_settings(state) -> dict:\n \"\"\"Return the effective Settings names committed by a folded run-start record.\n\n Pre-pin legacy logs have no recorded holdout fraction, so their snapshot remains authoritative\n for both holdout knobs. Verifier fields, however, have always been re-pinned from the legacy-safe\n fold defaults whenever a valid ``run_started`` identity exists.\n \"\"\"\n if not getattr(state, \"run_id\", \"\"):\n return {}\n values = {\n # Layer 3 changes which queue owns macro-action selection. Once a run starts, replay and\n # resume must keep the same owner even if the snapshot or ambient default changes later.\n \"card_driven_selection\": bool(getattr(state, \"card_driven_selection\", False)),\n # Layer 5 overlaps Card production with evaluation only when this pinned treatment is\n # positive. Old logs omit it and therefore retain the exact serial build/eval spine.\n \"speculation_depth\": int(getattr(state, \"speculation_depth\", 0)),\n \"select_verifier\": bool(getattr(state, \"select_verifier_tiebreak\", False)),\n \"select_verifier_samples\": int(getattr(state, \"select_verifier_samples\", 3)),\n \"verifier_ci_tie\": bool(getattr(state, \"verifier_ci_tie\", False)),\n }\n holdout_fraction = getattr(state, \"holdout_fraction\", None)\n if holdout_fraction is not None:\n values.update({\n \"holdout_fraction\": holdout_fraction,\n \"holdout_select\": bool(getattr(state, \"holdout_select\", False)),\n })\n if not values.keys() <= RUN_START_PINNED_FIELDS:\n raise RuntimeError(\"folded run-start pinned settings contract drifted\")\n return values\n\n# --- Run profiles (config-first presets) -------------------------------------------------------\n# A `profile` is a NAMED BUNDLE of setting defaults — nothing more. It only fills fields the user\n# did NOT set explicitly (via file / --set / LOOPLAB_* env / init-kwargs), so any explicit knob\n# always wins (see `_apply_profile`). Most engine intelligence lives behind individual flags. The\n# PART IV/V cross-run + concept-graph machinery (concept_pivot, graded_novelty, cross_run_concepts /\n# _advisory / _structured_claims / _curation / _read_tools, fingerprint_universal, foresight_verify)\n# now ships ON in the product `Settings` default. That is an explicit experimental product choice,\n# not a claim that a frozen A/B or workload-wide cost/quality gate has cleared: the bundle can steer\n# prompts and candidate generation, change graded-novelty admission / pre-execution choices, and add\n# paid LLM work when its client and memory prerequisites are present. The bare-library `EngineOptions`\n# default stays lean (see tests/test_options_divergence.py — product side is the aggressive one) so a\n# toy `Engine(...)` in a test doesn't fire cross-run LLM work unasked. The trust/confirm/ablate QUALITY\n# bundle still ships OFF and is turned on by `thorough`. Every value here is reachable by hand — the\n# profile is a convenience, never a hidden mode.\n# CODEX AGENT: keep this rationale behavioral and evidence-based; do not relabel read-only storage\n# access or proposal-only governance as a no-op for agent behavior, cost, or proposal admission.\n#\n# `thorough` deliberately touches only QUALITY/TRUST machinery, not spend: it does NOT raise\n# max_nodes / eval_parallel (those are cost knobs the user owns). It enables multi-seed confirmation\n# (demote seed-lucky leaders), the reward-hack / leakage / critic monitors AND flips them from\n# advisory to *gating* (`trust_gate=\"gate\"` — a\n# flagged win can no longer be selected as best), ablation-driven refinement, and the prompt cues\n# (complexity / budget / failure-reflection / watchdog-reflection). Agentic novelty and the experimental\n# Part IV/V bundle are already product defaults, not profile changes. Effect is large; marginal cost is a\n# few extra evals.\nPROFILES: dict[str, dict] = {\n \"default\": {}, # explicit no-op alias for \"ship-as-is\" (== omitting profile)\n \"fast\": {}, # today's lean defaults, named for symmetry with `thorough`\n \"thorough\": {\n \"confirm_top_k\": 3,\n \"confirm_seeds\": 3,\n # novelty_gate intentionally NOT enabled here: duplicate-detection is the agentic Researcher's\n # job (it reads past experiments + find_analogous_across_runs and DECIDES), not an algorithmic\n # param-distance / embedding auto-reject that mis-fired (it nudged a good fresh idea onto a\n # twice-dead lineage — live node 61). Search stays as tools; the LLM judges novelty.\n \"operator_bandit\": True, # P4: adaptive operator mix over folded yields (GreedyTree)\n \"reward_hack_detect\": True,\n \"code_leakage_detect\": True,\n \"critic_check\": True,\n \"trust_gate\": \"gate\", # detectors stop gating a flagged node from WINNING (not just audit)\n \"ablate_every\": 3,\n \"complexity_cue\": True,\n \"budget_aware\": True,\n \"failure_reflection\": True,\n \"watchdog_reflection\": True, # feed recent live-watchdog (train-monitor/ASHA) flags to proposals\n \"reflection_priors\": True, # no-op unless memory_dir is set (cross-run priors)\n },\n}\n\n\n# The shipped agent-governance matrix (who may change which setting at runtime). Defined as a module\n# constant so the Engine's direct-construction default matches this shipped default exactly (the\n# EngineOptions \"Engine() == shipped defaults\" invariant) instead of diverging to an all-locked map.\nDEFAULT_AGENT_CONTROL: dict[str, list[str]] = {\n \"timeout\": [\"researcher\", \"strategist\"],\n # Layer-2 canonical names are the presented/default governance surface. `_agent_may` falls back\n # to an old snapshot's legacy grant only when its canonical counterpart is absent, so removing\n # duplicate defaults preserves resume authority while an explicit canonical revocation still wins.\n \"eval_parallel\": [\"strategist\"],\n \"llm_parallel\": [\"strategist\"],\n # Strategy-only pseudo-setting: the closed per-lane allocation inside the canonical LLM total.\n # It is not a launch Settings field; operators pin it through `set_strategy` and the Strategist\n # may reallocate it only while this grant is present.\n \"llm_lane_limits\": [\"strategist\"],\n \"max_nodes\": [\"strategist\", \"boss\"],\n \"max_eval_seconds\": [\"strategist\", \"boss\"],\n \"policy\": [\"strategist\"],\n # The policy NAME and its PARAMS are gated independently in `_apply_strategy`; grant both by default\n # so the Strategist that may switch to (say) MCTS may also apply the `c`/`eta` it decided — else its\n # tuned params are silently dropped and the recorded `active_strategy` diverges from the live engine.\n \"policy_params\": [\"strategist\"],\n \"n_seeds\": [\"strategist\"],\n \"ablate_every\": [\"strategist\"],\n \"merge_mode\": [\"strategist\"],\n \"complexity_cue\": [\"strategist\"],\n \"ablate_code_blocks\": [\"strategist\"],\n \"prefer_sweep\": [\"strategist\"],\n \"novelty_stance\": [\"strategist\"],\n \"developer\": [\"strategist\"],\n \"fidelity\": [\"strategist\", \"researcher\"],\n}\n\n\ndef default_agent_control() -> dict[str, list[str]]:\n \"\"\"A FRESH deep-ish copy of the shipped governance matrix (each role list copied so a caller can\n mutate its own map without touching the module constant). The SINGLE constructor of the default,\n called by both `Settings.agent_control`'s default_factory AND the Engine's direct-construction\n default — so \"Engine() == shipped defaults\" can't drift if the copy depth ever needs to change\n (e.g. nested values requiring copy.deepcopy), instead of two verbatim copy expressions.\"\"\"\n return {k: list(v) for k, v in DEFAULT_AGENT_CONTROL.items()}\n\n\nclass Settings(BaseSettings):\n \"\"\"The engine settings schema (every knob a run accepts).\n\n Timeout family — six distinct knobs, each owned by a different subsystem:\n - `timeout`: per-eval wall-clock budget for ONE experiment's evaluation (engine/eval).\n - `max_eval_timeout`: hard ceiling for a governed per-node Researcher timeout override.\n - `llm_timeout`: LLM request idle timeout — inter-token stall limit in stream mode\n (core.llm OpenAICompatibleClient).\n - `llm_header_timeout`: LLM first-byte (response-headers) window for stream attempts (core.llm).\n - `agent_time_budget_s`: wall-clock ceiling across a tool-using agent loop's turns (agents).\n - `dep_install_timeout`: per-package budget for auto-installing a missing dep (env self-prep).\n\n Config-source precedence is owned by `looplab.core.appconfig` (the loader) — see its module\n docstring for the one canonical order.\n \"\"\"\n # Config precedence: see appconfig.py — the loader owns the full order. The `.env` is read\n # so `looplab run`/`looplab ui` pick up LOOPLAB_LLM_BASE_URL etc. without exporting them by hand\n # (keys MUST carry the LOOPLAB_ prefix, same as the env vars). utf-8-sig tolerates a BOM from\n # Windows editors. The test suite disables this (tests/conftest.py) so a dev's real .env in the\n # repo root can't leak into assertions.\n model_config = SettingsConfigDict(\n env_prefix=\"LOOPLAB_\",\n env_file=\".env\",\n env_file_encoding=\"utf-8-sig\",\n extra=\"ignore\",\n allow_inf_nan=False,\n )\n\n @classmethod\n def settings_customise_sources(\n cls, settings_cls, init_settings, env_settings, dotenv_settings,\n file_secret_settings):\n \"\"\"Preserve source precedence across canonical/legacy parallelism names.\n\n Pydantic ranks init > env > dotenv correctly, but it ranks field names rather than semantic\n alias families. Promoting a legacy value inside each source before the merge makes an old\n explicit snapshot/file value beat a lower-priority canonical env value. ``appconfig`` also\n promotes its file/typed/--set sub-layers before combining them into the init source.\n \"\"\"\n def _with_parallel_aliases(source):\n return lambda: canonicalize_parallelism_source(source())\n\n return (\n _with_parallel_aliases(init_settings),\n _with_parallel_aliases(env_settings),\n _with_parallel_aliases(dotenv_settings),\n _with_parallel_aliases(file_secret_settings),\n )\n\n # Run profile (config-first preset, see PROFILES above). \"default\"/\"fast\" = today's lean\n # defaults; \"thorough\" turns the built quality/trust machinery ON in one word. A profile only\n # fills fields the user did NOT set — every knob it touches stays individually overridable.\n profile: str = \"default\"\n\n # Bounds (review C/⚪): a non-positive node/seed budget or timeout is never valid. Upper bounds are\n # equally load-bearing — the UI /start path writes these straight into the engine's env, and an\n # unbounded `eval_parallel` (or legacy `max_parallel`; e.g. 100000 from a crafted preflight) would\n # fan out that many sandboxes → resource exhaustion. Reject at config time, not mid-run. Ceilings\n # are generous; tests top out near 50.\n n_seeds: int = Field(default=3, ge=1, le=1024)\n max_nodes: int = Field(default=8, ge=1, le=1_000_000)\n # CODEX AGENT: legacy evaluation-width input. `eval_parallel` below is canonical and wins when set;\n # keep this field for old env/config/snapshots and direct Engine call sites. A width > 1 admits\n # concurrent evals, whose resource reservations may be CPU-only or span one/more GPUs — there is no\n # one-node/one-GPU promise. `max_parallel=0` is launch-time AUTO (detected GPU count, at least 1).\n max_parallel: int = Field(default=1, ge=0, le=1024)\n # CODEX AGENT: legacy build-width input. `llm_parallel` below is canonical and also controls the\n # shared provider-call total when positive. A width > 1 uses fresh pooled researcher/developer pairs;\n # it says nothing about the later eval's CPU/GPU reservation. Startup `0` derives build fan-out from\n # resolved eval width; a live Strategist/operator zero settles to 1. Without role_factory it clamps to 1.\n parallel_build: int = Field(default=1, ge=0, le=64)\n # Hypothesis-card Kanban re-architecture (docs/23, Layer 2). Two INDEPENDENT concurrency axes,\n # decoupled from each other so cost (LLM work) can be tuned separately from throughput (GPU evals).\n # These are the CANONICAL names going forward; `max_parallel`/`parallel_build` above are kept as thin\n # back-compat aliases (env vars / snapshots / ~100 Engine(...) call sites depend on the exact names,\n # so they cannot be renamed — CLAUDE.md). Resolution (orchestrator.__init__): a NEW name, when set,\n # WINS over its legacy alias; None => fall back to the legacy field => byte-identical to today.\n # `eval_parallel`: concurrent EVALS (the GPU/experiment consumer). None -> max_parallel; 0 = AUTO\n # (one experiment per detected GPU). This is the in-Run eval-concurrency ceiling; the resource\n # scheduler bin-packs declared footprints separately. Sibling local GPU-owning Runs serialize\n # through the conservative host-pool lease; external execution needs its own admission boundary.\n # `llm_parallel`: total concurrent LLM provider calls + BUILD fan-out. None -> legacy\n # parallel_build semantics without a shared total; 0 = startup AUTO (= resolved eval_parallel\n # for build fan-out, historical research overlap unbounded). A positive canonical value enables\n # the named-lane broker the Strategist can reallocate at cadence.\n eval_parallel: int | None = Field(default=None, ge=0, le=1024)\n llm_parallel: int | None = Field(default=None, ge=0, le=64)\n # Training-log monitor (I-series watchdog family): a per-eval background observer that tails the live\n # training log while a (often multi-hour) declared stage runs in a worker thread. ON in the product\n # surface (Settings). Its alert event is fold-ignored and cannot directly change lifecycle/champion;\n # CODEX AGENT: with watchdog_reflection on, that alert is nevertheless advice in a later Researcher\n # prompt and can change future proposals. It only fires on the command-eval path with an LLM client;\n # bare-library EngineOptions stays OFF. See test_options_divergence.\n # `_interval_s` is the BASE tick cadence (the effective one adapts to the per-experiment budget).\n train_monitor: bool = True\n train_monitor_interval_s: float = Field(default=600.0, gt=0)\n # Phase 3 — INTERVENTION (opt-in, separate from observation): let the monitor tree-kill a training the\n # LLM judges 'broken' (diverged / silent CPU fallback / not learning) EARLY, instead of burning the\n # whole budget. Off by default — the observer only WATCHES unless this is on. A kill only fires on a\n # 'broken' verdict (a plateau is 'watch', never killed) with confidence >= the threshold; the node then\n # fails normally (reason='monitor_broken'), so replay reconstructs it from that one terminal event.\n train_monitor_kill: bool = False\n train_monitor_kill_confidence: float = Field(default=0.8, ge=0.0, le=1.0)\n # ASHA live-curve watchdog (sibling of the training monitor): reads the latest INTERMEDIATE value of\n # the objective metric off the live log (reusing the eval's OWN metric reader). Finished-endpoint rank\n # remains advisory. An opt-in KILL additionally requires an operator-declared metric.resource_key and\n # enough sibling observations at exactly that resource; absent comparable evidence cannot stop a run.\n # Records a fold-ignored `asha_rank` diagnostic + trace span; the library default is OFF (off == today).\n asha_live: bool = True\n # Opt-in INTERVENTION (separate from the advisory signal): let the watchdog tree-kill a node whose\n # intermediate metric stays below the SAME-RESOURCE bar past a short grace window. Off by default —\n # it only surfaces endpoint rank unless this is on. A kill fails normally (asha_underperforming).\n asha_live_kill: bool = False\n # The bar sits at `asha_live_quantile` along a WORST->BEST ordering of the finished siblings' finals:\n # 0.5 = the median (a node worse than the median peer flags); a SMALLER value is more conservative —\n # it lowers the bar toward the WORST finished peer, so only a node worse than nearly all peers flags\n # (0.0 = worse than the worst). `min_siblings` is the floor of finished peers required before it ranks\n # at all, so it never acts on too little evidence.\n asha_live_quantile: float = Field(default=0.5, ge=0.0, le=1.0)\n asha_live_min_siblings: int = Field(default=3, ge=1)\n timeout: float = Field(default=30.0, gt=0)\n # Hard ceiling for the Researcher's governed per-node `Idea.eval_timeout`. One hour preserves the\n # existing heavy/neural-net authoring example while staying well below the sandbox's defensive\n # 24-hour subprocess ceiling. This is operator-owned run config: agents may request less or more,\n # but the accepted action is always clamped here after the `agent_control.timeout` permission gate.\n max_eval_timeout: float = Field(default=3600.0, gt=0, le=24 * 3600.0)\n # Intra-node sweep: a sweep node runs a whole grid in one process, so it gets this multiple of\n # `timeout` as its wall-clock budget (solution.py path; RepoTasks use their per-profile timeout).\n sweep_timeout_mult: float = Field(default=8.0, ge=1)\n # Sandbox tier (ADR-13): \"trusted_local\" (subprocess, no Docker) for the CLI;\n # \"untrusted\" (Docker --network none, shared-kernel runtime) for hosted/multi-tenant UI;\n # \"hostile\" (untrusted + a true-isolation OCI runtime, gVisor `runsc` by default / Kata) for\n # running untrusted code. See runtime/sandbox.py::make_sandbox for the tier selection.\n trust_mode: str = \"trusted_local\"\n # Image for the untrusted command-eval tier (RepoTask, Phase 4): the framework's deps\n # should be baked in (the container runs --network none, so a pip setup can't fetch).\n docker_image: str = \"python:3.12-slim\"\n # Resource caps for the untrusted/hostile Docker tier (passed to `docker run --memory/--cpus`).\n # The default 4g bound protects other tenants from an OOM, but a model-training MLE-bench eval\n # routinely needs more — raise it (e.g. \"16g\") for such runs. \"\" disables the cap (unbounded).\n # Ignored by the trusted_local (subprocess) tier.\n sandbox_memory: str = \"4g\"\n sandbox_cpus: str = \"\"\n # Best-effort host-OOM guard for the TRUSTED-LOCAL (subprocess) tier. When set (e.g. \"8g\"), each\n # eval child gets an RLIMIT_AS address-space cap so a runaway trainer hits MemoryError instead of\n # OOM-killing the whole host (and the engine). POSIX only; a no-op on Windows. Default \"\" = OFF:\n # RLIMIT_AS bounds VIRTUAL memory, and CUDA/torch reserve tens of GB of virtual without using it,\n # so a default cap would spuriously kill GPU evals — leave it off for those and use the Docker\n # tier's real --memory cgroup bound (sandbox_memory) instead. Ideal for CPU/numpy/sklearn runs.\n sandbox_memory_local: str = \"\"\n # Best-effort disk-fill guard for the TRUSTED-LOCAL tier (P1-5): an RLIMIT_FSIZE cap on the size of\n # any single file an eval child writes (e.g. \"2g\"), so a runaway that writes an unbounded file gets\n # SIGXFSZ instead of filling the host disk. POSIX only. Default \"\" = OFF (large model checkpoints\n # need big files); set it for tasks that write only small artifacts.\n sandbox_fsize_local: str = \"\"\n # Search policy (ADR-2): \"greedy\" | \"evolutionary\" | \"mcts\" | \"asha\".\n policy: str = \"greedy\"\n # Ablation-driven refinement (I7): every N improves, ablate the best to find the\n # highest-impact parameter and refine it. 0 = off. (GreedyTree only.)\n ablate_every: int = 0\n # A0a (MLE-STAR): when ablating, treat each generated pipeline *code block* (not just a\n # numeric param) as an ablation unit — neutralize a block, measure the metric delta, refine\n # the highest-impact block. Off => the classic numeric-param ablation. Config-first knob.\n ablate_code_blocks: bool = False\n # A0b: real merge/ensembling. \"mean\" = legacy mean-param merge; \"ensemble\" = the Developer\n # writes a code-recombination ensemble over the two parents' solutions (verified: agent-proposed\n # ensembling 37.9%->43.9%). \"auto\" (default) = ensemble whenever the Developer actually\n # generates code (LLM/agent backends; declared via `is_code_generating`), mean for the\n # templated/toy developers where a code ensemble is meaningless — the evidence says code\n # recombination is the strongest single merge (removing it costs ~9 pp), so it is the default\n # wherever it can work. Falls back to mean when the Developer can't ensemble.\n merge_mode: str = \"auto\"\n # A0d (AIRA): inject a dynamic complexity hint into the draft/improve prompt keyed on the\n # node's child count (few children -> keep minimal; many -> escalate to ensembling/HPO).\n complexity_cue: bool = False\n # I1 (CAAFE-style) LLM feature-engineering: instruct the proposer/developer to add\n # semantically-meaningful engineered features (ratios, interactions, aggregations) as code. The\n # existing cross-validation eval + best-selection is the CV GATE — a feature set that doesn't\n # improve CV is never selected (feature engineering is non-universal, so this gate is mandatory).\n # Tabular tasks (those exposing column data). Off by default.\n feature_engineering: bool = False\n # A5: surface the REMAINING eval-compute budget into the proposal prompt so the Researcher can\n # reason over how much compute is left (explore broad while flush, exploit/cheapen when low).\n # No-op unless a max_eval_seconds budget is set.\n budget_aware: bool = False\n # A4 (LATS-style): feed a summary of the most recent FAILED branches (operator + error reason)\n # back into the proposal prompt so the proposer reflects on and avoids repeating them. ON by\n # default: it is SELECTIVE by construction (injects only when recent failures exist — the\n # evidenced form of reflection; reflecting at every step shows no gains, arXiv:2506.12928).\n failure_reflection: bool = True\n # Feed the LIVE-WATCHDOG observations (train-monitor health verdicts + ASHA intermediate-rank flags)\n # of recent experiments back into the proposal prompt, so the Researcher avoids re-proposing a config\n # whose TRAINING was already observed to be weak — even when the watchdog kills are off (default) and\n # the node ran to completion (those diagnostics are fold-ignored, so failure_reflection never sees\n # them). ON by default and SELECTIVE like failure_reflection (injects only when a recent flag exists).\n watchdog_reflection: bool = True\n # C3 deep test-driven repair: hand the Developer the failure taxonomy + a structured \"reproduce\n # then fix\" directive on debug, not just the raw stderr tail. ON by default (product surface); the\n # conservative library default in EngineOptions stays off, per that module's contract.\n deep_repair: bool = True\n # === Inline crash repair ==============================================================\n # Hybrid in-node crash repair: when an LLM-generated node CRASHES at runtime (mechanical errors\n # — bad import, removed kwarg, typo), the agent triages it and may repair the code IN PLACE within\n # the same eval (cheap; does NOT consume max_nodes and does NOT add a node to the search tree).\n # Semantic failures still flow to a new idea/debug node via the policy. ON by default; set False\n # to restore the prior behavior (every crash waits for a budgeted inter-node debug node).\n inline_repair: bool = True\n # Max in-place repair attempts per node before it fails normally. Default 0 = UNLIMITED: the same\n # agent keeps fixing + re-evaluating IN THE SAME node until it produces a clean metric — no new\n # node just to retry. Runaway is prevented not by a count but by the anti-stuck guard\n # (`inline_repair_stuck_repeat`): when the SAME error signature repeats with no progress, or the\n # agent's crash-triage says \"abandon\", the node fails. Set a positive N to cap the retries instead.\n inline_repair_attempts: int = Field(default=0, ge=0)\n # Anti-stuck: abandon in-node repair when the SAME error recurs this many times in a row (no\n # progress). Keeps \"unlimited\" repair from looping forever on an unfixable error.\n inline_repair_stuck_repeat: int = Field(default=4, ge=2)\n # Which failure reasons (_failure_reason: crash|timeout|oom|setup|no_metric|drift) are eligible for\n # inline repair. Default: mechanical crashes, timeouts AND OOM-kills — a timeout/OOM means the code\n # was too slow / too memory-hungry for the budget (or the pod's cgroup limit), not that the idea is\n # wrong, so the agent repairs it by reducing compute/memory (fewer estimators/epochs/folds/seeds, a\n # lighter model, a smaller batch, subsampling) instead of letting the node die unrecovered. Env\n # override expects a JSON array.\n inline_repair_reasons: tuple[str, ...] = (\"crash\", \"timeout\", \"oom\")\n # Cost ceiling for in-node repair of a MULTI-STAGE eval: the max number of FULL pipeline re-runs\n # (i.e. re-trains from the first stage) the inline-repair loop may do before abandoning to the\n # inter-node debug operator. A late-stage failure (a broken `score`/eval script that didn't touch\n # `train`) REUSES the completed train checkpoint and re-runs only from the failed stage — that is\n # CHEAP and NOT counted here; only a repair that changes an EARLIER stage's code (train.py/loss.py/…)\n # forces a full re-train, which each costs the whole training time. Without this bound, a repair that\n # keeps changing training code could burn many full trains (the anti-stuck guard is error-signature\n # based, not cost based). 0 = unlimited (legacy behavior). Single-command evals ignore it.\n inline_repair_retrain_cap: int = Field(default=2, ge=0)\n # Environment self-prep: when a solution crashes purely because a KNOWN library isn't installed\n # (ModuleNotFoundError), the engine pip-installs it into the eval interpreter and re-runs — so a\n # torch/XGBoost/CatBoost idea (e.g. a GRU model) actually runs on a fresh box instead of being\n # thrown away as `idea_rejected`. Trusted_local tier only (the untrusted/hostile Docker tiers run\n # --network none). A typo'd or local helper module is NOT installed (that's a code bug to repair).\n auto_install_deps: bool = True\n # Per-package wall-clock budget for an auto-install (seconds). Generous: large wheels like torch\n # can take minutes to download/build.\n dep_install_timeout: float = Field(default=900.0, gt=0)\n # --- Agent governance: who may change a setting at runtime (per-setting allow-list of roles) ---\n # Roles: \"strategist\" (A7 meta-controller; run-wide), \"boss\" (run-chat operator-proxy; run-wide),\n # \"researcher\" (per-experiment proposer; PER-NODE — e.g. set a longer eval timeout for a neural-net\n # idea). A setting ABSENT from this map is LOCKED — no agent may change it, only a human via the\n # snapshot/UI. The sole conditional exception is Strategy-only `card_scoring`: Card mode grants\n # it implicitly without changing the default flag-off snapshot, while an explicit [] revokes it.\n # The gate is ENFORCED at every AGENT seam via `_agent_may` — the strategist's whole\n # control surface (policy/operators/novelty_stance/prefer_sweep/developer/fidelity/timeout/\n # eval_parallel/llm_parallel) is checked in `_apply_strategy`, so removing a role from a knob locks it,\n # not just greys out the UI pill. The boss/max_* grants document boss-relevant knobs, but a\n # `budget_extend` is a HUMAN control intent (the boss action-builder can only emit `add_nodes`), so\n # it is applied as-is — the human always wins via the UI/snapshot. Defaults are conservative: resource/\n # search-shape knobs the agents already reason about; provider infra (llm_model/base_url/api_key,\n # docker_image) stays locked. The UI shows S/B/R pills per setting. `timeout` granted to the\n # researcher IS the \"auto\" per-node mode — the researcher sizes each experiment's wall-clock\n # (Idea.eval_timeout); the config `timeout` is the fallback for nodes it doesn't size. Env override\n # expects a JSON object. (`fidelity`/`novelty_stance`/`prefer_sweep`/`developer` are governance keys\n # for the strategist's per-node dials — not 1:1 Settings fields, but gated the same way.) The default\n # lives in the module-level DEFAULT_AGENT_CONTROL so the Engine's direct-construction default matches.\n agent_control: dict[str, list[str]] = Field(default_factory=default_agent_control)\n # C1 (Agentless localization): for repo tasks, rank the source files most relevant to the most\n # recent failure (traceback + identifiers) and surface them in the proposal/repair prompt so the\n # Developer edits the right place. Off by default; repo tasks only.\n localize_faults: bool = False\n # A2 surrogate-guided proposal (BO-lite): fit a k-NN surrogate over (params->metric) and propose\n # by acquisition instead of random/hill-climb. Numeric-bounds tasks only; bootstraps via the\n # base Researcher. Off by default. `surrogate_explore` = UCB-style exploration weight.\n surrogate_proposer: bool = False\n surrogate_explore: float = 0.1\n # E2 researcher panel: generate K candidate ideas per proposal and keep the one ranked best by a\n # cheap empirical surrogate over the (params->metric) history (NOT an LLM-judge). 1 = off.\n researcher_panel: int = 1\n # A1 ASHA / successive-halving: reduction factor (keep top 1/eta per rung) and rung budget.\n asha_eta: int = Field(default=3, ge=2)\n # Rung-0 width for ASHA/BOHB (the wide base of cheap drafts). 0 = use n_seeds (default,\n # preserving prior behavior); set >0 to seed a wider/narrower base independent of n_seeds.\n asha_rung_nodes: int = Field(default=0, ge=0)\n # A6 proxy/predictive scoring: cheaply rank a candidate's potential from early-stage signals\n # and skip a full eval for the doomed bottom fraction. 0.0 = off (no candidate skipped).\n proxy_scoring: bool = False\n proxy_kill_fraction: float = Field(default=0.0, ge=0.0, le=0.9)\n # === Novelty / dedup ==================================================================\n # E1 novelty/dedup gate: reject near-duplicate proposals (normalized param-space distance below\n # `novelty_epsilon`) by deterministically nudging them off the duplicate — stops the search\n # wasting evals re-trying the same idea. Audit event `novelty_rejected`. Off by default.\n # Novelty/dedup MODE selector: how a fresh proposal is checked against already-tried experiments.\n # \"off\" — no explicit gate; the agentic Researcher's own read-the-history judgment is trusted.\n # \"algo\" — the deterministic gate: numeric param-distance (`novelty_epsilon`) + optional embedding\n # similarity (`novelty_semantic`). Cheap, no LLM call, but can't explain itself.\n # \"llm\" — an LLM adjudicates (reads the real experiments via tools) whether the idea is a\n # near-duplicate and, if so, asks the Researcher once more for something different. One\n # extra LLM call per proposal — highest quality, follows the \"let the LLM decide\" line.\n novelty_mode: str = \"llm\"\n # Legacy sub-toggles, honored by the \"algo\" mode (and back-compat: novelty_gate=True forces \"algo\"):\n novelty_gate: bool = False\n novelty_epsilon: float = 0.05\n # T5 semantic novelty (active whenever the deterministic gate runs — novelty_mode=algo,\n # novelty_gate=true which is the legacy alias for algo, OR the Strategist novelty stance is\n # \"explore\"; a no-op only under novelty_mode=llm/off with a non-explore stance): reject a proposal\n # whose idea TEXT embeds within\n # `novelty_semantic_threshold` cosine of an existing node's. OFF BY DEFAULT: novelty is the agentic\n # Researcher's call, made by READING the actual prior experiments (read_experiment /\n # find_analogous_across_runs / list_experiments), NOT an embedding-similarity auto-reject that can't\n # explain itself and mis-fires on paraphrases. Semantic/param search stays available as TOOLS in the\n # Researcher's context; the LLM decides whether an idea is a true duplicate.\n novelty_semantic: bool = False\n novelty_semantic_threshold: float = Field(default=0.92, ge=0.5, le=1.0)\n # T10: debug-lineage depth bound for every policy — how many error-feedback repairs a failing\n # lineage gets before it is abandoned. The old default (1) abandoned lineages after ONE repair;\n # multi-turn/deeper debugging is a verified lever (AIRA2 ReAct debug +5.5 percentile points).\n debug_depth: int = Field(default=2, ge=1)\n # P4 operator bandit (GreedyTree): replace the fixed merge/ablate cadences with a\n # deterministic UCB over per-operator yield (Δmetric per eval-second, folded from the run).\n # Off by default (the cadences are well-tested and the bandit has no direct published\n # ablation); `thorough` turns it on.\n operator_bandit: bool = False\n # P1 hypothesis ledger: ask the Researcher to state the one-line hypothesis each experiment tests,\n # register deep-research directions as hypotheses, and track them to a verdict on the board. ON by\n # default (audit-only — never changes selection). Set False to drop the prompt nudge + registration.\n track_hypotheses: bool = True\n # E4/M2/M3 reflection-memory -> priors (gradient-free cross-run meta-learning): at run end distill\n # the winner into `<memory_dir>/meta_notes.jsonl` AND structured lessons (incl. NEGATIVE results:\n # tested/abandoned hypotheses + failure themes) into `lessons.jsonl` with a task fingerprint; at run\n # start, inject exact-task notes + fingerprint-matched lessons from SIMILAR tasks into the proposal\n # prompt. ON by default — but a NO-OP until `memory_dir` is set (that's where cross-run memory lives).\n reflection_priors: bool = True\n # M6 comparative lessons (MARS \"comparative reflective memory\", doc 13 §7 item 2): distill\n # credit-assigned lessons from PAIRS of solutions — which SPECIFIC change made a child beat\n # (or regress from) its parent, and what fixed a failure — on top of the one-shot run-end\n # reflection. At most one extra LLM call per distillation (all pairs batched); offline a\n # deterministic param-diff credit stands in. Gated on reflection_priors + memory_dir like the\n # rest of cross-run memory — and since memory_dir has a default (~/.looplab/memory), this is\n # ACTIVE out of the box; clear memory_dir to disable cross-run memory wholesale.\n comparative_lessons: bool = True\n # M6 mid-run cadences (doc 13 §7 item 5 — the AgentRxiv live-share pattern): every N created\n # nodes, WRITE comparative lessons to the shared cross-run store during the run (lessons_every\n # — one batched LLM call per firing) and RE-READ the store so lessons distilled by CONCURRENT\n # runs reach this run's proposals (lessons_refresh_every; file re-read, no LLM call, skipped\n # when the store is unchanged). ON by default (every 4 nodes) and, like comparative_lessons\n # above, live out of the box because memory_dir defaults on; 0 = off — run-end write /\n # run-start read only, the pre-M6 behavior.\n lessons_every: int = Field(default=4, ge=0)\n lessons_refresh_every: int = Field(default=4, ge=0)\n # B3 output redaction: mask credentials (known key shapes + high-entropy tokens) in the\n # stdout/stderr tail before it is persisted to the event log / spans / UI — a leaked secret in a\n # print()/traceback must not land in the durable log. Off by default; recommend on for untrusted.\n redact_output: bool = False\n # B5 reward-hacking detector: a host-side monitor that flags suspicious wins (grader/answer-key\n # access, runtime writes to frozen files, suspiciously-perfect metrics) as a `reward_hack_suspected`\n # audit event in the Trust panel. Off by default. Whether a flag CHANGES selection is governed by\n # `trust_gate` below (default: audit-only, never changes selection).\n reward_hack_detect: bool = False\n # Trust enforcement (T2): what a reward-hack / data-leakage flag actually DOES to selection.\n # \"audit\" (default) = surface it in the Trust panel, never change selection (today's behavior);\n # \"gate\" = a flagged node can no longer be selected as BEST **and isn't bred/confirmed from**\n # (breed_excluded — see replay._apply_trust_gate / models.breedable_nodes), but stays\n # FEASIBLE so it still counts for diversity/audit — closes the \"a hacked/leaky node can\n # win OR seed its lineage\" hole;\n # \"block\" = additionally mark it fully INFEASIBLE (removed from feasible_nodes entirely).\n # Deliberately gates only high-precision CHEATING/LEAKAGE signals; the heuristic `critic` signal\n # stays advisory (audit) in every mode — the field is a per-node quality hint, not proof of a hack.\n trust_gate: str = \"audit\"\n # I3 data-centric: static code-leakage scan of each evaluated solution (fit-before-split,\n # fit-on-test) surfaced into the Trust panel alongside reward-hack flags. Off by default.\n code_leakage_detect: bool = False\n # 4.4 sandbox instrumentation (needs reward_hack_detect): after each eval, flag a RUNTIME write\n # to a protected/frozen file (an answer key or grader tampered mid-run) — behavioral evidence a\n # static code scan misses. ON by default; only fires when the reward-hack detector is on.\n workdir_audit: bool = True\n # D8 decoupled research Verifier: check every Deep-Research memo's CLAIMS against their cited\n # evidence (node ids / urls) before recording — synthesis is the documented weak link (Kosmos\n # 57.9% accurate). Deterministic layer always; an LLM rubric pass when the DeepResearcher has a\n # client. Audit-only (verdicts ride inside the memo). ON by default (no-op with no memo/claims).\n research_verify: bool = True\n # C4 independent critic: an execution-free critic of each solution (stub / hardcoded-metric /\n # params-ignored; on host-graded tasks the metric checks become a submission-output check)\n # surfaced in the Trust panel. Audit-only. Off by default.\n critic_check: bool = False\n # A7 Strategist (NEW, user-requested): optional LLM/rule meta-controller that picks the search\n # policy/allocator + operator mix + fidelity (+ Developer backend) per situation. Config-first:\n # every choice the Strategist makes is also a direct knob. Defaults to \"agent\" so the agent adapts\n # the search policy/operators/fidelity per situation; set \"off\" for static config-driven search.\n # \"agent\" = a tool-using Strategist that READS the run/data/sibling-runs/KB/memory (B1-guarded)\n # before deciding, instead of the single-shot \"llm\" call over aggregate stats.\n strategist_backend: str = \"agent\" # \"off\" | \"rule\" | \"llm\" | \"agent\" — default AGENTIC: the\n # Strategist READS the run/data/sibling-runs/KB/memory with tools before deciding, not a single-shot\n # call over aggregate stats. \"llm\" = the old non-agentic single-shot; \"rule\"/\"off\" = no LLM.\n strategist_every: int = Field(default=3, ge=1) # consult cadence (created nodes)\n # PART V (F1): concept CLASSIFIER re-tag + consolidation cadence, DECOUPLED from strategist_every. The\n # LLM concept map is heavier and slower-moving than a strategy consult, so it refreshes on its own\n # (sparser) interval. Researcher-authored idea.concepts still fold immediately at node_created — this\n # only paces the classifier-evidence + consolidation refresh (and the concept-coverage pivot snapshot).\n concept_retag_every: int = Field(default=30, ge=1)\n # === Confirmation & holdout ===========================================================\n # Multi-seed confirmation (I12, ADR-15): confirm the top-k under N seeds before\n # finishing. 0 disables (default). Only meaningful when eval has variance.\n confirm_top_k: int = 0\n confirm_seeds: int = 0\n # D1 seed-holdout discipline: the FIRST seed the confirm phase uses. Search evals run with the\n # implicit seed 0 (LOOPLAB_EVAL_SEED unset -> \"0\"), so a base of 1 keeps every confirm split\n # DISJOINT from anything the search optimized against — the confirm metric is then a\n # generalization signal, not a re-measurement (AIRA: selecting on a signal the search saw\n # overfits by 9-13 pp). Set 0 to restore the legacy overlapping seeds 0..N-1.\n confirm_seed_base: int = Field(default=1, ge=0)\n # D1 holdout-gated promotion (B6, Arbor-style): for host-graded tasks, reserve this fraction of\n # the held-out labels as a FINAL holdout partition the search never sees — every search/confirm\n # eval is scored on the remaining rows only, and at finish the val-top-k are re-scored on the\n # holdout partition (free: the predictions already exist). 0.0 = off (legacy full-label scoring).\n holdout_fraction: float = Field(default=0.25, ge=0.0, le=0.9)\n # Champion selection prefers the HOLDOUT metric among the nodes that have one (the val-top-k).\n # Recorded in run_started so replay applies the same rule; old logs fold to False (legacy pick).\n # The per-node val-holdout `generalization_gap` folds into the Trust panel either way.\n holdout_select: bool = True\n # How many val-leaders get a holdout evaluation at finish (host-graded tasks).\n holdout_top_k: int = Field(default=3, ge=1)\n # R1-c / Part IV — calibrated §12-verifier tie-break in best-selection. When a fresh node's robust\n # metric EXACTLY TIES an already-eligible node, the engine runs the §12 verifier (grounded on each\n # node's realized result, `selection_criteria`) and records a soundness score per node. The tie-break\n # then applies across the WHOLE selection path: the mean pick breaks a robust_metric tie by the score,\n # and — when `holdout_select` is on — the holdout-slot ranking prefers the sounder node so it isn't\n # denied a holdout eval, and the final holdout override breaks a holdout_metric tie by it too. Strictly\n # a TIE-BREAK — it can NEVER move a node ahead of a strictly-better metric (§21.7 advisory-never-\n # overrides). Best-effort: verification is LAZY (only fires on an actual tie) and bounded per cadence,\n # so a large/late tie stays on deterministic metric+id order unless one atomic treatment scores every\n # member. Recorded in run_started so\n # replay applies the same rule; old logs fold to False (legacy pick). Opt-in (default off); needs a\n # reachable LLM client. Enable only after validating calibration offline with `verifier.calibrate`.\n # See trust/verifier.py + core/fitness.py.\n select_verifier: bool = False\n # R1-d (§21.19): widen the verifier tie-break from EXACT-metric to a conservative STATISTICAL tie.\n # The band is capped by both the metric leader's SE and the pooled SE-of-difference, so challenger\n # variance cannot widen it and a significant difference is never a tie (§21.7). Off -> exact only.\n verifier_ci_tie: bool = False\n # Verifier sample count for `select_verifier` (the §12 repeated-sampling expectation). 3 tames the\n # single-shot variance; 1 = single-shot (cheaper, noisier).\n select_verifier_samples: int = Field(default=3, ge=1, le=32)\n # Budget (I13): hard wall-clock ceiling; the run aborts cleanly when exceeded.\n max_seconds: float | None = None\n # Eval-compute budget (#2): hard ceiling on cumulative time spent INSIDE evals (training\n # runs), separate from wall-clock. Survives resume (summed from the event log). The real\n # guard against a silent multi-hour sweep when an eval is a minutes-hours training run.\n max_eval_seconds: float | None = None\n # Cross-run memory (I19, ADR-10): if set, the best result of each run is stored as\n # a case here, and the cases become retrievable knowledge for future runs.\n memory_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"memory\"))\n # HITL (I21, ADR-11): pause for human approval of the final best before finishing.\n require_approval: bool = False\n # Diversity archive (I22): niche bucket width in parameter space.\n archive_resolution: float = 1.0\n # Coverage read-model (narrowing signal): at the strategist cadence, compute a breadth summary\n # (themes / param-niches / theme entropy / dominant-theme fraction) from the folded run, record\n # it as a `coverage_snapshot` audit event, and surface it into the Strategist's decision context.\n # Deterministic, cheap, and purely additive context — no search-behavior change on its own. On by\n # default (like the rest of the always-on situational context). See search/coverage.py.\n coverage_context: bool = True\n # PART IV Phase 2a live steering (§21.11/§21.13). When on, the `concept_retag_every` cadence (NOT\n # `strategist_every` — the producer gates on `_should_consult_concepts`, which uses the seed boundary\n # + `concept_retag_every`, default 30) records a\n # concept-graph coverage + uncovered-region snapshot (deterministic heuristic tagger over the\n # task-type skeleton) and, on an `explore` stance, the Researcher's novelty hint names the exact\n # uncovered regions (\"0 coverage in {negatives/external-mining, distillation} — go there\") instead\n # of the vague \"broaden\". ON by default in the product Settings (ce4a379), EngineOptions off: it\n # only enriches an already-mode-gated explore hint, never forces exploration, and no-ops for a task\n # with no curated concept skeleton. The snapshot is auditable, but its prompt cue can change future\n # candidate generation; it does not directly rewrite best-metric selection. See search/concept_graph.py.\n # DESIGN NOTE (2026-07-17 critique): now ON unconditionally, but a toy / non-ML task yields EMPTY concept\n # tags while still paying the per-node LLM concept cost (~10 calls/node with the full Part IV/V bundle).\n # Consider AUTO-QUIESCING: if the heuristic tagger returns empty for the first N nodes (no concept signal),\n # skip the LLM concept work for the run — keeps it on for ML tasks, free on toy/degenerate ones.\n concept_pivot: bool = True\n # PART IV Phase 2b — D3 graded novelty into the LIVE novelty gate (§21.4/§21.13). When on and the\n # task has a curated concept skeleton, the novelty gate first grades a proposal over the concept\n # graph: a level-4 \"same direction, DIFFERENT implementation\" or a level-5 \"re-opens a\n # wrongly-abandoned FAILED direction\" proposal is ALLOWED through unchanged — the flat LLM/semantic\n # dedup gate can't tell \"this DCL tweak\" from \"the whole DCL branch\" and would wrongly reject a\n # legitimate variant or never re-open a sound-but-killed direction (the node_63 archetype). Levels\n # 1/2/3 (identical / near-dup / prior-run) still fall through to the existing gate. Deterministic\n # (heuristic tagger, no LLM), audit event `novelty_graded`, replay-safe. ON by default in the product\n # Settings (ce4a379), EngineOptions off; no-ops for a task with no skeleton. See search/graded_novelty.py.\n graded_novelty: bool = True\n # PART IV Phase 2b — D7 capability-expansion forced-jump DIRECTIVE (§21.8/§21.13, issue #7). When on\n # and the concept-graph cadence detects action-space LOCK-IN (the search has stayed inside one D5\n # branch for a long consecutive streak) on an `explore` stance, the Researcher's novelty hint\n # ESCALATES from \"broaden\" to \"expand the ACTION SPACE — build the missing infra (external mining,\n # a new data pipeline, a different eval), do NOT swap another {locked} lever.\" Prompt-cue only, on\n # top of the already-mode-gated explore hint; never adds a scored policy action and never touches\n # selection (the SCORED capability-expansion operator waits on the R1/SearchFitness gates, §21.13).\n # Reads the 2a concept-coverage snapshot, so it needs `concept_pivot` to record one. OPT-IN\n # (default off). See engine/proposal_cues.py + search/lock_in.py.\n capability_expansion: bool = False\n # PART IV cross-run Step 0 (§21.20.12/§21.20.13). Universal task-fingerprint tokenization: drop the\n # ASCII-only `[a-z0-9]` allowlist on goal keywords so a non-Latin goal (Russian, CJK, …) is NOT\n # silently dropped from its cross-run fingerprint (today such a goal keys only on kind/dir/metric and\n # never reaches SIMILAR-task priors/lessons/cases). Uses `[^\\W_]+`/`.casefold()` — same splitting as\n # before, any script. ON by default in the product Settings (ce4a379); the bare-library EngineOptions\n # default stays off, so a run pinned to it won't silently re-key a portfolio mid-flight. See engine/memory.py.\n fingerprint_universal: bool = True\n # PART IV cross-run Step 2 (§21.20). Fill graded-novelty's `prior_concepts` from the cross-run\n # ConceptCapsuleStore: at run end write a per-run concept capsule (the shipped `node_concepts` tags +\n # best outcome, keyed by task_fingerprint) to `memory_dir`; when a later SIMILAR run proposes an idea\n # whose concept was tried before, SURFACE the prior outcome as a `cross_run_prior` audit event (never\n # reject — D3 level 3). Audit-only, off the selection path; ON by default in the product Settings\n # (ce4a379), EngineOptions off. Needs a `memory_dir` to share capsules.\n # See engine/memory.py (ConceptCapsuleStore) + engine/novelty.py.\n # PREREQUISITES (CODEX — this flag is NOT standalone): the WRITE needs per-run concept tags\n # (`node_concepts`, produced by `concept_pivot`/F1 tagging) — no tags => no capsule; the SURFACE path\n # runs inside the graded-novelty precheck, so it also needs `graded_novelty` on. With only this flag +\n # `memory_dir` it persists/surfaces nothing. Enable `concept_pivot` + `graded_novelty` alongside it.\n # A non-Latin (Russian/CJK) portfolio should ALSO set `fingerprint_universal`, else the capsule's goal\n # tokens are dropped and its fingerprint over-matches on kind/dir/metric alone (the novelty direction\n # gate narrows this, but goal-word similarity is still lost).\n cross_run_concepts: bool = True\n # PART V (B): run-base + node-DELTA concept authoring. When on, once the first evaluated node has\n # authored concepts the engine seeds RunState.run_base_concepts from them (a one-shot EV_RUN_CONCEPTS,\n # idempotent because it only fires while the base is empty), and the Researcher's proposal hint tells it\n # to author only `concepts_added`/`concepts_removed` vs the run base + its parent — minimal per-node\n # annotations that inherit down the DAG (a swap propagates). Off -> every node authors its full concept\n # set as before. Product-on; the bare EngineOptions mirror defaults off (test_options_divergence).\n concept_run_base: bool = True\n # PART IV cross-run Step 5 advisory (§21.20.5). Fold the bounded cross-run CONTEXT PACK — evidence-\n # grounded claims with BOTH support and counter-evidence (Step 4) + a portfolio-coverage line (Step 3) —\n # into the Researcher's proposal prompt, exactly like the E4 cross-run prior note. Advisory ONLY: it is\n # prompt-grounding: it can change the Researcher's later candidates, but does not directly rewrite\n # best-metric selection (§21.7). Reads `memory_dir` (lessons.jsonl + concept_capsules.jsonl); \"\" when\n # empty. ON by explicit experimental product choice; no frozen A/B validation is claimed. The\n # bare-library EngineOptions default stays off (engine/options.py, frozen in\n # test_options_divergence). See engine/proposal_cues.py.\n cross_run_advisory: bool = True\n # PART IV cross-run §21.20.13 (full CR of the lean fuzzy claim merge). Switch the claim read-model to the\n # SCOPE+POLARITY-safe STRUCTURED claim key (engine/claim_key.py): claims from different tasks never\n # merge, opposite polarity (\"X helps\" vs \"X never helps\") is surfaced as a CONTRADICTION instead of being\n # collapsed, paraphrase/inflection variants group by exact structured key (no transitive over-merge), and\n # operator governance is scope-precise (a decision in task A cannot reach a same-worded claim in task B).\n # Affects the `cross_run_advisory` context pack only; ON by default in the product Settings (ce4a379);\n # the bare-library EngineOptions default stays off (engine/options.py). See engine/claims.py.\n cross_run_structured_claims: bool = True\n # PART IV cross-run §22.4 (AGENTIC taxonomy steward). At finalize, when an LLM client is available, let\n # the concept steward (engine/concept_steward.py) review the freshly-updated portfolio concept graph and\n # PROPOSE a curation — merge duplicate slugs / split conflated ones / purge noise. Proposals are LOGGED to\n # `concept_curation_log.jsonl` for operator ratification (via the serve/CLI governance surface); the LLM\n # never mutates fold state. The calls run synchronously during finalize, so they add latency and may add\n # paid inference; a caught steward failure does not prevent terminal completion or trigger a retry.\n # ON by explicit experimental product choice when `memory_dir` + an LLM backend are present; the\n # bare-library EngineOptions default stays off (engine/options.py). See engine/lessons.py.\n cross_run_curation: bool = True\n # Deprecated compatibility flag. Steward output is untrusted and finalize is proposal-only regardless\n # of this value; retained so old snapshots still validate and logs can disclose that auto was requested.\n cross_run_curation_auto: bool = False\n # Role backend (ADR-7/14): \"toy\" (offline optimizer) | \"llm\" (live model).\n backend: str = \"toy\"\n # Developer backend (ADR-7): \"default\" (templated/LLM from the task) or an external\n # CLI coding agent: \"opencode\" | \"aider\" | \"goose\" | \"continue\".\n developer_backend: str = \"default\"\n # C2 best-of-N: generate N candidate implementations per node and keep the best by an\n # execution-free reward (static validity + metric-print). 1 = off. In-house LLM developer only\n # (not external agents — ADR-7 cost rule). The top SWE-bench reliability lever for weak models.\n best_of_n: int = 1\n # D10 list-wise best-of-N selection: when the top static-scorers TIE, break the tie with a\n # comparative LLM selection over the candidates presented together (+~3 pts vs pointwise on\n # GAIA, arXiv:2506.12928). The static score stays the primary filter — the LLM is a weak\n # comparative prior, never the sole oracle. ON by default (no-op when best_of_n==1).\n best_of_n_listwise: bool = True\n # === Foresight / predict-before-execute ===============================================\n # FOREAGENT predict-before-execute (arXiv:2601.05930): use the LLM as an IMPLICIT WORLD MODEL to\n # predict — WITHOUT executing — which candidate / hypothesis will score best, primed with a\n # Verified Data Analysis Report (data profile / brief) + the accumulated experiment memory. Master\n # switch, ON by default; a no-op wherever there is nothing to rank (best_of_n==1 and\n # foresight_panel==1) or no LLM client. See `looplab/search/foresight.py`.\n foresight: bool = True\n # Hypothesis foresight breadth: generate K candidate ideas per proposal and keep the one PREDICTED\n # best pre-execution by the world model — it ranks the STRUCTURAL / text ideas the numeric k-NN\n # surrogate (researcher_panel) is blind to, primed with the data profile + memory (the synergy).\n # >1 enables it (LLM backend only; needs `foresight` on); 2 = on by default at modest cost, 1 = off.\n foresight_panel: int = 2\n # AGENTIC foresight: run the ranking (hypothesis-board prioritization + K-idea pick) as a TOOL-USING\n # loop that can pull actual experiment results / data facts before deciding, instead of a one-shot\n # prediction from a pre-baked report. ON by default (needs foresight + a client; falls back to the\n # one-shot predictor on any hiccup). A few extra LLM calls per proposal; set False for the cheaper\n # single-call ranker.\n foresight_agentic: bool = True\n # §1 foresight confidence gate: the minimum predicted confidence at which a predict-before-execute\n # pick is ACTED ON. Below it the ranker abstains — the K-idea panel falls back to the first\n # proposal, best-of-N falls through to the D10 tie-break — instead of committing a low-confidence\n # choice. 0.0 (default) = OFF (act on every pick, the historical behavior); raise toward ~0.5 to\n # make the world model defer when it isn't sure. Pairs with the foresight track record (§1) the\n # predictor is now primed with. Range 0.0-1.0.\n foresight_min_confidence: float = 0.0\n # PART IV Phase 2c — replace the world model's SELF-REPORTED confidence (measured Pearson≈0 with the\n # realized outcome, §21.12) with a CALIBRATED §12-verifier score. When on and a client is available,\n # after the K-idea ranker picks the predicted-best candidate the §12 verifier scores it (grounded +\n # repeated + criteria-decomposed: `foresight_criteria` — likely to improve the objective, and\n # sound/feasible) and THAT calibrated score becomes the confidence the `foresight_min_confidence` gate\n # and the telemetry use. Degrades to the self-reported confidence without a client or on any verifier\n # error (the telemetry records which source was used). ON by default in the product Settings (ce4a379),\n # EngineOptions off; a few extra LLM calls per acted-on proposal. See looplab/trust/verifier.py + search/foresight.py.\n # REVISIT (cost/quality, not yet resolved): a live classification run measured ~10 LLM calls & ~3.8 min/node\n # on deepseek-v4-flash with the full Part IV/V default bundle; the calibrated verifier is part of that per-node\n # tax. Kept ON deliberately — it raises decision quality (self-reported foresight confidence has ≈0 Pearson\n # with realized outcome), and lowering quality to save calls is the wrong trade. TODO: run a frozen quality/cost\n # A/B (verifier-on vs -off) to quantify the recall@objective gain per extra call before considering a default flip.\n foresight_verify: bool = True\n # Verifier sample count for `foresight_verify` (the §12 repeated-sampling expectation on a no-logprob\n # backend). 3 tames the single-shot variance §21.12 measured; 1 = single-shot (cheaper, noisier).\n # RESUME-critical: this value is persisted in config.snapshot.json, so a hard `le` schema bound would\n # make an old run started with a larger LOOPLAB_FORESIGHT_VERIFY_SAMPLES un-resumable (Settings(**snapshot)\n # would raise on `looplab resume` / finalize-recovery / serve replay). CLAMP into [1, MAX] via a validator\n # instead of rejecting — the runtime cap is preserved without breaking the snapshots/env-compat rule.\n foresight_verify_samples: int = 3\n\n @field_validator(\"foresight_verify_samples\")\n @classmethod\n def _clamp_foresight_verify_samples(cls, v: int) -> int:\n # Coerce a persisted/env value into [1, MAX] rather than reject (see field note above).\n try:\n v = int(v)\n except (TypeError, ValueError):\n return 3\n return max(1, min(v, MAX_FORESIGHT_VERIFY_SAMPLES))\n\n # T7 LLM response cache: serve an identical DETERMINISTIC (temperature 0) request from an\n # in-process content-addressed cache instead of re-hitting the model — cuts cost on\n # retry/panel/verify flows. NEVER caches sampling calls (temp>0: best-of-N / panel / novelty\n # re-propose must vary). Off by default (most role calls run at temperature>0 anyway).\n llm_cache: bool = False\n agent_cmd: str | None = None # override the agent's launcher (path / wrapper)\n # External-agent validation (ADR-7): wrap a CLI-agent Developer with a validator that\n # audits each output (no-op/syntax/crash/timeout), retries with feedback, and falls\n # back to the in-house LLM Developer. Off -> use the raw agent output unchecked.\n validate_agent: bool = True\n agent_max_retries: int = 1 # re-prompts of the agent on an invalid result\n # Patch-gated multi-file agent (ADR-7 Rule 3): run the CLI agent in a git worktree and\n # accept only edits whose paths match `agent_surface` (reject-not-strip out-of-surface\n # touches). Lets the agent create helper modules, not just solution.py. Degrades to\n # whole-file readback if git is unavailable.\n agent_patch_gate: bool = True\n agent_surface: list[str] = [\"*.py\"] # edit-surface allow-list (globs)\n # Trust policy for an agent-authored eval/metric adapter (RepoTask onboarding, Phase 3):\n # \"ratify_freeze\" waits for human approval, \"autonomous\" accepts the proposed spec without that\n # gate, and \"ratify_freeze_drift\" also enforces the configured independent drift cross-check.\n eval_trust_mode: str = \"ratify_freeze\"\n # RepoTask node seeding policy (run-wide fallback; a task/editable `seed_mode` overrides):\n # \"auto\" (default) copies git-tracked files when the editable is a git repo (so a tree bloated\n # with untracked artifacts isn't deep-copied per node), else a full copy; \"tracked\" forces\n # code-only; \"all\" forces a full recursive copy (legacy). Genesis authors per-task from the\n # user's words; this is the global default when unspecified.\n seed_mode: str = \"auto\"\n llm_model: str = \"qwen3:8b\"\n # === LLM / transport ==================================================================\n llm_base_url: str = \"http://localhost:11434/v1\" # Ollama OpenAI-compatible endpoint\n llm_temperature: float = 0.6\n # H3 per-role model presets (5090 recipe): optionally run the Researcher and Developer on\n # DIFFERENT models/endpoints (e.g. Developer=Qwen3-Coder-30B for code, a fast model for breadth).\n # Blank = use the shared llm_model/llm_base_url. Generalizes the role-backend swap to per-role.\n researcher_model: str | None = None\n developer_model: str | None = None\n researcher_base_url: str | None = None\n developer_base_url: str | None = None\n # Per-role sampling temperature (§4.1): the Researcher wants breadth (higher temp = more diverse\n # ideas), the Developer wants determinism (lower temp = fewer syntax slips), the Strategist a\n # steady hand. Each overrides the shared `llm_temperature` for that role only; None = use the\n # shared value (byte-identical to before). Flat + nullable, mirroring the per-role model fields.\n researcher_temperature: float | None = None\n developer_temperature: float | None = None\n strategist_temperature: float | None = None\n # Unified self-driving agent: one LLM identity that plays Researcher + Developer (+ Strategist)\n # across pipeline stages, choosing its own model/toolset per stage. ON by default — the agent\n # drives the loop (incl. crash triage: repair/abandon/reject_idea) and, via\n # `agent_drives_actions`, picks the next macro action within a pure legal-action gate. Set both\n # to False for the legacy byte-identical split-role behavior. (No-op unless backend=\"llm\".)\n unified_agent: bool = True\n agent_drives_actions: bool = True\n # Layer 3 Card queue ownership. False preserves the existing policy/unified-pilot action path.\n # True is pinned in run_started because changing the selector on resume would mix two search\n # treatments in one run. When both this and agent_drives_actions are true, Card selection wins.\n card_driven_selection: bool = False\n # Layer 5 bounded speculative Card buffer. Zero is a hard OFF switch and preserves the\n # historical alternating build/eval spine; positive values are pinned by run_started so a\n # resume cannot silently mix search treatments after a snapshot/default edit.\n speculation_depth: int = Field(default=0, ge=0, le=64)\n # Local evidence receipt produced by ``looplab speculation-gate``. This is deliberately absent\n # from the curated Settings UI: positive-depth execution is a calibrated rollout decision, not a\n # casual tuning knob. The Engine validates the receipt against the current scorer,\n # implementation, GPU inventory and raw paired-run evidence before it enables speculation.\n speculation_gate_receipt: str | None = Field(default=None, max_length=4096)\n # Per-stage model/endpoint overrides for the unified agent. Recognized keys (see\n # tasks.build_unified_agent): `propose` (researcher), `implement`/`repair` (developer),\n # `strategy`, `pilot`. Unlisted keys are ignored. Empty = the per-role researcher_*/developer_*\n # models, then the shared llm_model. Explicit map wins. No-op unless `unified_agent` is true.\n agent_stage_models: dict = {}\n agent_stage_base_urls: dict = {}\n llm_parser: str = \"tool_call\"\n # Reasoning/thinking toggle sent IN the request (providers differ — see `llm.reasoning_body`):\n # Qwen3 on vLLM/SGLang -> chat_template_kwargs.enable_thinking (bool)\n # OpenAI/Ollama-v1/DeepSeek -> reasoning_effort (low|medium|high|none)\n # `llm_reasoning`: \"\" = send nothing (server default — current behavior); off = actively disable;\n # on = enable at default depth; low|medium|high = enable at that effort.\n # Defaults to \"high\" so the agent reasons before proposing/repairing/triaging (better code, fewer\n # crashes); set \"\" to send nothing (server default) or \"off\" to disable. The model's chain-of-thought\n # is CAPTURED either way (reasoning_content field or inline <think>). NOTE: to get a SEPARATE\n # reasoning_content field from SGLang/vLLM, the server also needs `--reasoning-parser qwen3`.\n llm_reasoning: str = \"high\"\n llm_reasoning_style: str = \"auto\" # auto | qwen | effort | none — how to shape the request param\n llm_reasoning_extra: dict = {} # raw fields merged into the body (escape hatch, e.g. Anthropic thinking)\n # H1: drive structured calls with the endpoint's constrained decoding (vLLM/SGLang guided_json +\n # response_format json_schema) so weak models can't emit invalid JSON. Off for Ollama (default).\n llm_guided_json: bool = False\n # Stream every LLM request (SSE) and reassemble it, so the request `timeout` is an INTER-TOKEN\n # idle timeout rather than a whole-request deadline: a long-but-alive generation is never cut off\n # (tokens keep resetting the timer), while a genuinely STALLED endpoint (no token for `timeout` s)\n # is detected and retried on a fresh connection — the fix for silent multi-minute hangs. Set False\n # to use one blocking read (old per-op timeout semantics) if an endpoint streams badly.\n llm_stream: bool = True\n # LLM IDLE timeout (seconds). Stream mode: the INTER-TOKEN stall limit — a steady generation is\n # never cut off, only a silent endpoint. Non-stream: bounds the whole read. Raise for endpoints\n # with a long prefill on huge prompts; lower to fail fast on a flaky shared endpoint.\n llm_timeout: float = Field(default=180.0, gt=0)\n # First-byte (response-headers) window for STREAM attempts, seconds: an SSE response sends its\n # headers on admission, so no-headers ≈ a black-holed request — fail over to a fresh connection\n # fast instead of waiting the full idle timeout. Clamped to llm_timeout. Non-stream attempts are\n # NOT bounded by this (their headers arrive only after the whole generation). The default mirrors\n # `DEFAULT_HEADER_TIMEOUT_S` in looplab/core/llm.py is the single source (imported above).\n llm_header_timeout: float = Field(default=DEFAULT_HEADER_TIMEOUT_S, gt=0)\n # Honour HTTP(S)_PROXY / NO_PROXY / SSL_CERT_FILE env vars for LLM calls. Default False: the\n # transport connects DIRECTLY (many internal/OLAP endpoints are reachable only by bypassing an\n # ambient proxy — a picked-up proxy yields \"connection refused\"). Set true when the endpoint is\n # reachable ONLY via a corporate proxy or needs a custom CA bundle from the environment.\n llm_trust_env: bool = False\n # H4/C2: compact the growing agentic tool-call history once it exceeds this many chars (auto_summary\n # LLM-summarizes the stale middle; else middle-truncate). Sized to the model's real context window so\n # files read earlier STAY in context instead of being compacted away and re-read: ~1,000,000 chars ≈\n # 250k tokens (≈4 chars/token), matching the configured deepseek-v4-flash (≥200k tokens confirmed).\n # The old 120k-char (~30k-token) default was ~8× too aggressive and drove a read-thrash loop. 0 = off.\n context_budget_chars: int = 1_000_000\n # M5: the Researcher's always-on experiments digest budget (chars). 0 = AUTO — scales with the\n # run size (60 chars/node, bounded [1200, 6000]) instead of one flat cap for an 8-node toy run\n # and a 200-node benchmark run alike. Set a positive value to pin it.\n digest_char_cap: int = Field(default=0, ge=0)\n # D11 compression model slot (open_deep_research's four-slot pattern): a dedicated CHEAP model\n # for agent-history summarization (C2 auto-summary), so compression doesn't pay the main\n # model's price. Blank = use each loop's own model (legacy). `compressor_base_url` blank =\n # the shared llm_base_url.\n compressor_model: str | None = None\n compressor_base_url: str | None = None\n # === Agentic tool-loop ================================================================\n # Agentic tool-loop limits — apply to EVERY tool-using agent (the LLM Researcher, the unified\n # agent's pilot + crash-triage, Deep-Research, the run-chat Boss, the genesis repo-scout, and the\n # cross-run report synthesizer). The loop lets the model call read-only tools across turns before\n # emitting its structured result. Both default to UNLIMITED so the agent takes as many turns / as\n # long as it needs — it is never cut off mid-reasoning; set a positive value here (or per-run, or\n # in the UI settings) to bound latency/cost. These used to be hardcoded per call site (e.g. the\n # boss at max_turns=3 / 45s), which silently dropped a slow reasoning model to a no-op reply.\n agent_max_turns: int = 0 # max tool turns before the emit is forced (0 = unlimited)\n agent_time_budget_s: float = 0.0 # wall-clock ceiling across the loop's turns (0 = no cap)\n # G · Soft convergence SAFETY NET (not a research cap). Two thresholds on the agent's tool-turn count:\n # at `agent_emit_after` the loop NUDGES the agent once (\"you've investigated enough — emit now\"); at\n # `agent_emit_force` it FORCES the emit from what was gathered. Set GENEROUSLY — a focused researcher\n # may legitimately read many files/experiments before it can propose a grounded idea; these only catch\n # a genuine runaway that keeps issuing DIFFERENT calls forever (which stuck-detection, keyed on\n # repeats, misses). NB: the old 499-read runaway (node 63) was 79% REDUNDANT re-reads caused by a\n # broken read_file — fixing read_file (pagination) is the real cure; this is just insurance. 0 = off.\n agent_emit_after: int = 300\n agent_emit_force: int = 500\n # B1 · No-progress / stuck detection — the safety net that makes \"unlimited turns\" safe. The\n # turn/time ceilings above are only BACKSTOPS; this is what actually stops a runaway loop, on the\n # cheapest signal: when the model repeats the SAME tool call (or ping-pongs between two, or keeps\n # hitting the SAME error) with no progress, the loop forces the final emit and finishes. ON by\n # default; reading DIFFERENT files or running ONE long command never trips it. (OpenHands-style.)\n agent_stuck_detection: bool = True\n agent_stuck_repeat: int = 4 # identical calls in a row that count as \"stuck\" (>=2)\n agent_stuck_alternate: int = 4 # ping-pong cycles between two calls that count as \"stuck\" (>=2)\n # C1 · Self-plan (TodoWrite-style): expose an `update_plan` tool so a long-running agent keeps its\n # OWN working TODO and re-surfaces it every `agent_plan_reinject_every` turns — keeps the goal in\n # view across a long loop so the agent \"writes its own context\" (Claude Code style). ON by default.\n agent_self_plan: bool = True\n agent_plan_reinject_every: int = 5\n # C2 · Auto-summary (ON by default): when the tool-loop history grows long, LLM-summarize the\n # stale middle instead of only middle-truncating it. The trigger is `context_budget_chars` if set,\n # else a built-in high-water mark (context_budget.DEFAULT_SUMMARY_CHARS ≈ 120k chars) so short\n # loops are untouched. Falls back to truncation on any summarizer error; one extra model call per\n # over-budget turn.\n agent_auto_summary: bool = True\n # C4 · Repo-developer plan decomposition. A big feature attempted in ONE developer session made a\n # non-converging reasoning model loop indefinitely — it kept writing + exploring without ever\n # emitting `done` (10k+ LLM calls / hours; agentic stuck-detection can't catch VARIED exploration,\n # only literal repeats). Fix: the developer first proposes an ordered plan of ATOMIC steps; a\n # multi-step plan is executed step-by-step, each in a FRESH bounded session with only that step's\n # scope + the files accumulated so far, syntax-validated per write. Every session stays\n # small-context and convergent. ON by default for repo tasks (a 1-step plan == the old single pass).\n developer_plan_decompose: bool = True\n developer_plan_min_steps: int = 2 # a proposed plan with >= this many steps runs step-by-step\n developer_plan_max_steps: int = 8 # cap on plan length (a runaway planner can't spawn 100 steps)\n # Hard per-session backstop for the repo developer — the ONE write-heavy agent that can run away\n # even with stuck-detection on (varied writes/reads never trip the repeat/ping-pong signal). Bounds\n # the plan phase, EACH step, and the single-session fallback. Unlike the global agent_* limits\n # (0 = unlimited, meant for read-only agents), the developer always gets a FINITE ceiling so a\n # model that never emits `done` fails cleanly with the code it wrote so far, instead of looping.\n developer_session_max_turns: int = 500\n developer_session_time_budget_s: float = 1200.0 # 20 min wall-clock per developer session\n # Phase-handoff summaries. Each LLM phase in a node build (Researcher·propose → Developer·stages →\n # plan → implement) ends with ONE extra LLM call that distills its transcript — the repo structure\n # it mapped, files/data confirmed, decisions made — into a brief injected into the NEXT phase (even\n # across the role boundary). The next phase TRUSTS that instead of re-reading the same files, which\n # cuts the biggest source of tool-call thrash (stages/plan/implement each re-exploring the repo).\n # One summary call per phase in exchange for many fewer read calls downstream. ON by default.\n phase_handoff_summary: bool = True\n # Observability (ADR-17): capture each LLM call's full prompt + completion into the active\n # span (spans.jsonl) so the UI can show exactly what the model read and wrote per node.\n # Diagnostics only — `replay.fold` never reads spans. Default on for local single-user; set\n # LOOPLAB_TRACE_LLM_IO=0 to suppress (e.g. to keep spans.jsonl small or avoid storing prompts).\n trace_llm_io: bool = True\n # Run-introspection tools (ON by default): make the LLM Researcher (and DeepResearcher) a\n # tool-using agent that can read its OWN experiments (list/read/find-analogous/themes/code) and\n # the task data (schema/profile/asset) mid-loop, instead of seeing only best+parent. Advisory —\n # never changes best-selection. Off = the legacy single-shot Researcher (richer digest still added).\n researcher_tools: bool = True\n # Cross-run introspection: give the agentic Researcher / pilot read-only tools to look at\n # SIBLING runs (same task_id, same run-root) — list them, read an experiment / its code, and\n # find analogous configs across runs — so a run can build on what neighbouring runs already\n # learned instead of rediscovering it. Advisory; never changes best-selection. Needs the run's\n # own dir wired through (no-op for unit-built roles). Off = the legacy single-run view only.\n cross_run_tools: bool = True\n # Read-only access to EVERY run on this machine ACROSS ALL TASKS (not just same-task siblings):\n # the Developer/Researcher get list_all_runs + read_run_code + read_run_experiment so they can read\n # the code + result of ANY past experiment anywhere and reuse an approach. Broader than\n # `cross_run_tools` (same-task only); the agent decides when a foreign run is relevant. Advisory;\n # never changes best-selection. Needs the run's own dir wired through (no-op for unit-built roles).\n all_runs_tools: bool = True\n # PART V §22 — read-only CROSS-RUN KNOWLEDGE tool for the reasoning roles (Researcher, Strategist,\n # deep-research, and a Developer-scoped variant). Adds `cross_run_prior_attempts` / `cross_run_claims`\n # / `cross_run_atlas` over the §21.20 read-models (concept overview + claim assessments + atlas), read\n # from `memory_dir` (lessons.jsonl + concept_capsules.jsonl). Read-only is a storage-authority\n # boundary, not a behavioral no-op: retrieved content can steer later proposals, and a tool-using\n # reasoning loop can consume additional model calls. The tools never mutate cross-run truth (facts\n # are engine-written, verdicts operator-ratified, §22.4) or directly select a winner. ON by explicit\n # experimental product choice, EngineOptions off; stores only exist once Part-IV features have run.\n cross_run_read_tools: bool = True\n # Agentic retrieval (ADR-16): if set, the LLM Researcher gets grep/kb_search/read\n # tools over this directory of markdown notes and chooses when to use them.\n knowledge_dir: str | None = Field(default_factory=lambda: str(_LL_HOME / \"knowledge\"))\n # T4 real embeddings: model id for an OpenAI-compatible `/embeddings` endpoint used for\n # kb_search / case retrieval. Blank (default) = the dependency-free lexical `hash_embed` (today's\n # behavior). Set e.g. \"nomic-embed-text\" (Ollama) or \"text-embedding-3-small\" for SEMANTIC\n # retrieval; a misconfigured/offline endpoint degrades back to hash_embed at call time (never\n # crashes a run). `embed_base_url` blank = reuse `llm_base_url`; the shared `llm_api_key` is used.\n embed_model: str | None = None\n embed_base_url: str | None = None\n # Harmonic memory (Memora, ICML'26 — idea import). When ON, cross-run cases and knowledge notes are\n # indexed by a short ABSTRACTION + cue ANCHORS instead of their raw text, near-duplicate memories are\n # CONSOLIDATED into one entry under a matching abstraction, and `kb_search`/case retrieval EXPAND\n # through the top hits' anchors to surface related-but-not-similar memories. ON by default. Set\n # `memora=false` to restore the pre-Memora raw-text index. Never a source of truth — abstractions\n # live only in the derived, rebuildable retrieval index (never the event log or canonical cases.jsonl).\n memora: bool = True\n # Write abstractions with the wired chat model (richer than the deterministic lexical fallback). ON\n # by default; results are CACHED by content hash (see `memora_cache`) so a re-built index doesn't\n # re-call the model on unchanged notes/cases, and any endpoint failure degrades to lexical at call\n # time — so an offline box just gets lexical abstractions, never a crash. Set false to force lexical.\n memora_llm: bool = True\n # === Cross-run memory / Memora ========================================================\n memora_anchors: int = 6 # max cue anchors kept per memory\n # Cosine similarity at/above which a new case is CONSOLIDATED into an existing one (same evolving\n # topic) rather than stored as a separate entry. Only used when `memora` is on.\n memora_consolidate_threshold: float = 0.86\n # Where the LLM-abstraction cache lives (JSON). Blank (default) derives it from `memory_dir`\n # (`<memory_dir>/memora_cache.json`) or else `knowledge_dir` (`<knowledge_dir>/.memora_cache.json`);\n # with neither set the cache is in-memory only (per process). No-op unless `memora_llm` is on.\n memora_cache: str | None = None\n # E3 literature-grounded ideation (network-OPTIONAL): give the agentic Researcher an arXiv search\n # tool to ground ideas in real techniques. Off by default (egress is unreliable on some boxes);\n # fails gracefully if the network is blocked.\n literature_search: bool = False\n # Deep-Research stage (network-OPTIONAL): give the DeepResearcher a general web search/fetch\n # tool (DuckDuckGo) on top of arXiv to read across results + the web before steering the next\n # batch. Off by default (egress is unreliable on some boxes); fails gracefully when blocked.\n web_search: bool = False\n # Cadence for the Deep-Research stage: run it automatically every N created nodes (0 = off; it\n # still fires on a manual `deep_research` control event or a Strategist `request_research`).\n # Default 3: deep research analyzes the accumulating results and steers the next batch (its\n # directions become hints + open hypotheses); paired with `concurrent_research` it overlaps the\n # think with the GPU-bound eval, so the cadence costs LLM tokens but no wall-clock on the search.\n deep_research_every: int = 3\n # Overlap a DUE deep-research \"think\" with the GPU-bound eval instead of running it in its own\n # serial step (the agent is otherwise idle while a node trains). research() is pure compute on a\n # state snapshot — the engine still records the memo as the sole writer AFTER the eval, so the\n # event log stays single-writer. ON by default: the LLM is typically remote (no GPU contention\n # with eval), so overlapping is a free intelligence win over the long per-node training time.\n concurrent_research: bool = True\n # Concurrent-research REPEAT — don't idle a multi-DAY eval. The overlapped \"think\" fires ONCE per\n # window by default; ON here, it RE-RUNS on an adaptive time cadence for the WHOLE eval window\n # (a two-day training must not leave the reasoning agents idle after one memo). Self-paced: it\n # only records a memo whose CONTENT is new (identical re-runs are skipped, so the log/board don't\n # bloat) and backs off geometrically as the analysis converges, capped so it always re-checks.\n # Advisory-only (BACKGROUND_APPENDABLE — never touches node selection or replay). The library\n # default is one-shot (== today, byte-identical); this product default turns the repetition on.\n concurrent_research_repeat: bool = True\n # Base cadence (seconds) between repeated concurrent-research passes. Research is EXPENSIVE\n # (multi-turn LLM + web/arXiv), so this is a FLOOR, not a ceiling: the effective cadence is\n # max(this, ~5% of the per-experiment time budget) — a two-day eval is re-researched about hourly,\n # a short eval not at all (the first tick outlasts it). Only matters when the repeat is on.\n concurrent_research_interval_s: float = Field(default=1800.0, gt=0)\n # Per-eval-window backstop on repeated-research LLM calls (0 = cadence-only, no cap). The adaptive\n # cadence + convergence backoff are the primary budget control; this bounds a pathological\n # never-converging window. Past it the loop stops calling the LLM (the health monitor still runs).\n concurrent_research_max_calls: int = Field(default=40, ge=0)\n # Concurrent hypothesis-board CONSOLIDATION during the eval window. Repeated research keeps ADDING\n # near-duplicate directions to the open board (each memo registers up to 5 as open hypotheses), so a\n # long eval's board bloats before the between-nodes merge runs. This dedups/merges it on the SAME\n # background loop so the board stays clean for the next proposal. In Card-driven mode the same merge\n # changes ownership/readiness, so it is deferred to the joined main-task cadence instead. Needs the\n # reflect client + `track_hypotheses`.\n # Only active while `concurrent_research` runs the overlap loop. Library default off (== today).\n concurrent_consolidate: bool = True\n # Cadence for the agent-authored run report: regenerate the conclusion-first narrative every N\n # created nodes (0 = off; it still regenerates on a manual `report_refresh` from the UI). The\n # deterministic report always renders from the node set regardless of this knob.\n report_every: int = 3\n # Agent Skills (I18, ADR-9): dir of SKILL.md the Researcher can list/load as tools.\n skills_dir: str | None = None\n # Prompt store (I18, ADR-8): dir of editable, hot-reloaded role prompt .md files.\n prompt_dir: str | None = None\n # API key as a reference, never serialized as a value (ADR-11). Local servers\n # ignore it; default to a placeholder.\n llm_api_key: SecretStr | None = None\n\n @model_validator(mode=\"before\")\n @classmethod\n def _apply_profile(cls, data):\n \"\"\"Expand `profile` into a bundle of setting defaults BEFORE validation.\n\n pydantic-settings hands this validator the fully-merged input from ALL explicit sources\n (init kwargs + LOOPLAB_* env + .env), with fields the user left unset simply ABSENT (they\n fall to field defaults afterward). So a profile value is applied only when its key is not\n already present — i.e. any explicit file/CLI/env setting wins over the profile, and the\n profile wins over the bare field default. This makes `thorough` a true convenience preset,\n never an override of what the user asked for.\"\"\"\n if not isinstance(data, dict):\n return data\n prof = data.get(\"profile\", \"default\")\n # env may deliver the key as \"profile\" (env_prefix stripped) — normalize to a str\n prof = str(prof).strip().lower() if prof is not None else \"default\"\n overrides = PROFILES.get(prof)\n if overrides is None:\n raise ValueError(f\"unknown profile {prof!r}; known: {sorted(PROFILES)}\")\n for key, val in overrides.items():\n if key not in data: # explicit source (file/CLI/env) always wins\n data[key] = val\n return data\n\n @model_validator(mode=\"after\")\n def _check_trust_gate(self):\n if self.trust_gate not in (\"audit\", \"gate\", \"block\"):\n raise ValueError(\n f\"trust_gate must be audit|gate|block, got {self.trust_gate!r}\")\n if self.merge_mode not in (\"auto\", \"mean\", \"ensemble\"):\n raise ValueError(\n f\"merge_mode must be auto|mean|ensemble, got {self.merge_mode!r}\")\n # novelty_mode drives the dedup/novelty gate; an out-of-set value (e.g. a mis-cased\n # LOOPLAB_NOVELTY_MODE=LLM, or \"on\") used to fall through _apply_novelty_gate silently as a\n # NO-OP — turning the gate off with no diagnostic. Fail loudly at config time like trust_gate/\n # merge_mode do. (Other enum-ish string fields — seed_mode/eval_trust_mode/strategist_backend —\n # share the pattern; left unvalidated here only because their full value sets are less settled.)\n if self.novelty_mode not in (\"off\", \"algo\", \"llm\"):\n raise ValueError(\n f\"novelty_mode must be off|algo|llm, got {self.novelty_mode!r}\")\n # The remaining enum-ish string fields (arch-review §5 P3): a typo used to be accepted at\n # construction and only fail-safe/later-loud downstream (e.g. a mis-cased strategist_backend\n # silently ran the default). Fail loudly here like the fields above.\n if self.strategist_backend not in (\"off\", \"rule\", \"llm\", \"agent\"):\n raise ValueError(\n f\"strategist_backend must be off|rule|llm|agent, got {self.strategist_backend!r}\")\n if self.eval_trust_mode not in (\"ratify_freeze\", \"autonomous\", \"ratify_freeze_drift\"):\n raise ValueError(\n \"eval_trust_mode must be ratify_freeze|autonomous|ratify_freeze_drift, \"\n f\"got {self.eval_trust_mode!r}\")\n if self.seed_mode not in (\"auto\", \"tracked\", \"all\"):\n raise ValueError(f\"seed_mode must be auto|tracked|all, got {self.seed_mode!r}\")\n return self\n\n def masked_snapshot(self) -> dict:\n # Snapshots are a JSON contract (they are written verbatim to config.snapshot.json), not a\n # Python-object dump. In particular tuple-valued schema fields such as\n # ``inline_repair_reasons`` must become JSON arrays before callers hash the runtime envelope;\n # otherwise the strict speculation-scope canonicalizer sees a different/non-JSON preimage\n # from the one that ``json.dumps`` eventually persists.\n d = self.model_dump(mode=\"json\")\n if self.llm_api_key is not None:\n d[\"llm_api_key\"] = \"***\"\n else:\n d[\"llm_api_key\"] = None\n return d\n\n\n# Pre-versioned snapshots are full Settings dumps, so absence is a reliable per-field version marker.\n# Keep their historical effective behavior when newer product defaults become active: re-entry must not\n# silently add paid calls, interventions, concurrency or a different selection policy to an old run.\nLEGACY_CONFIG_SNAPSHOT_DEFAULTS: dict[str, object] = {\n \"parallel_build\": 1,\n \"eval_parallel\": None,\n \"llm_parallel\": None,\n \"train_monitor\": False,\n \"train_monitor_interval_s\": 600.0,\n \"train_monitor_kill\": False,\n \"train_monitor_kill_confidence\": 0.8,\n \"asha_live\": False,\n \"asha_live_kill\": False,\n \"asha_live_quantile\": 0.5,\n \"asha_live_min_siblings\": 3,\n # Before this operator ceiling existed, the sandbox's 24-hour process limit was authoritative.\n \"max_eval_timeout\": 24 * 3600.0,\n \"watchdog_reflection\": False,\n \"card_driven_selection\": False,\n \"speculation_depth\": 0,\n \"speculation_gate_receipt\": None,\n \"concurrent_research_repeat\": False,\n \"concurrent_research_interval_s\": 1800.0,\n # Zero means unlimited; 40 was the first bounded default, and is latent while repeat is disabled.\n \"concurrent_research_max_calls\": 40,\n \"concurrent_consolidate\": False,\n}\n\n\ndef migrate_config_snapshot(data: dict) -> dict:\n \"\"\"Return an effective copy of one legacy/current snapshot without rewriting evidence bytes.\"\"\"\n if not isinstance(data, dict):\n raise TypeError(\"config snapshot must be a JSON object\")\n migrated = dict(data)\n for key, value in LEGACY_CONFIG_SNAPSHOT_DEFAULTS.items():\n migrated.setdefault(key, value)\n return migrated\n\n\ndef settings_from_snapshot(data: dict) -> Settings:\n \"\"\"Build re-entry Settings from a masked snapshot under its historical missing-field contract.\"\"\"\n migrated = migrate_config_snapshot(data)\n migrated.pop(\"llm_api_key\", None)\n return Settings(**migrated)\n"}}}}