Skip to content

fix(tri_loop): damage-repair requires --snapshot instead of defaulting to a path nothing creates - #2338

Merged
gHashTag merged 3 commits into
masterfrom
fix/2327-damage-repair-snapshot-required
Aug 21, 2026
Merged

fix(tri_loop): damage-repair requires --snapshot instead of defaulting to a path nothing creates#2338
gHashTag merged 3 commits into
masterfrom
fix/2327-damage-repair-snapshot-required

Conversation

@gHashTag

@gHashTag gHashTag commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Closes #2327

The defect was still live on origin/master

Re-verified at 4ea72c322fa5572fc0c33fb5deedb739b7ad6c6a. All three line numbers in the issue are exact:

file:line on master text
scripts/tri_loop/damage_repair.py:82 DEFAULT_SNAPSHOT = "docs/corpus/damage_snapshot_2026-08-15.json"
scripts/tri_loop/damage_freeze.py:130 out_path = "docs/corpus/damage_snapshot.json"
scripts/tri_loop/corpus_status.py:132 ap.add_argument("--snapshot", required=True, type=Path, help="tri damage-freeze json")

docs/corpus/ has zero paths on master (git ls-tree -r origin/master matches only docs/CORPUS-RATCHET.md, a different path).

The two defaults disagreeing is not a theoretical problem. On a clean checkout of master, running the pair back to back with no arguments:

$ ./scripts/tri damage-freeze specs          # writer, no --out
wrote docs/corpus/damage_snapshot.json

$ ls docs/corpus/
damage_snapshot.json

$ ./scripts/tri damage-repair                # reader, no --snapshot
no snapshot at docs/corpus/damage_snapshot_2026-08-15.json
run: tri damage-freeze specs --out docs/corpus/damage_snapshot_2026-08-15.json
exit=2

The reader reports "no snapshot" while the freeze it could have used is sitting beside it in the same directory.

What I checked before choosing a shape, and what I chose

Chosen: make --snapshot required, following corpus_status.py. Not "make the defaults agree".

Why not make them agree. To agree on "a path that exists", a snapshot would have to be committed. A snapshot pins file_sha256 per file plus a corpus_sha256; committed, it goes stale the moment any spec changes, and this tool's own staleness check (REFUSING: the corpus moved since the snapshot was frozen) would then refuse on every run. Agreeing on the undated name without committing it leaves a default pointing at a file that still doesn't exist until someone runs freeze — the same dangling shape, just likelier to connect by accident.

The rule adopted instead, which corpus_status.py:132 already followed for this same artifact: a reader must not guess at an input path, because it cannot know which freeze the caller meant; a writer may default its output path, because it creates the file rather than hoping one is there. Under that line damage_freeze.py:130's default is legitimate and is deliberately left alone; damage_repair.py:82's is not. It is also the same instinct as the staleness check: a tool that refuses to repair against the wrong snapshot should equally refuse to invent which snapshot you meant.

Effect on existing callers — checked, and it is zero. damage_snapshot appears in exactly two places repo-wide, both the defaults themselves. Nothing in the repo invokes damage-repair at all: scripts/ci/loop-tools-tracked.sh only asserts the file is tracked and the subcommand routes, and loop-tools-gate.yml runs tri damage, a different subcommand. No doc references it.

One honest correction to the issue's framing. I could not reproduce a fail-open in the "proceeds with garbage" sense — master's reader exits 2, and the digest guard would catch corpus drift if the dated file ever existed. The defect I did reproduce is a dangling default: the no-argument path is dead, and the error misdirects, telling you no snapshot exists while one does. Either fix removes the guess; this one removes the possibility of a guess.

Also changed: the surviving no snapshot at <path> message now echoes back the path the caller passed instead of a hardcoded literal, so the remedy it prints cannot drift from the request the way the two defaults did.

Mutant evidence

