diff --git a/docs/agents/RETRO-2026-08-31-agreed.md b/docs/agents/RETRO-2026-08-31-agreed.md new file mode 100644 index 00000000..2a77f2b7 --- /dev/null +++ b/docs/agents/RETRO-2026-08-31-agreed.md @@ -0,0 +1,103 @@ +# Agreed retro — Port and Decoder, 2026-08-31 + +Two self-reviews, one round of mutual attack, and the result both agents accept. + +* Decoder's own review: `docs/agents/RETRO-2026-08-31.md` on `auto/frame-blend-draw-path`. +* Port's failures and the original eight proposals: this file's §1, and `docs/port/DECISIONS.md`. + +⚠️ **Nothing here is applied to `PROTOCOL.md`.** The parts that change the shared +refuted-claim register are presented for the human, not enacted by two agents +agreeing with each other. Both agents remain paused. + +## 1. The single most expensive thing we did + +**We let claims that rest on our own renderer sit in the register as settled +refutations.** + +`REFUTED.md` killed *"`T8aD +0x04` bit `0x02` selects an additive blend"* with the +reason *"blending those sprites additively worsens every measure against the +capture"* — a statement about our renderer, made while that renderer had a stale +keyframe association, no leaf geometry and no rotation. The field is real. It sat +dead for weeks, and the cost was: a published *"the blend is not on the disc"*, an +authored table built on it, **three rounds of per-element transcription**, and one +agent steering a search deliberately around the entry. + +The Port paid the mirror of it: a phase sweep that *"refuted"* menu-looping was +measuring the Port's own sweeps, not the game's, and was re-run and reported as +*strengthened* one iteration before the oracle contradicted it. + +📌 **Neither of us was careless. The rule was in `PROTOCOL.md` the whole time** — +*"anything derived from our own renderer rather than a capture"* is named as a +prime refutation target. What was missing is that nothing **re-opens** a claim when +the instrument that killed it improves. + +## 2. The gap underneath our controls + +`PROTOCOL.md` already requires running an instrument through a control. **We both +did, and it did not help**, because: + +> **Our controls verified capability, not configuration.** + +* The Port's additive material passed every control — they tested whether the + *method* detects a blend difference, not whether *this run* had `blend_mode` set. + It was left at Godot's default, `MIX`. The change predicted a large move and + delivered **0.03**, and would have been publishable as a careful negative. +* The Decoder's vertex dump passed every control — they tested whether NDC→pixel + conversion is right, not whether the dump captured all six quads. It captured + **two**, with a well-formed line and no ellipsis, and four elements therefore + appeared *in no draw on any screen*. + +## 3. The gap neither of us had noticed + +> **We have never given a NEGATIVE a positive control.** + +Every *"undecodable, with reach"* page lists **where we looked**. Not one shows +that the search method **can find a property that is there**. *"Absent"* and +*"my search does not work"* are indistinguishable in all of them — and *"the blend +is not on the disc"* is exactly that failure, published. + +## 4. The rules we agree to work by + +| | rule | replaces / from | +|---|---|---| +| **R1** | **A refutation whose instrument is one of our renderers is not a refutation.** It is *"our renderer disagrees"* — 🟡, not ❌. Each register entry names its `instrument:`, and a `--stale ` mode lists everything that instrument killed, for re-opening when it changes. | Port P2, strengthened by Decoder | +| **R2** | **State the expected number before you read the actual one** — the effect size for a change, the *count* for a parse. *"This draw declares 24 indices, so I expect 6 quads."* | Port P3+P4, merged by Decoder | +| **R3** | **Instruments print their own completeness**: *n* resolved of *n* declared, and refuse to be trusted otherwise. | Port P4 | +| **R4** | **A negative carries a positive control.** Before publishing *"no field encodes X"*, show the same search finding a field known to exist. | Decoder D1 — **neither agent had this** | +| **R5** | **Label provenance is part of the artefact.** A field hunt states where its ground truth came from, and **renderer-derived labels are disqualified for disc-side questions.** | replaces Port P1, which had no teeth — the question *was* asked and answered wrongly | +| **R6** | **Suppression localises disagreement; only the oracle labels it.** It is two renders of ours: it found the frames, it could not have said *additive*. | Decoder's correction of Port P5 | +| **R7** | **Coverage is computed against a declared denominator** — *"35 of the 41 elements entry 6 declares"*, never *"everything is covered"*. | Port P6 + Decoder | +| **R8** | **Hold the role line even when the answer looks obvious.** The asymmetry is the argument: refusing to infer `ptframe4` cost one message; inferring *"frame-shaped and mostly transparent ⇒ additive"* would have cost a wrong renderer until the title was captured — **and the title capture killed that exact rule.** | Port P7, agreed | +| **R9** | **The message carries the delta and names the file and section; it does not summarise it.** Short messages are safe only when the pointer is precise. | Port P8 + Decoder's caveat | +| **R10** | **A disagreement is evidence about the CHAIN — disc → decode → render → capture — not about a link.** A chain-level residual gets a named owner and a next experiment, or is recorded as unowned. | Decoder F | +| **R11** | **A cross-agent pointer must fail loudly when it goes stale.** Every staleness incident here was silent. | Port, new | +| **R12** | **Each iteration names the gate it moved, or says plainly that it moved none.** | Port, new — see §5 | + +## 5. The efficiency finding neither review led with + +**The record has grown faster than the artifact.** `DECISIONS.md` is past 13 000 +lines. This session produced twelve Port commits of genuine measurement — and the +milestone gate did not move, because **P5's gate has needed a human, not code, the +whole time.** Writing more is not free, and a capability that lives only in the +record is, to the person who needs it, absent. + +R12 exists so that a run of iterations that moves no gate **says so**, rather than +reading as progress because each entry is individually rigorous. + +## 6. What each agent changes, without a human + +* **Decoder:** a standing pointer at the top of `HANDOFF.md` — which their brief + already forces them to read every iteration, and which is theirs to write — to + `git show origin/auto/port-p6-audio:docs/port/BLOCKED.md`. **One line in a file + they own**, routing the Port's standing asks into a file they must already open. + This closes a gap `BLOCKED.md` records as having cost three sessions. +* **Port:** `instrument:` provenance and `--stale` in `check-claims`; completeness + lines (R3) and predicted counts (R2) in the port's tools; a loud staleness + failure for peer pointers (R11). + +## 7. What needs the human + +1. **The register re-classification (R1).** It changes the file both agents read to + decide what *not* to try. Two agents agreeing is not the right authority. +2. **P5's gate** — a person clicking through the port. Unchanged, and it is the + only thing standing between the milestone and done. diff --git a/docs/port/BLOCKED.md b/docs/port/BLOCKED.md index 84a7c089..e5382529 100644 --- a/docs/port/BLOCKED.md +++ b/docs/port/BLOCKED.md @@ -175,7 +175,13 @@ per-draw alpha is as a function of sweep position. **3. Does `kind & 0x2` belong in the exporter?** Their focusable-flag result (0 violations in 15 493 entries, 24 paks) implies `ptbtn00` on the PRESS Ⓐ plate — `0x73002` in my export — is focusable, and my exporter classifies it `unknown` -rather than `button`. That is a **decode**, so consuming it is legitimate; I am +rather than `button`. + +📌 **The consequence is concrete, not theoretical.** `export/screens/title/press_start.json` +carries **`"buttons": []`** — an empty button list — on a screen whose one element +**has a focus record**. The port is describing a screen with a focusable element as +having no buttons. And it is the same element the Decoder says the PRESS Ⓐ pulse is +made of: `ptbtn00f` additive over a non-additive `ptbtn00`. That is a **decode**, so consuming it is legitimate; I am asking rather than taking because it changes an exporter classification that other things read, and `0x3000` (817 elements disc-wide, button-shaped and *not* focusable) is exactly the trap a looser rule would fall into. diff --git a/tools/port/verify-menu-audio b/tools/port/verify-menu-audio index 183790f3..a8b3579a 100755 --- a/tools/port/verify-menu-audio +++ b/tools/port/verify-menu-audio @@ -49,6 +49,29 @@ run walk down,down,accept,cancel,up run ctrl wait,wait,wait,wait,wait run noop left,left,left,left,left +# 🔴 AND A PER-CUE KNOWN NEGATIVE, because the bed-only control could not settle +# what it was being asked. `move` reported NOT FOUND on three consecutive runs at +# margins 0.109/0.120/0.131 against a 0.15 line that a documented earlier run had +# cleared at 0.185. Two readings fit that -- the cue stopped playing, or the +# threshold sits above the quietest cue's true signal -- and A MARGIN CANNOT +# SEPARATE THEM, because both produce a small number. +# +# So each cue now gets its own negative: the SAME walk, with only that cue's .ogg +# replaced by silence through the mod tree. Silencing a cue that is playing must +# collapse its correlation and leave the other two alone, which is a 3x3 matrix +# with six off-diagonal controls rather than one number to compare against a +# threshold. +for c in move confirm back; do + d="$OUT/sup_$c"; mkdir -p "$d/audio/se" + ffmpeg -v error -f lavfi -i anullsrc=r=44100:cl=stereo \ + -t "$(ffprobe -v error -show_entries format=duration -of csv=p=0 export/audio/se/$c.ogg)" \ + -c:a libvorbis "$d/audio/se/$c.ogg" -y + SYLPHEED_MODS="$d" run "sup_$c" down,down,accept,cancel,up + grep -q "^mod: audio/se/$c.ogg" "$OUT/sup_$c.log" || { + echo "the $c override was never read -- the matrix below would be meaningless" >&2 + exit 2; } +done + python3 - "$OUT" <<'PYEOF' import array, math, subprocess, sys O = sys.argv[1]; SR = 44100 @@ -123,19 +146,54 @@ def slide(tpl, hay, step=16): return best found = [] +tpls = {} for cue in ("move", "confirm", "back"): tpl = dec("export/audio/se/%s.ogg" % cue, "%s/%s.raw" % (O, cue))[:int(0.15*SR)] + tpls[cue] = tpl rw, tw = slide(tpl, walk) rc, _ = slide(tpl, ctrl) + # 🔴 NO VERDICT ON THIS LINE ANY MORE. It used to print PRESENT/NOT FOUND on + # `margin > 0.15`, and it called `move` NOT FOUND on three consecutive runs at + # 0.109/0.120/0.131 while the cue was DEMONSTRABLY SOUNDING -- silencing its + # .ogg collapses it to the bed floor. The bed-only control is a DIFFERENT RUN, + # so its margin carries every difference between two runs; the threshold that + # once cleared 0.185 was never a property of the cue. The number is still worth + # printing. The verdict now comes from the suppression matrix below. hit = rw - rc > 0.15 found.append((cue, tw, hit)) - print("%-8s walk r=%.3f at %5.2fs | bed-only r=%.3f | margin %+.3f %s" - % (cue, rw, tw, rc, rw-rc, "PRESENT" if hit else "NOT FOUND")) + print("%-8s walk r=%.3f at %5.2fs | bed-only r=%.3f | margin %+.3f" + % (cue, rw, tw, rc, rw-rc)) # 3. The ORDER is the strongest evidence here and it is free: the correlator is # never told where to look, so three templates landing in script order -- # move (step 1) before confirm (step 3) before back (step 4) -- is three # independent searches agreeing with the log. +# 3b. THE SUPPRESSION MATRIX. Row = the cue silenced, column = the template +# searched for. The diagonal is the only cell that should move. +sup = {c: dec("%s/sup_%s.wav" % (O, c), "%s/sup_%s.raw" % (O, c)) + for c in ("move", "confirm", "back")} +base = {c: slide(tpls[c], walk)[0] for c in tpls} +print("\nsuppression matrix -- drop in r when one cue's .ogg is silenced") +print(" " + "".join("%9s" % c for c in ("move", "confirm", "back"))) +ok = True +for row in ("move", "confirm", "back"): + drops = {col: base[col] - slide(tpls[col], sup[row])[0] for col in ("move", "confirm", "back")} + print(" silence %-6s" % row + "".join("%+9.3f" % drops[c] for c in ("move", "confirm", "back"))) + if drops[row] <= 0.05: + ok = False + print(" 🔴 silencing %s did not remove %s -- that cue is NOT SOUNDING" % (row, row)) +print(" => %s" % ("all three cues SOUND: silencing each one collapses its own signal" + if ok else "at least one cue is not sounding")) +# 🔴 THE VERDICT IS THE DIAGONAL ONLY, and the first version of this asserted the +# off-diagonal too -- "silencing a cue must not move the others". That failed, and +# the material is why: `confirm` lands at 1.12 s and `back` at 1.21 s, 0.09 s apart +# under a 0.15 s template, so the two windows OVERLAP. Silencing `confirm` raises +# `back` by 0.468 because confirm was masking it. That is a fact about two cues the +# game plays 90 ms apart, not a fault, and an assertion that calls it one would +# fail forever on correct audio. +print(" (off-diagonal is MASKING between overlapping cues, not an error --") +print(" confirm at 1.12 s and back at 1.21 s share a 0.15 s window)") + times = [t for _, t, hit in found if hit] print("cue order vs script order : %s" % ("CONSISTENT" if times == sorted(times) and len(times) == 3