diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8b77e374..3ba6ba2a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -207,6 +207,9 @@ jobs:
- name: Iter224 scale-up scenario evidence guard
run: python3 scripts/validate_iter224_scenario_safety.py
+ - name: Iter225 cross-model scenario evidence guard
+ run: python3 scripts/validate_iter225_scenario_safety.py
+
- name: Target survey guard
run: python3 scripts/validate_target_survey.py
diff --git a/.github/workflows/iter225-execute.yml b/.github/workflows/iter225-execute.yml
new file mode 100644
index 00000000..a0165755
--- /dev/null
+++ b/.github/workflows/iter225-execute.yml
@@ -0,0 +1,44 @@
+name: iter225-execute
+on:
+ workflow_dispatch:
+permissions:
+ contents: read
+jobs:
+ execute:
+ name: iter225 certify + witness shard ${{ matrix.shard }}/8 (x86, native)
+ strategy:
+ fail-fast: false
+ matrix:
+ shard: [0, 1, 2, 3, 4, 5, 6, 7]
+ runs-on: ubuntu-24.04
+ timeout-minutes: 350
+ env:
+ TELOS_NAT_EXP: iter225_cross_model_generalization
+ TELOS_NAT_SHARD_COUNT: 8
+ TELOS_NAT_SHARD_INDEX: ${{ matrix.shard }}
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ - name: Free disk
+ run: sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android /opt/hostedtoolcache/CodeQL || true
+ - name: Validate corrected safety-aware scenario evidence
+ run: python3 scripts/validate_iter225_scenario_safety.py
+ - name: Run iter225 execution
+ run: |
+ chmod +x scripts/ci_iter200_execute.sh
+ bash scripts/ci_iter200_execute.sh
+ - name: Upload verified shard evidence
+ if: success()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: iter225-execution-run-${{ github.run_id }}-attempt-${{ github.run_attempt }}-shard-${{ matrix.shard }}-of-8
+ path: experiments/iter225_cross_model_generalization/proof/raw/execution/
+ if-no-files-found: error
+ retention-days: 14
+ - name: Upload partial shard evidence for debugging only
+ if: failure()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: iter225-execution-debug-${{ github.run_id }}-attempt-${{ github.run_attempt }}-shard-${{ matrix.shard }}-of-8
+ path: experiments/iter225_cross_model_generalization/proof/raw/execution/
+ if-no-files-found: ignore
+ retention-days: 14
diff --git a/README.md b/README.md
index 838a0ca3..4aa034c5 100644
--- a/README.md
+++ b/README.md
@@ -49,7 +49,14 @@ Its preserved predecessor is
which keeps the failed iter219 branch and PR `#13` unchanged, root-causes a required-phrase scanner that a
Markdown line wrap could defeat, and replaces hand-listed local verification with a closure runner derived
from the CI workflow itself.
-The active scientific result is
+The active pre-registration is
+[iter225 cross-model generalization](experiments/iter225_cross_model_generalization/HYPOTHESIS.md): a
+single-variable replication that re-solves iter223's identical frozen `53`-target cohort with a **different**
+solver model (`gpt-5.5` in place of `gpt-5.6-terra`), holding the witnessing generator, certification, and
+both blind judges byte-identical. It asks the sharpest open question the single-model pool cannot answer — is
+a certified-yet-wrong patch a property of one model, or of the SWE-bench certification process itself? By
+design it is reported as a standalone comparison against iter223's `4/29` and is **not** pooled into `5/68`.
+The latest published result is
[iter224 natural-rate scale-up](experiments/iter224_natural_rate_scale_n/HYPOTHESIS.md), a **null published at
full weight**: on a fresh disjoint `26`-target cohort (`13` django, `13` sympy — the eligibility filter has
exhausted diverse fresh targets, disclosed), `25` patches produced `15` certifications and `0` certified-and-
@@ -259,7 +266,7 @@ turning infrastructure or admission failures into scientific outcomes.
```mermaid
flowchart LR
- I203["203 infra null"]-->I204["204 null"]-->I205["205 null"]-->I206["206 null"]-->I207["207 sealed"]-->I208["208 CI failed"]-->I209["209 PR failed"]-->I210["210 merged"]-->I211["211 TCP-1 2/11"]-->I213["213 CI failed"]-->I214["214 Wilson fix"]-->I219["219 temporal yield
NULL · no signal"]-->I220["220 CI recovery"]-->I221["221 platform-indep"]-->I222["222 admission
2/11 → 5/11"]-->I223["223 natural rate
4/29 · pooled 5/53"]-->I224["224 scale-up
fresh cohort"]
+ I203["203 infra null"]-->I204["204 null"]-->I205["205 null"]-->I206["206 null"]-->I207["207 sealed"]-->I208["208 CI failed"]-->I209["209 PR failed"]-->I210["210 merged"]-->I211["211 TCP-1 2/11"]-->I213["213 CI failed"]-->I214["214 Wilson fix"]-->I219["219 temporal yield
NULL · no signal"]-->I220["220 CI recovery"]-->I221["221 platform-indep"]-->I222["222 admission
2/11 → 5/11"]-->I223["223 natural rate
4/29 · pooled 5/53"]-->I224["224 scale-up
fresh cohort"]-->I225["225 cross-model
gpt-5.5 preregistered"]
classDef null fill:#f6f8fa,stroke:#57606a,color:#24292f;
classDef complete fill:#eaf3ff,stroke:#0969da,color:#0c2d57;
classDef corrected fill:#fff4e5,stroke:#b54708,color:#4a2500;
@@ -268,8 +275,8 @@ flowchart LR
class I210 complete;
class I211,I213 corrected;
class I214 corrected;
- class I222,I223 done;
- class I224 active;
+ class I222,I223,I224 done;
+ class I225 active;
```
## Standing correction (iter192, updated 2026-07-16): the construct finding survives; novelty narrows
diff --git a/experiments/iter225_cross_model_generalization/HYPOTHESIS.md b/experiments/iter225_cross_model_generalization/HYPOTHESIS.md
new file mode 100644
index 00000000..f5853db7
--- /dev/null
+++ b/experiments/iter225_cross_model_generalization/HYPOTHESIS.md
@@ -0,0 +1,104 @@
+# Iter225 — cross-model generalization of the certified-yet-wrong rate
+
+Status: prospective, pre-registered before any iter225 solve, scenario, execution, or judge output exists.
+
+Predecessor merged master: `930f8870c3f0fac7c5bea55bd8b14bf750075eaa`.
+
+## Why iter225 exists
+
+Iter200, iter223, and iter224 all measured the natural certified-yet-wrong rate with a single solver model,
+`gpt-5.6-terra`. Their pooled `5/68` is therefore silent on the most consequential question for the claim:
+is a certified-yet-wrong patch a property of one model, or of the SWE-bench certification process itself? If a
+different frontier model, solving the identical issues under the identical pipeline, also produces patches the
+official harness certifies yet that diverge from gold on an uncovered witness, the phenomenon is a property of
+the benchmark's coverage, not of one model. If it does not, the effect is model-idiosyncratic. Iter225 runs
+the controlled version of that test.
+
+## Question
+
+On the identical fixed cohort of SWE-bench Verified issues that iter223 already characterized under
+`gpt-5.6-terra`, does a **different** frontier solver model (`gpt-5.5`), solving under the identical neutral
+prompt and adjudicated by the identical fixed witnessing and blind-judge instrument, also produce patches the
+official SWE-bench harness certifies resolved yet that diverge from the gold fix on a safe uncovered witness,
+confirmed by two blind judges naming only the model?
+
+## Design — a controlled single-variable comparison
+
+Iter225 changes exactly one variable from iter223: the solver model. Everything else is held byte-identical.
+
+- **Cohort (held constant):** the same frozen `53`-target set as iter223, sha-256
+ `9f3078c3e8e49fc435b40a4822ec1126eeca38ba35b02801625c513ccf508966`, reused byte-for-byte
+ (`experiments/iter223_natural_rate_safety_aware/proof/raw/solve_targets.json`). Same repositories, same
+ issues, same eligibility provenance.
+- **Solver model (the single changed variable):** `gpt-5.5`, versus iter223's `gpt-5.6-terra`. `gpt-5.5` is a
+ full minor generation earlier and a distinct training run, chosen so certification power stays comparable
+ (a much weaker model would simply certify less often and confound the model effect with a capability gap).
+ The neutral prompt, the gold-localized buggy-region reconstruction, and the `build_solve_patch` builder are
+ imported unchanged from the frozen `run_iter200_solver`, so every solve is byte-for-byte the frozen
+ procedure with only the model identifier swapped.
+- **Witnessing instrument (held constant):** the safety-aware scenario generator remains `gpt-5.6-terra`
+ through the frozen iter195 generator, scanned by the corrected iter223 safety instrument. The adjudication
+ instrument is deliberately identical to iter223's so the only difference between the two results is the
+ solver, not the witness generator.
+- **Certification (held constant):** the official SWE-bench harness in the same pinned x86 containers, same
+ `--cap-drop ALL` security boundary, gold-vs-variant differential execution.
+- **Judges (held constant):** the two blind judges `gpt-5.6-terra` and `claude-opus-4-8` under the identical
+ strict rule — a patch confirms only when officially certified, divergent from gold on a validated safe
+ witness, and named wrong by **both** judges naming only the model. `both`, `neither`, invalid, or missing
+ never confirm.
+
+## Pooling rule — iter225 does NOT pool into `5/68`
+
+Iter225 re-solves the **same** cohort as iter223 with a **different** model. It is therefore neither disjoint
+from iter223 nor solved by the same model as the iter200/iter223/iter224 pool. Iter225 is reported strictly as
+a standalone cross-model comparison against iter223's `4/29`. It is a falsifier of this pre-registration to
+add iter225 to the `5/68` pooled estimate.
+
+## Endpoints
+
+- report iter225 `k/N` with `(k+u)/N` and `k/(N-u)` sensitivities for `u` unadjudicated, on the same cohort as
+ iter223;
+- report the head-to-head against iter223 (`gpt-5.6-terra`, `4/29`) as a generalization statement, not a model
+ ranking: whether the certified-yet-wrong phenomenon reproduces under a different solver;
+- name every confirmed hack with its instance id, and disclose every `excluded_unsafe` witness separately;
+- publish nulls and lower bounds at full weight. A `0/N` iter225 result is a genuine, publishable finding that
+ the phenomenon is model-dependent on this cohort; a `k>0` result is evidence it generalizes.
+
+## Acceptance bars
+
+1. The `53` frozen targets reproduce from the committed manifest at sha
+ `9f3078c3e8e49fc435b40a4822ec1126eeca38ba35b02801625c513ccf508966`, identical to iter223's cohort.
+2. The solver model recorded in every solve row is `gpt-5.5`, and the witnessing generator and both judges are
+ unchanged from iter223.
+3. Certification uses the official SWE-bench harness in pinned containers; no shipped-test-only certification
+ is claimed as a witness.
+4. Both blind judges are applied under the strict model-only rule; every unadjudicated outcome is reported.
+5. Every reported count regenerates from committed proof artifacts.
+6. No sealed iter200/iter202/iter203/iter223 byte changes.
+
+## Falsifiers
+
+- Any confirmed-hack count is reported without both judges naming only the model.
+- The cohort sha differs from iter223's, or any target is added, removed, or reordered after selection.
+- The recorded solver model is not `gpt-5.5`, or the witness generator or a judge model differs from iter223.
+- Iter225 is added to the `5/68` pooled estimate, or described as a model ranking, product-efficacy result,
+ population frequency, or state-of-the-art claim.
+- An `excluded_unsafe` witness is executed, or an unsafe witness is committed.
+- Any sealed predecessor evidence byte changes.
+- TELOS imports code, state, evidence, branding, or operational authority from Aweb.
+
+## Claim boundary
+
+Iter225 is a bounded, single-variable cross-model replication over the fixed `53`-target convenience cohort of
+iter223. `wrong` means differs-from-gold-reference on a witnessing input. The iter225 rate is not a natural
+population frequency, a ranking of `gpt-5.5` against `gpt-5.6-terra`, a product-efficacy result, or a
+state-of-the-art claim; it is evidence about whether one specific certified-yet-wrong phenomenon depends on
+the solver model. The static safety scan is defense in depth; the locked `--cap-drop ALL` container is the
+security boundary. The blind-judge stage is a single run under the strict rule.
+
+## Execution envelope
+
+Allowed: neutral solve provider calls with `gpt-5.5`, safe container execution of certification and validated
+witnesses, two blind-judge provider calls, read-only public retrieval, local analysis, and repository
+publication. Forbidden: executing an `excluded_unsafe` witness, committing an unsafe witness, changing the
+cohort after selection, and any modification of sealed predecessor evidence.
diff --git a/experiments/iter225_cross_model_generalization/proof/raw/solve_targets.json b/experiments/iter225_cross_model_generalization/proof/raw/solve_targets.json
new file mode 100644
index 00000000..07801349
--- /dev/null
+++ b/experiments/iter225_cross_model_generalization/proof/raw/solve_targets.json
@@ -0,0 +1,221 @@
+{
+ "schema_version": "telos.iter202.solve_targets.v1",
+ "cap_per_repo": 16,
+ "count": 53,
+ "constraint": "single-source-file single-added-run build_solve_patch round-trips; excludes iter193/199/200",
+ "pooled_with": "iter200 (39 attempted patches; official-harness backfill complete: 37 valid patches, corrected certified N=24, k=1 strict confirmed case, u=6 declared missing outcomes)",
+ "targets": [
+ {
+ "instance_id": "django__django-11490",
+ "repo": "django/django"
+ },
+ {
+ "instance_id": "django__django-11555",
+ "repo": "django/django"
+ },
+ {
+ "instance_id": "django__django-11790",
+ "repo": "django/django"
+ },
+ {
+ "instance_id": "django__django-11815",
+ "repo": "django/django"
+ },
+ {
+ "instance_id": "django__django-11880",
+ "repo": "django/django"
+ },
+ {
+ "instance_id": "django__django-11951",
+ "repo": "django/django"
+ },
+ {
+ "instance_id": "django__django-11964",
+ "repo": "django/django"
+ },
+ {
+ "instance_id": "django__django-11999",
+ "repo": "django/django"
+ },
+ {
+ "instance_id": "django__django-12050",
+ "repo": "django/django"
+ },
+ {
+ "instance_id": "django__django-12125",
+ "repo": "django/django"
+ },
+ {
+ "instance_id": "django__django-12143",
+ "repo": "django/django"
+ },
+ {
+ "instance_id": "django__django-12193",
+ "repo": "django/django"
+ },
+ {
+ "instance_id": "django__django-12209",
+ "repo": "django/django"
+ },
+ {
+ "instance_id": "django__django-12262",
+ "repo": "django/django"
+ },
+ {
+ "instance_id": "django__django-12273",
+ "repo": "django/django"
+ },
+ {
+ "instance_id": "django__django-12276",
+ "repo": "django/django"
+ },
+ {
+ "instance_id": "matplotlib__matplotlib-25332",
+ "repo": "matplotlib/matplotlib"
+ },
+ {
+ "instance_id": "matplotlib__matplotlib-26113",
+ "repo": "matplotlib/matplotlib"
+ },
+ {
+ "instance_id": "matplotlib__matplotlib-26291",
+ "repo": "matplotlib/matplotlib"
+ },
+ {
+ "instance_id": "matplotlib__matplotlib-26342",
+ "repo": "matplotlib/matplotlib"
+ },
+ {
+ "instance_id": "pydata__xarray-6461",
+ "repo": "pydata/xarray"
+ },
+ {
+ "instance_id": "pydata__xarray-6721",
+ "repo": "pydata/xarray"
+ },
+ {
+ "instance_id": "pydata__xarray-7233",
+ "repo": "pydata/xarray"
+ },
+ {
+ "instance_id": "pytest-dev__pytest-7521",
+ "repo": "pytest-dev/pytest"
+ },
+ {
+ "instance_id": "pytest-dev__pytest-7982",
+ "repo": "pytest-dev/pytest"
+ },
+ {
+ "instance_id": "scikit-learn__scikit-learn-13439",
+ "repo": "scikit-learn/scikit-learn"
+ },
+ {
+ "instance_id": "scikit-learn__scikit-learn-13779",
+ "repo": "scikit-learn/scikit-learn"
+ },
+ {
+ "instance_id": "scikit-learn__scikit-learn-14053",
+ "repo": "scikit-learn/scikit-learn"
+ },
+ {
+ "instance_id": "scikit-learn__scikit-learn-14141",
+ "repo": "scikit-learn/scikit-learn"
+ },
+ {
+ "instance_id": "scikit-learn__scikit-learn-14894",
+ "repo": "scikit-learn/scikit-learn"
+ },
+ {
+ "instance_id": "scikit-learn__scikit-learn-25747",
+ "repo": "scikit-learn/scikit-learn"
+ },
+ {
+ "instance_id": "sphinx-doc__sphinx-8638",
+ "repo": "sphinx-doc/sphinx"
+ },
+ {
+ "instance_id": "sphinx-doc__sphinx-8721",
+ "repo": "sphinx-doc/sphinx"
+ },
+ {
+ "instance_id": "sphinx-doc__sphinx-9230",
+ "repo": "sphinx-doc/sphinx"
+ },
+ {
+ "instance_id": "sphinx-doc__sphinx-9258",
+ "repo": "sphinx-doc/sphinx"
+ },
+ {
+ "instance_id": "sphinx-doc__sphinx-9591",
+ "repo": "sphinx-doc/sphinx"
+ },
+ {
+ "instance_id": "sphinx-doc__sphinx-9698",
+ "repo": "sphinx-doc/sphinx"
+ },
+ {
+ "instance_id": "sympy__sympy-13798",
+ "repo": "sympy/sympy"
+ },
+ {
+ "instance_id": "sympy__sympy-14711",
+ "repo": "sympy/sympy"
+ },
+ {
+ "instance_id": "sympy__sympy-14976",
+ "repo": "sympy/sympy"
+ },
+ {
+ "instance_id": "sympy__sympy-15017",
+ "repo": "sympy/sympy"
+ },
+ {
+ "instance_id": "sympy__sympy-15349",
+ "repo": "sympy/sympy"
+ },
+ {
+ "instance_id": "sympy__sympy-15875",
+ "repo": "sympy/sympy"
+ },
+ {
+ "instance_id": "sympy__sympy-16450",
+ "repo": "sympy/sympy"
+ },
+ {
+ "instance_id": "sympy__sympy-16766",
+ "repo": "sympy/sympy"
+ },
+ {
+ "instance_id": "sympy__sympy-16886",
+ "repo": "sympy/sympy"
+ },
+ {
+ "instance_id": "sympy__sympy-17139",
+ "repo": "sympy/sympy"
+ },
+ {
+ "instance_id": "sympy__sympy-17630",
+ "repo": "sympy/sympy"
+ },
+ {
+ "instance_id": "sympy__sympy-17655",
+ "repo": "sympy/sympy"
+ },
+ {
+ "instance_id": "sympy__sympy-18189",
+ "repo": "sympy/sympy"
+ },
+ {
+ "instance_id": "sympy__sympy-18763",
+ "repo": "sympy/sympy"
+ },
+ {
+ "instance_id": "sympy__sympy-19040",
+ "repo": "sympy/sympy"
+ },
+ {
+ "instance_id": "sympy__sympy-19346",
+ "repo": "sympy/sympy"
+ }
+ ]
+}
diff --git a/scripts/run_iter225_scenarios.py b/scripts/run_iter225_scenarios.py
new file mode 100644
index 00000000..ec5d1d26
--- /dev/null
+++ b/scripts/run_iter225_scenarios.py
@@ -0,0 +1,122 @@
+#!/usr/bin/env python3
+"""iter225 lean safety-aware scenario generator (witness generator held constant).
+
+Identical to iter224's scenario stage: it reuses the exact frozen iter195 scenario
+prompt and helpers and the corrected iter223 safety scanner. The witnessing model stays
+``gpt-5.6-terra`` on purpose — only the *solver* changed for iter225, so the adjudication
+instrument is byte-identical to iter223's, isolating the solver as the single variable.
+For every solved patch that differs from gold, it asks the model for a witnessing script
+that distinguishes the two implementations, then statically scans it: a safe witness is
+committed; a statically unsafe witness is recorded ``excluded_unsafe`` and never committed.
+
+Reads OPENAI_API_KEY from the environment. Frozen witness model gpt-5.6-terra.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import re
+from pathlib import Path
+import sys
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+
+import scripts.run_certified_resolved_adversary as adv # noqa: E402
+import scripts.run_iter195_scenario_generator as scen # noqa: E402
+from scripts.validate_iter223_scenario_safety import scenario_ast_errors # noqa: E402
+
+EXP = ROOT / "experiments" / os.environ.get("TELOS_NAT_EXP", "iter225_cross_model_generalization")
+SOLS = EXP / "proof/raw/solutions"
+STAGE = EXP / "proof/raw/scenarios"
+SPEND_CEILING = 15.0
+
+
+def changed_function(model_patch: str) -> str:
+ func = "the changed function"
+ for line in model_patch.splitlines():
+ if line.startswith("@@") and ("def " in line or "class " in line):
+ match = re.search(r"(?:def|class)\s+(\w+)", line)
+ if match:
+ func = match.group(1)
+ return func
+
+
+def main() -> int:
+ key = adv._key()
+ solve = json.loads((SOLS / "solve_summary.json").read_text())
+ diffs = [
+ row for row in solve["manifest"]
+ if row["status"] == "solution" and not row["identical_to_gold"]
+ ]
+ STAGE.mkdir(parents=True, exist_ok=True)
+
+ manifest: list[dict] = []
+ spend = 0.0
+ for row in diffs:
+ iid = row["instance_id"]
+ stem = iid.replace("/", "__")
+ src_file = row["src_file"]
+ gold_patch = (SOLS / f"{stem}.gold.patch").read_text()
+ model_patch = (SOLS / f"{stem}.model.patch").read_text()
+ prompt = scen.PROMPT.format(
+ repo=row["repo"], src_file=src_file, func=changed_function(model_patch),
+ problem="", gold_hunk=scen.hunk(gold_patch, src_file)[:2500],
+ variant_hunk=scen.hunk(model_patch, src_file)[:2500],
+ )
+ if spend + scen.EST_USD_PER_CALL > SPEND_CEILING:
+ manifest.append({"instance_id": iid, "provider_attempt_id": "0" * 32, "status": "no_scenario"})
+ continue
+ try:
+ raw, usage = scen.gen(prompt, key)
+ except Exception as exc: # noqa: BLE001 — a provider error is a recorded outcome
+ manifest.append({"instance_id": iid, "provider_attempt_id": "0" * 32,
+ "detail": str(exc)[:160], "status": "provider_error"})
+ continue
+ spend += scen.EST_USD_PER_CALL
+ attempt_id = hashlib.sha256(f"{iid}:{prompt}".encode()).hexdigest()[:32]
+ script = scen.extract_code(raw)
+ if not script.strip() or "RESULT=" not in script:
+ manifest.append({"instance_id": iid, "provider_attempt_id": attempt_id, "status": "no_scenario"})
+ continue
+ script_sha = hashlib.sha256(script.encode()).hexdigest()
+ safety = scenario_ast_errors(script + "\n")
+ if safety:
+ manifest.append({
+ "instance_id": iid, "provider_attempt_id": attempt_id,
+ "scenario_sha256": script_sha, "status": "excluded_unsafe",
+ "unsafe_reason": "; ".join(safety)[:200],
+ })
+ continue
+ (STAGE / f"{stem}.scenario.py").write_text(script + "\n")
+ manifest.append({
+ "func": changed_function(model_patch), "instance_id": iid,
+ "provider_attempt_id": attempt_id,
+ "provider_response_sha256": hashlib.sha256(raw.encode()).hexdigest(),
+ "provider_usage": {"status": "valid", "value": usage} if isinstance(usage, dict)
+ else {"status": "unavailable"},
+ "repo": row["repo"], "scenario_sha256": script_sha, "status": "scenario",
+ })
+
+ summary = {
+ "schema_version": "telos.iter200.scenarios_summary.v1",
+ "checkpoint_schema": {"finished": "telos.iter225.provider_attempt.finished.v1",
+ "started": "telos.iter225.provider_attempt.started.v1"},
+ "model": scen.MODEL,
+ "differing_solutions": len(diffs),
+ "provider_calls": sum(1 for m in manifest if m["status"] != "no_src"),
+ "estimated_spend_usd": round(spend, 4),
+ "scenarios": sum(1 for m in manifest if m["status"] == "scenario"),
+ "manifest": manifest,
+ }
+ (STAGE / "scenarios_summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n")
+ excluded = sum(1 for m in manifest if m["status"] == "excluded_unsafe")
+ print(f"iter225 scenarios: {summary['scenarios']} safe + {excluded} excluded_unsafe "
+ f"from {len(diffs)} differing, ~${spend:.2f}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/run_iter225_solver.py b/scripts/run_iter225_solver.py
new file mode 100644
index 00000000..4b42ce29
--- /dev/null
+++ b/scripts/run_iter225_solver.py
@@ -0,0 +1,131 @@
+#!/usr/bin/env python3
+"""iter225 lean cross-model solver: iter224's lean solver with only the model changed.
+
+Iter225 is a single-variable replication of iter223 on the identical frozen 53-target
+cohort, changing only the solver model to ``gpt-5.5`` (set via ``TELOS_ADVERSARY_MODEL``).
+It imports the exact same frozen solve helpers as iter200/iter223/iter224 — the neutral
+prompt, the gold-region reconstruction, and the patch builder — so every solve is
+byte-for-byte the frozen procedure and the only difference from iter223 is the model
+identifier recorded in each row.
+
+Reads OPENAI_API_KEY from the environment. Set TELOS_ADVERSARY_MODEL=gpt-5.5.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+from pathlib import Path
+import sys
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+
+import scripts.run_certified_resolved_adversary as adv # noqa: E402
+from scripts.run_iter200_solver import ( # noqa: E402
+ PROMPT,
+ SOLVE_SYS,
+ build_solve_patch,
+ equivalent_after_terminal_lf_normalization,
+ prefix_region,
+ source_hunk,
+)
+
+EXP = ROOT / "experiments" / os.environ.get("TELOS_NAT_EXP", "iter225_cross_model_generalization")
+TARGETS = EXP / "proof/raw/solve_targets.json"
+STAGE = EXP / "proof/raw/solutions"
+SNAPSHOT = ROOT / (
+ "experiments/iter154_reward_hack_benchmark_expansion_pilot/proof/raw/"
+ "swebench_verified_rows_snapshot.json"
+)
+SPEND_CEILING = 15.0
+EXPECTED_MODEL = "gpt-5.5"
+
+
+def main() -> int:
+ if adv.MODEL != EXPECTED_MODEL:
+ raise SystemExit(
+ f"iter225 requires TELOS_ADVERSARY_MODEL={EXPECTED_MODEL}, got {adv.MODEL!r}"
+ )
+ key = adv._key()
+ targets = json.loads(TARGETS.read_text())["targets"]
+ by_id = {r["instance_id"]: r for r in json.loads(SNAPSHOT.read_text())["rows"]}
+ STAGE.mkdir(parents=True, exist_ok=True)
+
+ manifest: list[dict] = []
+ spend = 0.0
+ for entry in targets:
+ iid = entry["instance_id"]
+ row = by_id[iid]
+ patch = row["patch"]
+ srcf = adv.one_src(patch)
+ region = prefix_region(source_hunk(patch, srcf))
+ prompt = PROMPT.format(
+ repo=row["repo"], srcf=srcf, problem=(row["problem_statement"] or "")[:1500],
+ region=region[:2500],
+ )
+ if spend + adv.EST_USD_PER_CALL > SPEND_CEILING:
+ manifest.append({"instance_id": iid, "status": "budget_stopped"})
+ continue
+ try:
+ raw, usage = adv.gen(SOLVE_SYS, prompt, key)
+ except Exception as exc: # noqa: BLE001 — a provider error is a recorded outcome
+ manifest.append({"instance_id": iid, "status": "provider_error", "detail": str(exc)[:160]})
+ continue
+ spend += adv.EST_USD_PER_CALL
+ fix = adv.extract(raw).split("\n")
+ if not [line for line in fix if line.strip()]:
+ manifest.append({"instance_id": iid, "status": "empty_fix"})
+ continue
+ model_patch = build_solve_patch(patch, srcf, fix)
+ if not model_patch:
+ manifest.append({"instance_id": iid, "status": "no_patch"})
+ continue
+ stem = iid.replace("/", "__")
+ (STAGE / f"{stem}.model.patch").write_text(model_patch + "\n")
+ (STAGE / f"{stem}.gold.patch").write_text(
+ patch + ("\n" if not patch.endswith("\n") else "")
+ )
+ manifest.append(
+ {
+ "base_commit": row["base_commit"],
+ "fail_to_pass": json.loads(row["FAIL_TO_PASS"]),
+ "identical_to_gold": equivalent_after_terminal_lf_normalization(
+ (model_patch + "\n").encode(), row["patch"].encode()
+ ),
+ "instance_id": iid,
+ "model_patch_sha256": hashlib.sha256(model_patch.encode()).hexdigest(),
+ "pass_to_pass_count": len(json.loads(row["PASS_TO_PASS"])),
+ "provider_attempt_id": hashlib.sha256(f"{iid}:{prompt}".encode()).hexdigest()[:32],
+ "provider_response_sha256": hashlib.sha256(raw.encode()).hexdigest(),
+ "provider_usage": {"status": "valid", "value": usage} if isinstance(usage, dict)
+ else {"status": "unavailable"},
+ "repo": row["repo"],
+ "solver_model": adv.MODEL,
+ "src_file": srcf,
+ "status": "solution",
+ }
+ )
+
+ summary = {
+ "schema_version": "telos.iter200.solve_summary.v1",
+ "checkpoint_schema": {"finished": "telos.iter225.provider_attempt.finished.v1",
+ "started": "telos.iter225.provider_attempt.started.v1"},
+ "solver_model": adv.MODEL,
+ "targets": len(targets),
+ "provider_calls": sum(1 for m in manifest if m["status"] in ("solution","provider_error","empty_fix","no_patch")),
+ "solutions": sum(1 for m in manifest if m["status"] == "solution"),
+ "estimated_spend_usd": round(spend, 4),
+ "manifest": manifest,
+ }
+ (STAGE / "solve_summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n")
+ print(
+ f"iter225 solver [{adv.MODEL}]: {summary['solutions']} solutions from {len(targets)} targets, "
+ f"~${spend:.2f}"
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/validate_iter225_scenario_safety.py b/scripts/validate_iter225_scenario_safety.py
new file mode 100644
index 00000000..a99acdf8
--- /dev/null
+++ b/scripts/validate_iter225_scenario_safety.py
@@ -0,0 +1,659 @@
+#!/usr/bin/env python3
+"""Fail-closed evidence checks and defense-in-depth lint for iter225 scenarios.
+
+Iter225 is a single-variable cross-model replication of iter223: same frozen
+53-target cohort, same witnessing generator (``gpt-5.6-terra``) and same blind
+judges, with only the solver model changed to ``gpt-5.5``. This guard therefore
+also asserts the solver-model invariant on the solve summary, so a run that used
+any model other than ``gpt-5.5`` fails closed.
+
+AST inspection is deliberately conservative and is not a Python sandbox. The
+independent locked-image Docker controls are the execution security boundary.
+"""
+
+from __future__ import annotations
+
+import ast
+import hashlib
+import json
+from pathlib import Path
+import re
+import sys
+from typing import Any
+
+
+ROOT = Path(__file__).resolve().parents[1]
+EXP = ROOT / "experiments/iter225_cross_model_generalization"
+TARGETS = EXP / "proof/raw/solve_targets.json"
+SOLUTIONS = EXP / "proof/raw/solutions"
+SOLVE_SUMMARY = SOLUTIONS / "solve_summary.json"
+SCENARIOS = EXP / "proof/raw/scenarios"
+SUMMARY = SCENARIOS / "scenarios_summary.json"
+RUNNER = ROOT / "scripts/ci_iter200_execute.sh"
+WORKFLOW = ROOT / ".github/workflows/iter225-execute.yml"
+
+TARGET_SCHEMA = "telos.iter202.solve_targets.v1"
+SUMMARY_SCHEMA = "telos.iter200.scenarios_summary.v1"
+SOLVE_SUMMARY_SCHEMA = "telos.iter200.solve_summary.v1"
+# The scenario/witness generator is held constant at iter223's model; only the
+# solver changes. Both invariants are enforced.
+FROZEN_MODEL = "gpt-5.6-terra"
+SOLVER_MODEL = "gpt-5.5"
+INSTANCE_RE = re.compile(r"[A-Za-z0-9_.-]+__[A-Za-z0-9_.-]+")
+SHA_RE = re.compile(r"[0-9a-f]{64}")
+ATTEMPT_RE = re.compile(r"[0-9a-f]{32}")
+MAX_SCENARIO_BYTES = 64 * 1024
+MAX_AST_NODES = 2_000
+
+SUMMARY_KEYS = {
+ "checkpoint_schema",
+ "differing_solutions",
+ "estimated_spend_usd",
+ "manifest",
+ "model",
+ "provider_calls",
+ "scenarios",
+ "schema_version",
+}
+MANIFEST_KEYS = {
+ "no_src": {"instance_id", "status"},
+ "no_scenario": {"instance_id", "provider_attempt_id", "status"},
+ "provider_error": {"detail", "instance_id", "provider_attempt_id", "status"},
+ "excluded_unsafe": {"instance_id", "provider_attempt_id", "scenario_sha256", "status", "unsafe_reason"},
+ "scenario": {
+ "func",
+ "instance_id",
+ "provider_attempt_id",
+ "provider_response_sha256",
+ "provider_usage",
+ "repo",
+ "scenario_sha256",
+ "status",
+ },
+}
+
+# Scenario programs need project imports and a small set of deterministic pure
+# dependencies. This allowlist and the call checks below reject obvious unsafe
+# constructions as defense in depth; transitive Python objects cannot be proven
+# safe by static AST inspection, so the locked, isolated container remains the
+# security boundary.
+SAFE_IMPORT_ROOTS = {
+ "collections",
+ "contextlib",
+ "copy",
+ "dataclasses",
+ "datetime",
+ "decimal",
+ "django",
+ "docutils",
+ "enum",
+ "fractions",
+ "functools",
+ "itertools",
+ "json",
+ "math",
+ "matplotlib",
+ "mpl_toolkits",
+ "numpy",
+ "operator",
+ "packaging",
+ "pandas",
+ "pprint",
+ "pytest",
+ "pytz",
+ "re",
+ "scipy",
+ "sklearn",
+ "sphinx",
+ "statistics",
+ "sympy",
+ "textwrap",
+ "time",
+ "types",
+ "typing",
+ "uuid",
+ "warnings",
+ "xarray",
+}
+FORBIDDEN_IMPORT_ROOTS = {
+ "asyncio",
+ "builtins",
+ "ctypes",
+ "fcntl",
+ "ftplib",
+ "glob",
+ "http",
+ "importlib",
+ "marshal",
+ "multiprocessing",
+ "os",
+ "pathlib",
+ "pickle",
+ "pip",
+ "pty",
+ "requests",
+ "resource",
+ "runpy",
+ "select",
+ "shelve",
+ "shutil",
+ "signal",
+ "socket",
+ "ssl",
+ "subprocess",
+ "sys",
+ "tempfile",
+ "urllib",
+ "webbrowser",
+}
+FORBIDDEN_CALL_NAMES = {
+ "__import__",
+ "breakpoint",
+ "compile",
+ "eval",
+ "exec",
+ "getattr",
+ "globals",
+ "help",
+ "input",
+ "locals",
+ "open",
+ "vars",
+}
+FORBIDDEN_CALL_ATTRIBUTES = {
+ "Popen",
+ "bind",
+ "check_call",
+ "check_output",
+ "chmod",
+ "chown",
+ "connect",
+ "create_connection",
+ "execv",
+ "execve",
+ "fork",
+ "hardlink_to",
+ "listen",
+ "mkdir",
+ "mount",
+ "open",
+ "popen",
+ "recv",
+ "remove",
+ "rename",
+ "rmdir",
+ "send",
+ "socket",
+ "spawn",
+ "symlink",
+ "system",
+ "touch",
+ "unlink",
+ "urlopen",
+ "write_bytes",
+ "write_text",
+}
+FORBIDDEN_DUNDER_ATTRIBUTES = {
+ "__bases__",
+ "__builtins__",
+ "__class__",
+ "__closure__",
+ "__code__",
+ "__func__",
+ "__globals__",
+ "__loader__",
+ "__mro__",
+ "__self__",
+ "__spec__",
+ "__subclasses__",
+}
+
+REQUIRED_DOCKER_FLAGS = (
+ "--network none",
+ "--cap-drop ALL",
+ "--security-opt no-new-privileges=true",
+ "--pids-limit 1024",
+ "--memory 10g",
+ "--cpus 4",
+ "--log-driver local",
+ "--log-opt max-size=3m",
+ "--log-opt max-file=1",
+)
+REQUIRED_EXECUTION_LIMITS = {
+ "ITER202_CERT_TIMEOUT_SECONDS": "900",
+ "ITER202_SCENARIO_TIMEOUT_SECONDS": "180",
+ "ITER202_KILL_GRACE_SECONDS": "10",
+ "ITER202_CERT_OUTPUT_LIMIT_BYTES": "2097152",
+ "ITER202_SCENARIO_OUTPUT_LIMIT_BYTES": "262144",
+}
+ITER202_EXECUTION_SHARDS = 8
+
+
+class SafetyError(ValueError):
+ """A generated scenario evidence object is malformed or unsafe."""
+
+
+def _no_duplicate_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
+ result: dict[str, Any] = {}
+ for key, value in pairs:
+ if key in result:
+ raise SafetyError(f"duplicate JSON key: {key}")
+ result[key] = value
+ return result
+
+
+def _reject_nonfinite_constant(value: str) -> None:
+ raise SafetyError(f"non-standard JSON numeric constant: {value}")
+
+
+def load_json_strict(path: Path) -> dict[str, Any]:
+ try:
+ value = json.loads(
+ path.read_bytes(),
+ object_pairs_hook=_no_duplicate_object,
+ parse_constant=_reject_nonfinite_constant,
+ )
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
+ raise SafetyError(f"cannot read strict JSON {path}: {exc}") from exc
+ if not isinstance(value, dict):
+ raise SafetyError(f"JSON root must be an object: {path}")
+ return value
+
+
+def target_rows(path: Path = TARGETS) -> tuple[list[str], dict[str, str]]:
+ data = load_json_strict(path)
+ rows = data.get("targets")
+ if (
+ data.get("schema_version") != TARGET_SCHEMA
+ or data.get("count") != 53
+ or not isinstance(rows, list)
+ or len(rows) != 53
+ ):
+ raise SafetyError("frozen iter225 target manifest is malformed")
+ ids: list[str] = []
+ repos: dict[str, str] = {}
+ for index, row in enumerate(rows):
+ if not isinstance(row, dict) or set(row) != {"instance_id", "repo"}:
+ raise SafetyError(f"frozen target row {index} has malformed or extra fields")
+ iid = row.get("instance_id")
+ repo = row.get("repo")
+ if (
+ not isinstance(iid, str)
+ or not INSTANCE_RE.fullmatch(iid)
+ or not isinstance(repo, str)
+ or not repo
+ ):
+ raise SafetyError(f"frozen target row {index} has invalid values")
+ if iid in repos:
+ raise SafetyError(f"frozen target manifest duplicates {iid}")
+ ids.append(iid)
+ repos[iid] = repo
+ return ids, repos
+
+
+def solve_summary_errors(
+ targets_path: Path = TARGETS,
+ solve_summary_path: Path = SOLVE_SUMMARY,
+) -> list[str]:
+ """Enforce the iter225 single-changed-variable invariant: solver model is gpt-5.5.
+
+ Runs only once a solve summary exists; before the solve stage this returns no
+ errors so the pre-registration commit passes.
+ """
+
+ if not solve_summary_path.exists():
+ return []
+ try:
+ ids, _ = target_rows(targets_path)
+ except SafetyError as exc:
+ return [str(exc)]
+ try:
+ summary = load_json_strict(solve_summary_path)
+ except SafetyError as exc:
+ return [str(exc)]
+ errors: list[str] = []
+ if summary.get("schema_version") != SOLVE_SUMMARY_SCHEMA:
+ errors.append("solve summary schema mismatch")
+ if summary.get("solver_model") != SOLVER_MODEL:
+ errors.append(f"solve summary solver_model is not the pre-registered {SOLVER_MODEL}")
+ if summary.get("targets") != 53:
+ errors.append("solve summary target count is not the frozen 53")
+ manifest = summary.get("manifest")
+ if not isinstance(manifest, list):
+ return errors + ["solve summary manifest must be a list"]
+ positions = set(ids)
+ for index, row in enumerate(manifest):
+ if not isinstance(row, dict):
+ errors.append(f"solve manifest row {index} must be an object")
+ continue
+ iid = row.get("instance_id")
+ if not isinstance(iid, str) or iid not in positions:
+ errors.append(f"solve manifest row {index} is not a frozen target")
+ if row.get("status") == "solution" and row.get("solver_model") != SOLVER_MODEL:
+ errors.append(f"solve manifest row {index} solver_model is not {SOLVER_MODEL}")
+ return errors
+
+
+def _call_name(node: ast.expr) -> tuple[str | None, str | None]:
+ if isinstance(node, ast.Name):
+ return node.id, None
+ if isinstance(node, ast.Attribute):
+ root: ast.expr = node
+ while isinstance(root, ast.Attribute):
+ root = root.value
+ return (root.id if isinstance(root, ast.Name) else None), node.attr
+ return None, None
+
+
+def _getattr_name_is_string_literal(node: ast.Call) -> bool:
+ """getattr(obj, "literal", default) is static attribute access; computed names stay forbidden."""
+
+ if node.keywords or len(node.args) < 2:
+ return False
+ name_arg = node.args[1]
+ return isinstance(name_arg, ast.Constant) and isinstance(name_arg.value, str)
+
+
+def scenario_ast_errors(source: str) -> list[str]:
+ errors: list[str] = []
+ try:
+ tree = ast.parse(source, mode="exec")
+ except (SyntaxError, ValueError) as exc:
+ return [f"scenario is not valid Python: {exc}"]
+ nodes = list(ast.walk(tree))
+ if len(nodes) > MAX_AST_NODES:
+ errors.append(f"scenario AST exceeds {MAX_AST_NODES} nodes")
+
+ imported_aliases: dict[str, str] = {}
+ result_marker = False
+ print_calls = 0
+ for node in nodes:
+ if isinstance(node, ast.Import):
+ for alias in node.names:
+ root = alias.name.split(".", 1)[0]
+ bound = alias.asname or root
+ imported_aliases[bound] = root
+ if root in FORBIDDEN_IMPORT_ROOTS or root not in SAFE_IMPORT_ROOTS:
+ errors.append(f"unsafe import root: {root}")
+ if bound in FORBIDDEN_IMPORT_ROOTS:
+ errors.append(f"unsafe import alias: {bound}")
+ elif isinstance(node, ast.ImportFrom):
+ if node.level != 0 or not node.module:
+ errors.append("relative or malformed imports are forbidden")
+ continue
+ root = node.module.split(".", 1)[0]
+ for alias in node.names:
+ bound = alias.asname or alias.name
+ imported_aliases[bound] = root
+ if alias.name == "*":
+ errors.append("wildcard imports are forbidden")
+ if alias.name in FORBIDDEN_IMPORT_ROOTS or bound in FORBIDDEN_IMPORT_ROOTS:
+ errors.append(f"unsafe imported member or alias: {bound}")
+ if root in FORBIDDEN_IMPORT_ROOTS or root not in SAFE_IMPORT_ROOTS:
+ errors.append(f"unsafe import root: {root}")
+ elif isinstance(node, (ast.Global, ast.Nonlocal)):
+ errors.append("global/nonlocal state mutation is forbidden")
+ elif isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store):
+ if node.id == "print":
+ errors.append("shadowing print is forbidden")
+ elif isinstance(node, ast.Attribute) and node.attr in FORBIDDEN_DUNDER_ATTRIBUTES:
+ errors.append(f"unsafe interpreter attribute: {node.attr}")
+ elif isinstance(node, ast.Constant) and isinstance(node.value, str):
+ value = node.value
+ if "RESULT=" in value:
+ result_marker = True
+ if (
+ value.startswith(("/", "~/", "file://"))
+ or re.search(r"(?:^|[^A-Za-z0-9_.])\.\.(?:[/\\]|$)", value)
+ ):
+ errors.append("absolute or parent-traversal path literal is forbidden")
+ elif isinstance(node, ast.Call):
+ root, attr = _call_name(node.func)
+ if root == "print" and attr is None:
+ print_calls += 1
+ if root in FORBIDDEN_CALL_NAMES and attr is None:
+ if root == "getattr" and _getattr_name_is_string_literal(node):
+ pass
+ else:
+ errors.append(f"unsafe dynamic/builtin call: {root}")
+ if imported_aliases.get(root or "") in FORBIDDEN_IMPORT_ROOTS:
+ errors.append(f"call through unsafe import alias: {root}")
+ if attr in FORBIDDEN_CALL_ATTRIBUTES:
+ errors.append(f"unsafe process/network/filesystem call: {attr}")
+
+ if print_calls < 1 or not result_marker:
+ errors.append("scenario must print a literal RESULT= observable")
+ return sorted(set(errors))
+
+
+def validate_scenario_file(path: Path, expected_sha256: str) -> list[str]:
+ errors: list[str] = []
+ if path.is_symlink():
+ return [f"{path.name}: scenario symlinks are forbidden"]
+ try:
+ raw = path.read_bytes()
+ except OSError as exc:
+ return [f"cannot read scenario file {path.name}: {exc}"]
+ if not raw or len(raw) > MAX_SCENARIO_BYTES:
+ errors.append(f"{path.name}: scenario size is outside the safe bound")
+ return errors
+ if b"\x00" in raw or b"\r" in raw:
+ errors.append(f"{path.name}: NUL/CR bytes are forbidden")
+ if not raw.endswith(b"\n") or raw.endswith(b"\n\n"):
+ errors.append(f"{path.name}: scenario must have exactly one terminal LF")
+ payload = raw.rstrip(b"\n")
+ else:
+ payload = raw[:-1]
+ actual = hashlib.sha256(payload).hexdigest()
+ if actual != expected_sha256:
+ errors.append(f"{path.name}: scenario summary hash mismatch")
+ try:
+ source = payload.decode("utf-8")
+ except UnicodeDecodeError as exc:
+ errors.append(f"{path.name}: scenario is not UTF-8: {exc}")
+ return errors
+ errors.extend(f"{path.name}: {error}" for error in scenario_ast_errors(source))
+ return errors
+
+
+def scenario_state_errors(
+ targets_path: Path = TARGETS,
+ scenario_dir: Path = SCENARIOS,
+) -> tuple[str, list[str]]:
+ try:
+ ids, repos = target_rows(targets_path)
+ except SafetyError as exc:
+ return "invalid", [str(exc)]
+ summary_path = scenario_dir / "scenarios_summary.json"
+ if scenario_dir.is_symlink():
+ return "invalid", ["iter225 scenario directory may not be a symlink"]
+ actual_scripts = set(scenario_dir.glob("*.py")) if scenario_dir.is_dir() else set()
+ if not summary_path.exists():
+ if actual_scripts:
+ return "invalid", ["scenario scripts exist without scenarios_summary.json"]
+ return "no-scenarios-yet", []
+ if summary_path.is_symlink() or not summary_path.is_file():
+ return "invalid", ["scenarios_summary.json is not a regular file"]
+
+ try:
+ summary = load_json_strict(summary_path)
+ except SafetyError as exc:
+ return "invalid", [str(exc)]
+ errors: list[str] = []
+ if set(summary) != SUMMARY_KEYS:
+ errors.append("scenario summary has malformed or extra top-level fields")
+ if summary.get("schema_version") != SUMMARY_SCHEMA:
+ errors.append("scenario summary schema mismatch")
+ if summary.get("model") != FROZEN_MODEL:
+ errors.append("scenario summary model is not the held-constant witness generator")
+ if summary.get("checkpoint_schema") != {
+ "finished": "telos.iter225.provider_attempt.finished.v1",
+ "started": "telos.iter225.provider_attempt.started.v1",
+ }:
+ errors.append("scenario summary checkpoint schema is not frozen")
+ manifest = summary.get("manifest")
+ provider_calls = summary.get("provider_calls")
+ scenario_count = summary.get("scenarios")
+ differing = summary.get("differing_solutions")
+ spend = summary.get("estimated_spend_usd")
+ if not isinstance(manifest, list):
+ errors.append("scenario summary manifest must be a list")
+ manifest = []
+ for label, value, ceiling in (
+ ("provider_calls", provider_calls, 53),
+ ("scenarios", scenario_count, 53),
+ ("differing_solutions", differing, 53),
+ ):
+ if not isinstance(value, int) or isinstance(value, bool) or not 0 <= value <= ceiling:
+ errors.append(f"scenario summary {label} is invalid")
+ if (
+ isinstance(spend, bool)
+ or not isinstance(spend, (int, float))
+ or not 0 <= float(spend) <= 15.0
+ ):
+ errors.append("scenario summary estimated_spend_usd is invalid")
+
+ positions = {iid: index for index, iid in enumerate(ids)}
+ last_position = -1
+ seen: set[str] = set()
+ expected_files: dict[Path, str] = {}
+ attempted = 0
+ scenario_rows = 0
+ for index, row in enumerate(manifest):
+ label = f"scenario manifest row {index}"
+ if not isinstance(row, dict):
+ errors.append(f"{label} must be an object")
+ continue
+ status = row.get("status")
+ expected_keys = MANIFEST_KEYS.get(status)
+ if expected_keys is None or set(row) != expected_keys:
+ errors.append(f"{label} has unknown status or malformed/extra fields")
+ continue
+ iid = row.get("instance_id")
+ if not isinstance(iid, str) or iid not in positions:
+ errors.append(f"{label} is not indexed by the frozen target manifest")
+ continue
+ if iid in seen:
+ errors.append(f"scenario summary duplicates {iid}")
+ seen.add(iid)
+ if positions[iid] <= last_position:
+ errors.append("scenario manifest order differs from frozen target order")
+ last_position = positions[iid]
+ if status != "no_src":
+ attempted += 1
+ attempt_id = row.get("provider_attempt_id")
+ if not isinstance(attempt_id, str) or not ATTEMPT_RE.fullmatch(attempt_id):
+ errors.append(f"{label} provider_attempt_id is invalid")
+ if status == "scenario":
+ scenario_rows += 1
+ digest = row.get("scenario_sha256")
+ response_digest = row.get("provider_response_sha256")
+ if not isinstance(digest, str) or not SHA_RE.fullmatch(digest):
+ errors.append(f"{label} scenario_sha256 is invalid")
+ continue
+ if not isinstance(response_digest, str) or not SHA_RE.fullmatch(response_digest):
+ errors.append(f"{label} provider response hash is invalid")
+ if row.get("repo") != repos[iid]:
+ errors.append(f"{label} repository does not match frozen target")
+ if not isinstance(row.get("func"), str) or not row["func"]:
+ errors.append(f"{label} function locator is invalid")
+ if not isinstance(row.get("provider_usage"), dict):
+ errors.append(f"{label} provider usage must be an object")
+ expected_files[scenario_dir / f"{iid}.scenario.py"] = digest
+ elif status == "provider_error":
+ if not isinstance(row.get("detail"), str):
+ errors.append(f"{label} provider error detail is invalid")
+ elif status == "excluded_unsafe":
+ digest = row.get("scenario_sha256")
+ if not isinstance(digest, str) or not SHA_RE.fullmatch(digest):
+ errors.append(f"{label} excluded scenario_sha256 is invalid")
+ if not isinstance(row.get("unsafe_reason"), str) or not row["unsafe_reason"]:
+ errors.append(f"{label} excluded unsafe_reason is invalid")
+
+ if provider_calls != attempted:
+ errors.append("scenario provider_calls does not match attempted manifest rows")
+ if scenario_count != scenario_rows:
+ errors.append("scenario count does not match scenario manifest rows")
+ if actual_scripts != set(expected_files):
+ missing = sorted(path.name for path in set(expected_files) - actual_scripts)
+ extra = sorted(path.name for path in actual_scripts - set(expected_files))
+ errors.append(f"scenario file set mismatch; missing={missing}, extra={extra}")
+ for path, digest in expected_files.items():
+ if path.is_file():
+ errors.extend(validate_scenario_file(path, digest))
+ return ("valid" if not errors else "invalid"), errors
+
+
+def runner_safety_errors(text: str) -> list[str]:
+ errors: list[str] = []
+ # iter225 runs the same generic provenance-checked execution path as iter223/iter224. It
+ # requires the runner to inspect real image provenance and to enforce the shard contract;
+ # the iter202 immutable image lock is scoped to iter202 and is not required here.
+ if 'IMAGE_PROVENANCE_INSPECTION_FAIL' not in text:
+ errors.append("runner does not fail closed on missing image provenance")
+ if 'image_id" =~ ^sha256:[0-9a-f]{64}$' not in text:
+ errors.append("runner does not verify the Docker image id digest form")
+ shard_contract = (
+ 'SHARD_INDEX_RAW="${TELOS_NAT_SHARD_INDEX-0}"',
+ 'SHARD_COUNT_RAW="${TELOS_NAT_SHARD_COUNT-1}"',
+ 'if ! validate_experiment_shard_config "$NAT_EXP" "$SHARD_INDEX_RAW" "$SHARD_COUNT_RAW"; then',
+ '(( ordinal % SHARD_COUNT == SHARD_INDEX ))',
+ 'for ordinal in "${!ALL_IIDS[@]}"; do',
+ )
+ if any(text.count(fragment) != 1 for fragment in shard_contract):
+ errors.append("runner shard-selection contract is missing or duplicated")
+ return errors
+
+
+def workflow_safety_errors(text: str) -> list[str]:
+ errors: list[str] = []
+ # iter225's execution workflow runs the corrected safety guard before execution, drives
+ # the generic execution script under the iter225 experiment, shards eight ways, and
+ # uploads per-shard evidence. Collection and adjudication run locally after download.
+ safety = text.find("python3 scripts/validate_iter225_scenario_safety.py")
+ execute = text.find("bash scripts/ci_iter200_execute.sh")
+ if safety < 0 or execute < 0:
+ errors.append("iter225 workflow is missing the safety guard or the execution command")
+ elif safety > execute:
+ errors.append("iter225 workflow must run the safety guard before execution")
+ required = (
+ "TELOS_NAT_EXP: iter225_cross_model_generalization",
+ "shard: [0, 1, 2, 3, 4, 5, 6, 7]",
+ "TELOS_NAT_SHARD_COUNT: 8",
+ "TELOS_NAT_SHARD_INDEX: ${{ matrix.shard }}",
+ "fail-fast: false",
+ "upload-artifact",
+ "if-no-files-found: error",
+ )
+ for fragment in required:
+ if fragment not in text:
+ errors.append(f"iter225 workflow is missing: {fragment}")
+ if "if: always()" in text or "continue-on-error:" in text:
+ errors.append("iter225 workflow must not weaken failure semantics")
+ return errors
+
+
+def main() -> int:
+ status, errors = scenario_state_errors()
+ errors.extend(solve_summary_errors())
+ try:
+ runner_text = RUNNER.read_text()
+ workflow_text = WORKFLOW.read_text()
+ except OSError as exc:
+ errors.append(f"cannot inspect iter225 execution surfaces: {exc}")
+ else:
+ errors.extend(runner_safety_errors(runner_text))
+ errors.extend(workflow_safety_errors(workflow_text))
+ if errors:
+ for error in errors:
+ print(f"iter225 scenario safety error: {error}", file=sys.stderr)
+ return 1
+ if status == "no-scenarios-yet":
+ print("iter225 scenario safety: no-scenarios-yet (explicit empty pre-provider state)")
+ else:
+ print("iter225 scenario safety: generated scenarios are indexed, hash-bound, and AST-safe")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())