New test scripts/ci/test_damage_repair_snapshot_required.py. It builds a one-spec corpus and freezes it at the writer's default name, so a default-named snapshot is present on disk for every case — a reader that quietly adopts it is guessing, and that is a failure here. Four guards, failures collected rather than raised so one break cannot hide the other three. No compiler, no corpus, pure Python.

Failing against master's current code — 3 of 4 guards:

FAIL (3):
  - G2 right reason: the refusal does not both name `--snapshot` and say it is required.
    stderr was: 'no snapshot at docs/corpus/damage_snapshot_2026-08-15.json\n
                 run: tri damage-freeze specs --out docs/corpus/damage_snapshot_2026-08-15.json\n'
  - G3 resolves no path: the refusal names 'docs/corpus'. ...
  - G3 resolves no path: the refusal names '.json'. ...

G1 passes on master. The defective tool already exited 2, so a guard that only asked "did it exit non-zero" would have been green on this defect. That is precisely why G2 and G3 exist.

One mutant per guard, each leaving the earlier guards passing so every guard is proven independently:

mutant change guard that fired quoted failure
M1 snapshot = "docs/corpus/damage_snapshot.json" G1 G1 refuses: exited 0 with no --snapshot. A snapshot named docs/corpus/damage_snapshot.json was present, so the tool adopted a freeze the caller never named.
M2 refusal reworded to --snapshot is unset. G2 (alone) G2 right reason: the refusal does not both name --snapshot and say it is required. Exiting non-zero for an unrelated reason would look identical.
M3 dated path re-added as a "helpful" suggestion line G3 (alone) G3 resolves no path: the refusal names 'docs/corpus'. The reader must not point at a concrete snapshot file it was not given -- a hardcoded path here is exactly the defect #2327 reported.
M4 if snapshot is None:if True: G4 (alone) G4 still works: an explicit --snapshot to a real freeze exited 2. The refusal in G1 is then not a required-argument check, it is a tool that never runs.

M2, M3 and M4 each fired their own guard with all others still passing, so no guard is riding on another's failure. G4 is the anti-vacuity guard — without it, M4 (always refuse) would satisfy G1–G3 and look like a fix.

All mutants reverted; grep -n MUTANT returns nothing; the suite is 4/4 green on this branch:

OK: 4/4 -- damage-repair refuses without --snapshot (G1), says so in those
words (G2), names no snapshot path of its own (G3), and still runs when given
one (G4). The writer's default output was present throughout and was not adopted.

Gates

Wired into loop-tools-gate, which already covers these tools, is path-filtered on scripts/tri_loop/**, and needs no build. Ran every step of that workflow locally: loop-tools-tracked.sh PASS, py_compile 10/10 ok, dispatcher ok, new test 4/4.

The local .githooks/pre-commit could not run: it shells out to t27c check-now, and t27c is not git-tracked (git ls-files | grep -c 't27c$'0) — a build artifact, not a file missing from my checkout, and building it was not affordable here. So I verified the gate's actual assertions instead, the ones scripts/ci/now-sync-gate-diff.sh enforces in CI: presence (adds docs/now/2026-08-21-damage-repair-snapshot-required.md, matching the gate regex), freshness (2026-08-21 == UTC today), content (2 headings, 7 bullets). check-now-freshness in CI is the authoritative check and gates this PR regardless.

Corpus Ratchet, Seal Coverage and FPGA E2E Build are already red on master, are not required, and are unrelated to this change.

Adjacent, deliberately not fixed here

damage_freeze.py's --out default is left as-is — under the reader/writer rule above it is correct, since freeze creates the file.

Filed separately rather than widened into this diff: #2341damage_repair.py parses argv by hand with no else branch, so an unknown flag is silently ignored. Verified: a typo'd --clas DC-13bfd302 drops the scope and repairs every class (2 instead of 1), exiting 0 as if it had done what was asked. Distinct defect, distinct fix, not in scope here.

…g to a path nothing creates

DEFAULT_SNAPSHOT pointed at docs/corpus/damage_snapshot_2026-08-15.json.
docs/corpus/ has zero paths on master, and the companion writer damage_freeze.py
defaults its --out to docs/corpus/damage_snapshot.json -- a different name -- so
the two tools never connected on defaults even once the directory existed.

Removed the default rather than making the pair agree. Agreeing would mean
committing a snapshot, whose per-file digests go stale the instant any spec
changes, at which point the tool's own staleness check refuses on every run.
The rule adopted instead, which corpus_status.py already followed for this same
artifact: a reader must not guess at an input path, because it cannot know which
freeze the caller meant; a writer may default its output path, because it creates
the file. So damage-freeze --out keeps its default and damage-repair --snapshot
has none.

No caller is affected: damage_snapshot appears in exactly two places repo-wide,
both the defaults themselves, and nothing in the repo invokes damage-repair.

scripts/ci/test_damage_repair_snapshot_required.py freezes a one-spec corpus at
the writer's default name and then asserts four independent properties of the
reader: it refuses with no --snapshot, says so in those words, names no concrete
snapshot path of its own, and still runs when given one. Against the previous
code it fails 3/4. G1 alone passed there -- the defective tool already exited 2 --
which is why the other three exist.

Closes #2327
@github-actions

Copy link
Copy Markdown
Contributor

PR Dashboard

Generated at: 2026-08-21 15:45:34 UTC

Summary

Status Count
Total Open PRs 3
PRs with Failing Checks 1
PRs with All Checks Green 2
READY 1
FAILING 1
PENDING 0

Seal Status

  • ⚠️ STALE -- sha256(compiler.rs)=65f033d04125 != manifest seal=87e5cbd3ad94.
    The committed NMSE numbers were certified against an older compiler.rs.
    Run scripts/reseal-check.sh locally for the two-step reseal command (advisory; not a merge gate).

@github-actions

Copy link
Copy Markdown
Contributor

📓 NotebookLM Notebook linked to this PR

This notebook contains session context, decisions, and artifacts for this work.

@gHashTag
gHashTag enabled auto-merge (squash) August 21, 2026 15:45
@github-actions

Copy link
Copy Markdown
Contributor

PR Dashboard

Generated at: 2026-08-21 15:48:34 UTC

Summary

Status Count
Total Open PRs 5
PRs with Failing Checks 2
PRs with All Checks Green 3
READY 1
FAILING 2
PENDING 0

Seal Status

  • ⚠️ STALE -- sha256(compiler.rs)=65f033d04125 != manifest seal=87e5cbd3ad94.
    The committed NMSE numbers were certified against an older compiler.rs.
    Run scripts/reseal-check.sh locally for the two-step reseal command (advisory; not a merge gate).

@github-actions

Copy link
Copy Markdown
Contributor

📓 NotebookLM Notebook linked to this PR

This notebook contains session context, decisions, and artifacts for this work.

@github-actions

Copy link
Copy Markdown
Contributor

📓 NotebookLM Notebook linked to this PR

This notebook contains session context, decisions, and artifacts for this work.

@github-actions

Copy link
Copy Markdown
Contributor

PR Dashboard

Generated at: 2026-08-21 15:50:13 UTC

Summary

Status Count
Total Open PRs 4
PRs with Failing Checks 2
PRs with All Checks Green 2
READY 1
FAILING 2
PENDING 0

Seal Status

  • ⚠️ STALE -- sha256(compiler.rs)=65f033d04125 != manifest seal=87e5cbd3ad94.
    The committed NMSE numbers were certified against an older compiler.rs.
    Run scripts/reseal-check.sh locally for the two-step reseal command (advisory; not a merge gate).

@gHashTag
gHashTag merged commit ee494b9 into master Aug 21, 2026
23 of 25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

damage_repair.py DEFAULT_SNAPSHOT points at docs/corpus/, which does not exist in master

2 participants