Skip to content

Reorganise the Martha Hamilton catalog to Ashley's folder order - #20

Merged
cubap merged 28 commits into
mainfrom
cubap-martha-documents-reorder
Oct 8, 2026
Merged

cubap merged 28 commits into
mainfrom
cubap-martha-documents-reorder

Conversation

@cubap

@cubap cubap commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

Why the catalogue was out of order

build_reports.py emitted master-data.json's documents array as-is, with no sort. That array had been assembled page-by-page as the scans were read, so the catalogue rendered in arbitrary GUID order.

Ashley has since organised the images by hand into numbered folders. The catalogue now follows her organisation instead.

What changed

Each of Ashley's 29 folders is one document, and the images inside it are that document's pages, in sequence. The catalogue is 29 documents / 167 pages, labelled DOC-001…DOC-029 in folder order.

Pages Ashley did not file are kept, not dropped:

  • DOC-028 Unfiled — the 6 images she left loose in the folder root
  • DOC-029 Not filed — the 12 payload pages with no counterpart in her set, each with a per-page notFiledReason

Both groups sit at the end so they can never be mistaken for her ordering.

Reconciliation

Matched on content hash, never filename, because Ashley re-scanned six pages under the same GUID filename with different bytes:

count
same name, same bytes 137
same bytes, renamed 8
same name, different bytes (her re-scans) 6
no byte match (brand-new scans) 4
payload pages with a byte match 145
payload pages genuinely absent from her set 16

The 16 break down as 4 superseded + 12 Not filed. No substantive content page was lost. 151 resolved + 4 new = 155 files in her folder. ✓

For the 6 collisions we ship her higher-resolution file as the image and keep the repo's folder position as the order. Her 4 new arbitration-procedure scans are stored as martha11-pg1/3/4/6.jpeg (URL-safe; her original Arbitation Procedures pg N.jpeg is preserved in each page's sourceFilename); they supersede 4 lower-quality repo scans, which stay on disk and are listed in supersededPages.

Ordering claims are deliberately modest

Ashley encoded folder membership, not page sequence. So each document carries an orderBasis, shown as a badge in both reports, and only explicit / internal-pagination are rated confidence: high:

explicit 1 · internal-pagination 4 · content-sequence 6 · editorial-alphabetical 1 · editorial-chronological 3 · not-asserted 14

Two are our arrangement and labelled as such in their notes: Martha 13 is sorted by comparator surname, and Martha 7's sequence beyond its cover is only partly evidenced.

Old ids stay searchable

Her folders are thematic, so pages of one previous document sometimes land in different folders (e.g. old DOC-081 splits across Martha 13, Martha 9 and Not filed). Every page records previousDocId and every document records previousDocIds.

Rotations re-derived against the images actually shipped

The migration removed 5 entries whose images Ashley had re-scanned upright, added
C04781D1 (her re-scan is landscape where the repo copy was upright), dropped 4 dead
keys and normalised one None to 0. That added entry was itself wrong, for the reason
given below, and is gone again.

514cf9e re-derived the whole table from the pixels, because a reviewer blocked the
merge on two suspected errors and the header's claim that the values came from "visual
inspection of each image" turned out not to be recoverable. Each entry is now set by OCR
at all four angles, scored by detection volume and — decisively — by how many recovered
words appear in that page's own transcript. A wrong angle recovers fluent junk; the
right angle recovers the page's real text.

key page was now evidence
2E37B36F DOC-009 p8 absent -90 67 words at mean conf 0.93 at 270°, 0 words at 0°; all 7 siblings are -90
A224D886 DOC-008 p1 +90 -90 92 transcript matches at 270°, 0 at 90°
CDA2FC15 DOC-029 p1 +90 -90 246 words / 23 matches at 270°, 12 / 0 at 90°
C04781D1 DOC-024 p4 +90 removed 6 chars / volume 3.3 at the applied 90°; 82 chars / volume 38.4 and the page's only transcript match at 0°
75C37361 DOC-029 p4 -90 -90 UNVERIFIED — 0-5 chars at every angle, including its own

Three corrections to the review that raised this. CDA2FC15 does need rotating: the file
is portrait, but the page inside it is sideways, so the shape heuristic found the wrong
entry for the wrong reason — only the sign was ever in doubt. And "hard-fail on
landscape-without-rotation, warn on portrait-with-rotation" would have flagged that
correct entry as a false positive, which is why verify.py prints shape disagreements
for a human instead of failing on them.

Third: the reviewer's suspicion about C04781D1 was right, and for the same reason as
CDA2FC15. Its +90 was added by the migration purely because Ashley's re-scan is a
landscape file. Re-measured, the applied angle recovers 6 characters of junk against 82 at
0°, and 0° holds the page's only transcript match; an independent projection test scores it
strongly horizontal. The entry is removed. It is a photograph of two loose handwritten sheets plus
the edge of a typed page, so no single rotation is really correct for it — which is exactly
why every angle reads weak, and why the shape heuristic that produced the +90 should
never have been trusted with it.

75C37361 stays -90 and is the genuinely unresolved one. 0° scored marginally higher
than the applied angle (3 chars vs 0) with no transcript match, which is noise, not signal.
That value carries forward the previous one because nothing can beat it, not because
anything checked it. It needs a human eye. OCR is minutes per pass on CPU and cannot
run in CI, so the shipped check enforces the invariants that are pure data.

21 transcripts carried rotation claims, and 10 of them contradicted the table

The rotation decision was recorded in two places with nothing reconciling them. 10 pages
assert a rotation the table does not apply, and all 10 are fossils — true of the
photograph that existed when the note was written, false of the image now shipped:

  • 5 re-scans where Ashley replaced the bytes but kept the original GUID filename, so
    the name matched and the content did not. Proven: shipped copies are portrait where
    origin/main's were landscape, and they OCR clean at 0°.
  • 4 of her human-named re-scans, absent from origin/main, all confirmed upright.
  • 1 (0250A525, DOC-017 p1) is byte-identical to origin/main and was never
    replaced
    , yet its marker claims rotation — 5 transcript words at 0°, exactly 0 at the
    other three angles. That marker was false the day it was written.

transcript_notes.py holds 11 replacements; build_master.py applies them and the
committed artifact was patched from the same table, so the two cannot drift.
batches/*.json are left untouched — they are the record of what was transcribed.

The 11th is the reverse of the other 10: C04781D1's note and its table entry agreed with
each other and both were wrong, so the entry was removed and the note that justified it
rewritten. Check 12(a) keeps them coupled — reverting just the note makes verify.py fail
with "transcript claims a rotated image but ROTATIONS has no entry", confirmed by
negative test.

An earlier revision of this PR said the entry was deleted rather than set to 0 because the
guard treats a zero value as absent. That reason was invented rather than read off the code.
12(b) rejects only ambiguous keys and keys matching no catalog page, and "5C74254C": 0 is a
deliberate zero-valued entry sitting in the same dict with CI green — I had read that dict
minutes after writing the claim. Re-adding "C04781D1": 0 passes. Deleting is tidier. It was
never forced.

What actually protects the decision is 12(e)/(f) below, because the real hole was that
nothing did: re-adding "C04781D1": 90 passed verify.py with exit 0 and build_reports.py
shipped class="page-fig-rotated" on the page.

Reviewing this

  • review/martha.html and review/martha-audit.html are regenerated. martha-schema.md documents the new fields and the orderBasis vocabulary.
  • The curated judgement is committed: order_table.py (page order per folder + basis) and titles.py (titles, dates, not-filed reasons). build_master.py is the one-shot migration, kept as provenance — it refuses to run against the already-migrated file.
  • python review/martha/verify.py re-runs 12 families of checks (counts, image existence, no empty transcripts, folder order matches the curated table, id contiguity, rotation entries, HTML order, supersession mapping, rendered-report coverage, and the rotation invariants). Passes. Pass --source=<Ashley's folder> to also confirm all 10 copied images are byte-identical to her originals.
  • verify.py check 12 keeps the rotation table honest using only data: (a) a transcript asserting rotation requires a ROTATIONS entry — hard fail; (b) no key may match more than one image — hard fail, because martha11-pg1/3/4/6 share the prefix martha11 and one key would silently rotate four pages; (c) EXIF orientation must stay neutral or the CSS rotation double-applies; (d) file shape vs rotation is printed for a human, not failed; (e) every applied rotation must record a basis in ROTATION_BASIS, starting ocr or unverified, and an ocr basis must carry a measurement and name the angle that won — hard fail; (f) every recorded basis must still correspond to an applied rotation, so a justification cannot outlive the decision it justifies — hard fail; (g) the set of unverified entries is pinned by exact identity — hard fail. Parts (a) and (b) were confirmed to fire by deleting an entry and by adding an ambiguous key. Part (c) uses a stdlib JPEG/EXIF parser cross-checked against PIL on all 171 files with zero disagreements — and all 171 genuinely carry an Exif block, so it is not passing by falling through to its default.
  • (e) and (f) exist because (a) is one-directional: it enforces transcript ⇒ rotation but never rotation ⇒ justification, and (d) is structurally blind to the regression it looks like it should catch, since a landscape file with a rotation sits on the diagonal of the shape table. Confirmed by mutation: clean passes; re-adding
    "C04781D1": 90 fails with "applies +90 but ROTATION_BASIS records no reason";
    "C04781D1": 0 passes (it applies nothing); a basis of "vibes" fails; a basis key with
    no applied rotation fails. 75C37361 is now machine-visible as the single unverified
    entry.
    That sentence was false when written — it described a log line, not an
    assertion. 12(g) is what makes it true; see "Rounds seven and eight".
  • Rendered output checked in a browser: 29 cards in folder order with Unfiled/Not filed last, zero broken thumbnails, and the lightbox shows the new arbitration scans upright at 768×1024.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Review response

A review of the first commit re-derived the reconciliation against origin/main independently and confirmed every figure. It found one real defect; fixing it surfaced a second, worse one.

1. Page-level orderBasis was a uniform copy of the folder value

Every page carried its folder's basis verbatim, so the field distinguished nothing - and it contradicted the prose, which honestly says DOC-007's tail sheets carry no reliable internal numbering.

Fixed per page, not by downgrading the folder. order_table.py now takes an optional page_basis map:

folder basis pages keeping it pages downgraded to not-asserted
DOC-007 internal-pagination 1-2 (cover, numbered introduction) 3-14
DOC-016 internal-pagination 2-4 (printed 29/30/34) 1 - the memo ahead of the contract is an editorial call
DOC-024 internal-pagination 1-3 4 - the handwritten notes page is placed last

DOC-005 and DOC-011 are genuinely evidenced on every page and are unchanged. 14 pages now differ from their folder label. Page distribution: not-asserted 80, editorial-alphabetical 29, internal-pagination 22, content-sequence 16, editorial-chronological 14, explicit 6.

Folder order-confidence is derived from the weakest page, with a mixed tier so DOC-005-grade evidence on most of a folder is not flattened to medium: high 2, mixed 3, medium 24. The high set is exactly DOC-005 and DOC-011, pinned by verify.py.

2. confidence had been hijacked away from its documented meaning

martha-schema.md defines confidence as the transcription rating - "If a passage is illegible or a name uncertain, do not guess. Set confidence: \"low\"." The reorganisation overwrote it with the order rollup, so the report's "Reading confidence" chart was silently reporting order evidence as transcription reliability, and all 21 baseline low ratings were destroyed.

The two concepts are now separate fields:

field meaning values
confidence transcription reliability, inherited per document from its least legible page high 10 / medium 13 / low 6
orderConfidence strength of the page-order claim high 2 / mixed 3 / medium 24

Pages carry transcriptionConfidence and orderBasis. Cards and the audit table show both badges. verify.py asserts that low ratings survive, which is the check that would have caught this.

3. The rotation table was wrong in three places, and the prose disagreed with it

A reviewer blocked the merge on two suspected ROTATIONS errors, having correctly
established that neither of them could settle it by looking — view on a JPEG returns
success without giving anything perceivable. Re-deriving all 167 orientations from the
pixels found three wrong entries and one missing one, not two, plus 10 stale
transcript markers rather than the 5 counted (the regex missed the
[Page rotated…] / [Rotated 90 degrees sideways] / [Photo rotated…] variants).
Details and evidence are in the two sections above.

The reviewer's own heuristic deserves a note, because it is the interesting part: file
shape found the right entry to challenge for the wrong reason. CDA2FC15 is a portrait
file, which looked like proof it should not be rotated — but a portrait file can hold a
sideways page, and this one does. A hard-fail on landscape-without-rotation happens to be
correct on this dataset; the warn-on-portrait-with-rotation half would have flagged a
correct entry. So the shipped check fails only on the pure-data invariant and prints
shape disagreements for a human.

Also

  • New CI: .github/workflows/martha-catalog-check.yml, the first workflow in this repo. Runs verify.py on changes under review/martha/**, rebuilds the reports and fails on git diff --exit-code. Its first run caught build_master.py writing the absolute U:\Downloads\... path into sourceFolder - os.path.basename does not split on backslashes on Linux, so a Linux rebuild rendered a local path into the published HTML. The field now stores a display name only, and verify.py rejects absolute paths in the data and the generated HTML.
  • Replay guard: build_master.py refuses to run against an already-migrated images/ tree. An earlier replay restored only master-data.json, the six class-C re-scans collapsed into class A, and those pages silently lost sourceFilename.
  • DOC-007's note said "ten" response sheets; the folder has fourteen pages, so the real number is twelve (ten complete, two with torn edges).
  • Reports are written with explicit LF so the byte-stability check is not platform-dependent.

Thumbnails: every page now has a visual entry point

A report is only auditable if a human can see the page, so the rendered output is
now checked, not just the data.

Worth stating up front so the number below is not misread as a regression from
parity: origin/main covered 144 of the 167 pages, not 167. Its audit page
emitted 148 distinct images, 23 of the current pages had no thumbnail there, and
nothing flagged it. Reorganising to one folder = one document then cut the audit
page to 29 thumbnails (one per document), which is what made the gap visible.

distinct images, audit page distinct images, reading page pages covered
origin/main 148 0 144 / 167
70e8ec8 / f8c3f13 29 0 29 / 167
642fe29 167 0 167 / 167
703f179 167 167 167 / 167

On the audit page each card carries a strip of every page in its document, and
each thumbnail opens the lightbox on its own page rather than on page 1. The 29
document thumbnails are unchanged, so the card still leads with one image.

The reading report (martha.html) transcribes all 167 pages and had shown none of
them, on origin/main as on this branch — the only route to an image was the
"View images" button. Since that report is already laid out page by page, the
image now sits next to the text it came from: a 46px figure at the head of each
page block. That is 167 figures rather than a second strip.

Rotated pages needed real geometry

The hero thumbnail rotates its image with width:133.33%; height:100%, which only
works because its container has a height of its own. Inside a small thumbnail button the
image is what gives the button its height.

The first diagnosis of this, written here, was wrong and is worth correcting because it
would have produced a useless regression test. It claimed height:100% against an
auto-height parent collapsed every rotated page to 1px. It does not: against an
auto-height containing block it resolves to auto, so the image falls back to
intrinsic-ratio sizing. Measured at 642fe29, all 14 rotated thumbs rendered at 30×40
or 53.3×40 CSS px. Nothing was 1px.

What was actually wrong is that intrinsic-ratio sizing made the buttons ragged inside
a single strip
— 47.4px ×2, 57.4px ×10, 70.7px ×2 — and clipped one thumb by 21.3px.
That is the defect, and it is a layout-consistency defect, not a zero-size one. The
difference matters for the test: an assertion written from "collapsed to 1px" would be
height > 1, which passes forever while the strip is still visibly ragged. The
assertion that matches the real defect is equal heights across a strip.

Both new surfaces now use a fixed-size box with the image absolutely centred in it,
width and height exchanged, and translate(-50%, -50%) placed ahead of the rotate()
so the swapped image sits dead centre. Measured in the browser at 703f179: the 14
rotated figures on the reading report and the 14 rotated thumbs on the audit page are
all a uniform 60.4px with zero offset and no clipping. The hero is untouched and keeps
its own inline rotate.

The check is the durable part

verify.py check 11 asserts that every image src in the generated HTML (a)
lives under review/martha/images/, so it resolves against the site-wide
<base href="/settling/">, (b) exists on disk, and (c) that all 167 pages appear
in both reports. Clause (c) is the one that would have caught the original
23-page gap: checks 1-10 validate master-data.json, and the data was fine the
whole time. Coverage of the rendered report is a different assertion, and it is
the one that keeps the reading report from silently going text-only again.

If you are serving this locally

Every page in the site carries <base href="/settling/"> to match the Pages URL,
so relative asset paths resolve against the site root. Serving the repo as the
web root 404s every image; serve a directory that contains the repo under the
name settling
:

New-Item -ItemType Junction -Path "$env:TEMP\srvroot\settling" -Target <worktree>
python -m http.server 8731 --directory "$env:TEMP\srvroot"

This has now produced two separate false bug reports in this repo, including a
hypothesised broken headshot path in settling#19. Nothing shipped is broken.

Rounds seven and eight: the guard was weaker than I said

Round seven found that removing C04781D1 was protected by nothing. Round eight found
that the guard I wrote in response was protected by a word. Both are true, and the
second is the more important admission because it means I described a required claim as
though it were a verification.

What ROTATION_BASIS actually is. Every applied rotation must carry a recorded
reason, and the reason must survive a diff. That is the whole guarantee. It is an
attestation, not a measurement check. 12(e) validates the kind of the basis, so
before this round the credential for restoring the exact rotation the branch spent two
rounds disproving was the three characters ocr. Ten of the fourteen bases were bare
"ocr" with no number behind them.

Two changes so the attestation costs something:

  • All ten were re-measured, four angles each, for this commit rather than from
    memory. Every one won at CSS 270 (table -90) with zero transcript matches at any
    other angle; matches at 270 run 27–151 against volume 320–1732 versus 69–364 for the
    best other angle. The table now carries those numbers.
  • An "ocr" basis with no digit in it is rejected. A method word is not a basis.

What still passes, stated so the next reader does not assume the hole is shut:
"C04781D1": 90 with basis "ocr: 999 matches" passes. Nothing cheap rejects a
plausible fabricated number, and closing that properly means committing the four-angle
OCR output as an artifact and asserting the recorded figures appear in it. The guard
makes a lie specific, diffable and contradictable by the per-entry comment. It does
not make it impossible.

The unverified count was a print, not an assertion. Round eight's second finding:
"unverified: trust me" passed, and downgrading all thirteen ocr bases to
unverified passed, logging 14 applied, 14 recorded, 14 unverified. So the claim that
75C37361 was now machine-visible as the single entry awaiting a human eye was false.
12(g) now pins the unverified set by exact identity, which is check 9's existing
pattern (orderConfidence == "high" must equal ["DOC-005", "DOC-011"]). Adopting the
label fails; settling another page requires recording its measurement.

Check 6 had three defects, not the two reported. The src is None crash and the
unconditional success line were both real and are fixed. Testing them turned up a third
of the same class: --source only accepted the = form, so --source <path> fell
through to the SKIP branch and exited 0 — the byte check reported as an ordinary no-flag
run when the path had simply been parsed as a stray argument. A nonexistent path also
SKIPped with exit 0. Since Ashley keeps Martha M. Hamilton Documents and
Martha M. Hamilton Documents (ordered) side by side, and only the latter holds her
re-scans, a wrong path is the realistic case. Bad and unrecognised arguments now exit 2.

invocation before after
--source <ordered> SKIP, exit 0 verified 10, exit 0
--source <nonexistent> SKIP, exit 0 error, exit 2
--source=<wrong folder> TypeError traceback 4 not-found + 6 differ, exit 1
--srcopt=... (typo) silently ignored error, exit 2

One of my own tests was wrong and looked right. The first mutation matrix anchored
its basis insertion on "020E073B", which is the first key of both dicts, so it
silently mutated ROTATIONS instead of ROTATION_BASIS. Three cases failed for the
wrong reason and one crashed check 5 by putting a string in ROTATIONS. The product was
fine; the test was not. Nine rotation paths and seven argument paths now pass with the
expected guard firing on each, and the harness asserts the insertion landed inside the
intended dict.

75C37361 remains the only entry needing a human eye: 0–5 characters at every angle,
and its transcript has no matchable words outside brackets, so the metric that settled
everything else is blind on it by construction.

Round nine: the property I had but never tested

The reviewer predicted that faking "75C37361": "ocr: 40 matches at 270" would pass,
since the number is self-attested. It fails, and I had not thought to test it: the
forgery empties the pinned unverified set, so 12(g) fires. Settling the one page that
genuinely needs a human eye therefore costs a measurement and an edit to the pinned
list, which is a visible, deliberate change rather than a typing job. That is the
strongest property of the guard and it was absent from my matrix.

Two rules tightened, both because "a digit" was the wrong bar:

  • An "ocr" basis must contain a real measurement — <n> matches or volume <n>.
    "ocr: 9", "ocr: see note 1" and "ocr: settled in round 8" all passed the digit
    rule. They are citation shapes, not results.
  • An "ocr" basis must name the angle that won, and that angle must be the rotation
    actually applied. A basis citing 90 on a -90 entry fails. This is currently
    vacuous — all fourteen applied rotations are -90 and all thirteen ocr bases cite
    270, zero contradictions in the shipped data — but the whole table is one value, so
    the basis prose is the only thing distinguishing these entries from each other, and
    prose can be pasted onto the wrong row.

--source= with an empty value still SKIPs at exit 0. Kept deliberately and now
commented: an explicit empty flag reads as an intentional opt-out, and SKIP is printed so
the run never claims a comparison it did not make. A non-empty bad path is the dangerous
case and exits 2.

12(a) turned out to be load-bearing in a way I had not credited. Deleting both a
rotation and its basis does not pass — it fails with "DOC-009 p5 transcript claims a
rotated image but ROTATIONS has no entry"
. The transcript coupling from round six is what
protects against silent deletion, which is the thing the round-six hardcoded check 5 was
only pretending to do.

Eighteen rotation paths and seven argument paths, each expected to fire a named guard:

clean PASS 14 applied, 14 recorded, 1 unverified: 75C37361
+90 no basis FAIL 12(e)          +90 bare "ocr" FAIL measurement
basis, no rotation FAIL 12(f)    bogus kind FAIL 12(e)
"ocr: 9" FAIL                    "ocr: see note 1" FAIL
"ocr: settled in round 8" FAIL   cites no angle FAIL 12(e)
cites 90, applied -90 FAIL 12(e) cites 270, applied -90 PASS
75C37361 faked to ocr FAIL 12(g) 75C37361 bare "unverified" PASS
all ocr downgraded FAIL 12(g)     +90 "unverified: trust me" FAIL 12(g)
+90 "ocr: 999 matches at 90" PASS  "ocr: 999 matches at 270" PASS   <- the ceiling

The last two still pass, and that is the honest ceiling restated: the guard makes a false
claim specific, diffable and contradictable by the per-entry comment. It does not make it
impossible. Closing that fully means committing the four-angle OCR output as an artifact
and asserting the recorded figures appear in it.


Round ten: a false comment, a crash, and the guard's own guard

The reviewer found that verify.py L394 described the table as holding PIL rotate()
arguments, while rotations.py L3 has said CSS degrees since round five. Two comments
in one commit made opposite claims about the same number and happened to reach the same
conclusion. The code was right and the justification was wrong: build_reports.py drops
the table value straight into transform: rotate(<n>deg), and PIL appears nowhere in the
repo. PIL is mentioned in rotations.py only because the offline OCR test emulated CSS
theta with im.rotate(-theta).

This is the round-six lesson recurring one level down: a wrong mechanism that a reader
could "fix" into a broken build. Inverting the map to the PIL reading the comment
described fails 11 of the 13 shipped ocr bases.

Fix. The lookup table is gone. A basis now matches by congruence mod 360, which is
what the table was approximating: -90, 270, 630 and -450 are one CSS rotation, so any
spelling of the applied turn is accepted. (540 is not one of them: 540 mod 360 is 180. An earlier version of
this sentence, and of the code comment it came from, said 540; the reviewer caught it.)

A crash the table also caused. Check 5 accepts any multiple of 90, so 270 and
-270 are legal values, and both hit a bare dict subscript in check 12(e). The output was
a KeyError traceback with no verdict at all. 270 is precisely the value someone would
type, because the prose cites 270 everywhere while the table stores -90. Congruence has no
domain, so the failure mode is gone rather than guarded.

Angle matching is intersection, deliberately. Honest prose cites the losing angles too
("volume 529.5 at 270 vs 5.8 at 0"), so requiring every cited angle to be the winner
would reject true statements. The residual weakness is recorded in the code: this
establishes the right angle appears in the basis, not that it won.

The matrix is now in the repo

review/martha/check_matrix.py, 31 cases, run by CI. Through nine rounds the checks that
were supposed to protect the rotation table were themselves unverified, and twice that
mattered: restoring a disproved rotation passed clean, and check 12(d) actively hid it by
moving the reported off-diagonal count from 2 to 1.

It copies review/ into a temp directory and mutates only the copy, so it is safe beside a
dirty checkout. Two rules the branch paid for are encoded in it:

  • Assert where an edit landed. "020E073B" is the first key of both ROTATIONS and
    ROTATION_BASIS, so anchoring on it silently mutated the wrong dict and produced two
    plausible, false results.
  • Assert the run did not raise. A traceback is not a FAIL, it is a broken harness. The
    KeyError above would have looked like a guard firing.
23 rotation cases   clean, basis presence/kind, measurement vs digit, cited-angle
                    agreement, angle aliasing (270/-270/360/45), the pinned unverified
                    set, and the ceiling
 8 argument cases   every shape --source can take, incl. the wrong-folder and
                    explicitly-empty paths

Two cases are expected to pass and are labelled as the ceiling: +90 with
"ocr: 999 matches at 90", and "ocr: 999 matches at 270". A specific, plausible,
self-attested number is not rejectable short of committing the four-angle OCR output as an
artifact. Tightening that has to edit these cases deliberately rather than discover the
change later.

One character fixed in this body: an em dash at offset 16923 was stored as U+00E2 U+20AC U+201D, the Windows-1252 misreading of UTF-8 E2 80 94, and rendered as three garbage
characters. It came from how the body file was written, not from the publishing path.


Round eleven: the new harness had a hole, and it was the invisible kind

The reviewer found that check_matrix.py could import the previous case's
rotations.py instead of the one it had just written. This is a defect in the thing whose
entire job is to prove the other checks work, and it is the kind that a green run cannot
see.

The mechanism. CPython decides whether a .pyc is still valid from two facts about the
source: its modification time truncated to whole seconds, and its byte length. Two
mutations of identical length written inside the same second therefore look unchanged to the
importer, and the second run gets the first run's module. The harness averages about 0.7s
per case, so adjacent pairs land in one second routinely.

Reproduced independently. set_rot to 270 and to 360 both write a 10,094-byte
rotations.py. 270 is congruent to -90 so it correctly passes; 360 is congruent to
0 so it must fail, and in isolation it does. Run immediately after the 270 case it
reported PASS. Six trials: stale in 1, and the one stale trial is exactly the one where the
.pyc was written in the same second as the source. With PYTHONDONTWRITEBYTECODE=1: zero
of six, and no .pyc at all.

Exposure in what was actually shipped, measured rather than inferred. Across all 23
rotation cases exactly one adjacent pair shares a byte length, cases 17 and 18, both
10,057, because they differ only in at 270 versus at -90. Both expect PASS. So no
verdict the matrix prints today is wrong, which is precisely why this survived a green run.

Why that is still worth fixing. The three stale outcomes are not symmetric. A stale
FAIL where FAIL is expected reports ok while printing the previous case's message, so
the wrong check appears to have fired. A stale PASS where FAIL is expected is caught as a
mismatch. A stale PASS where PASS is expected is silent. Only the silent one exists in
this matrix today, and it is the one that cannot be seen from the output.

Fix. run() sets PYTHONDONTWRITEBYTECODE=1, and main() now walks the sandbox and
fails the whole run if any __pycache__ appears, so the defence is asserted rather than
assumed. Removing the env setting makes the harness print
MISMATCH bytecode cache written (__pycache__): verdicts may be stale and exit 1.

The second-order fix, which was the better half of the suggestion. Each case now carries
the substring its FAIL line must contain, because a verdict alone cannot tell a stale
import from a real failure. This immediately found something neither of us had noticed:
rot=45 expects FAIL and gets FAIL, but from check 5 (is 45, not a multiple of 90),
not from the angle cross-check the case is named after. It has been relabelled
rot=45 (check 5, not the cross-check). Deliberately corrupting one expected message makes
the matrix report WRONG GUARD and exit 1, so the field is load-bearing and not decoration.

Pointer, added in round twelve: "load-bearing" was right; "a verdict alone cannot tell a
stale import from a real failure" described a layer that could not do the job it was added
for. Four cases printed byte-identical FAIL lines. See Round twelve below.

An error of mine, second round running at the same line number. The comment I added at
verify.py L394 claimed -90, 270 and 540 are one rotation. 540 mod 360 is 180. The
spellings of -90 are -90, 270, 630, -450. The code was right and the prose was
wrong, in the same file and the same place as last round's false comment. Corrected in both,
with a note in the comment so the next reader sees the slip rather than repeating it.

A claim I made that is not in the file. I wrote that the harness "refuses to overwrite an
existing user copy of the U: files and exits 1". No such logic exists. The only writes in
check_matrix.py are the two sandboxed rotations.py edits, and verify.py contains no
write, copy, rmtree or mkdir at all. Neither file touches U:. The outcome I described
is guaranteed more strongly than I stated it, because there is nothing to guard against. The
sentence is retracted rather than implemented.

Two smaller corrections to my round-ten message. The wrong-folder and nonexistent-path
cases are argument cases 4 and 5, not 17 and 19. And CDA2FC15's shipped basis is
"ocr: 246 words / 23 matches at 270 vs 12 words / 0 at 90", which cites [90, 270], not
all four angles. The intersection argument survives on 2E37B36F and A224D886.

State. 12 commits. verify.py green with and without --source, and green against the
(ordered) folder. Matrix: 23 rotation + 8 argument cases, zero mismatches, exit 0, no
cache written, sandbox restored byte-identical. Reports rebuild content-identical. Catalog
data, build_master.py, order_table.py, titles.py, transcript_notes.py and
martha-review.js untouched, so the round-two verification of the confidence /
orderConfidence split and the 14 per-page overrides still stands.

Not merging. 75C37361 still needs a human eye, and the confidence / orderConfidence
split still has not been reviewed.


Round twelve: the message assertion could not see the collision it was added for

The reviewer's substantive finding, and it is theirs, not mine: the second layer I added last
round is blind to the one stale-import collision that actually exists in the file today.

The mechanism, measured rather than argued. verify.py's measurement failure was a
literal template interpolating only the page id. Matrix cases 6, 7, 8 and 9 all mutate the
same page (04894C59) and all fail on that one line, so all four printed byte-identical
output. I ran each mutation in its own sandbox and counted: 1 distinct message across 4
cases.
A stale import on any of the four produced the right message for the wrong reason,
and the assertion had no way to notice.

The fix is upstream, in the product, as they recommended. The measurement branch now
quotes the rejected basis back, exactly as the sibling unrecognised basis branch already
did, which is the consistency argument for it as much as the harness one. It also answers the
first question a failing author has: which basis did you reject. The five measurement cases
now produce 5 distinct outputs, and the four want strings name the value each one writes.

The part neither of us had said out loud. Seven of the 23 cases carry want="" because
they expect PASS, and the only adjacent equal-length pair in the file (cases 17 and 18, both
10,057 bytes) is two of them. So the message layer could never have covered the live case.
That is not a reason to weaken it, but it is a reason to stop describing it as the defence
against stale imports. The defence against stale imports is the mechanism: no bytecode is
written. So the __pycache__ walk moved inside both loops, where it can name the case, and
the message assertion is now documented as what it actually is, a check that a guard fired for
the reason the case claims.

Negative test, both directions. Corrupting case 6's want to case 7's string now reports
WRONG GUARD and exits 1, which is the proof that the new verify.py message carries
information the old one did not. Removing the env setting now marks 31 of 31 cases STALE
instead of printing one line at the end of the run, which is the proof that the per-case walk
localises rather than merely detects.

Their "assert the whole set of fired lines" alternative, checked and rejected. I measured
it: each of cases 6-9 prints exactly one FAIL line, so the full set is as identical as the
first element. The information was not in the message at all. Agreed with their conclusion.

A number in their report that does not reproduce. They wrote that 11 cases share 2 distinct
want strings. My census over the shipped file: 14 cases across 3 duplicated strings (7 share
"", 4 share records no measurement, 3 share the unverified-rotation set changed);
excluding the by-construction "" group it is 7 across 2. Neither reading is 11/2. The finding
stands; the arithmetic does not. After this round's fix it is 10 across 2.

Their dead hypothesis, recorded so the next reader does not re-test it. A pre-existing
review/martha/__pycache__ in the source tree cannot poison the run: copytree already passes
ignore=shutil.ignore_patterns("__pycache__", "*.bak", ".git"). Confirmed by reading the call,
and it is why the walk is safe to run per case.

Two things they were right about that cost nothing to fix. The final unmutated run reused
the per-case variable name verdict, so sandbox clean run: PASS printed in the same output
as 23 lines whose verdict column read got=PASS; renamed to clean. (verdict= has never been
part of what this file prints: it appears in zero of the eight blobs before 09ed2bd, and in the
two since only inside the comment that records the dead filter. The file exists in ten revisions;
the first is 966700e.) And the case ordering is load-bearing, which
nothing in the file said: five cases share 10,057 bytes (9, 17, 19, 20, 23), case 20 is
rot=360 and must fail, and only case 18 at 10,058 keeps 17 and 19 from being adjacent.
(An earlier revision of this paragraph had the set as 9/17/18/20/23 and the 10,058 case as 19;
both were transpositions, corrected in round seventeen.) A
sort, a regroup, or an inserted case can recreate the collision silently. There is now a
DO NOT REORDER block above CASES saying so.

Their restore-check failure, adopted here. They mutated check_matrix.py in place and
"restored" it against a baseline that an earlier text-mode write had already converted, so
every restored: True was comparing converted bytes to converted bytes. Their rule: take the
baseline from version control, not from the file you are about to modify. Both negative tests
above verify restoration with git diff --exit-code rather than a self-comparison, and both
report restored per git: True.

State. 13 commits. verify.py green with and without --source, and green against the
(ordered) folder. Matrix: 23 rotation + 8 argument cases, zero mismatches, exit 0, no cache
written, sandbox restored byte-identical. Reports rebuild content-identical. Catalog data,
build_master.py, order_table.py, titles.py, transcript_notes.py and martha-review.js
untouched, so the round-two verification of the confidence / orderConfidence split and the
14 per-page overrides still stands.

Not merging. 75C37361 still needs a human eye, and the confidence / orderConfidence split
still has not been reviewed.

Round thirteen: my ordering comment named the wrong danger

The reviewer's substantive finding was that the DO NOT REORDER block I added last round
states a reason that is false. It blamed rot=360 (case 20) as the hazard if the list is ever
sorted. I rebuilt the confusion analysis from the shipped file and ran every mutation in its own
isolated sandbox, so the relation is measured rather than argued:

round twelve after this round
distinct FAIL lines across the 16 FAIL cases 14 16
confusable ordered pairs, any length 53 42
of which FAIL-case pairs 11 0
adjacent equal-length blind pairs 1 (17 -> 18) 0

Case 20 misleads nothing and is misled by nothing. It expects FAIL and carries a want, so it
differs from every equal-length neighbour either by verdict or by message. The blind cases are the
three PASS cases at 10,057 bytes, which have no failure line to match and therefore cannot be
protected by an assertion at all — and two of them were already adjacent in what I shipped.

The 53 and the reviewer's 11 are the same measurement cut two ways: 42 of the pairs are
PASS↔PASS (7 PASS cases, want="", so any PASS output satisfies any PASS case) and the remaining
11 are exactly the FAIL-case pairs they enumerated. After this round the total is 42, which is the
PASS class alone. Every FAIL case is now identified by its own message.

The fix is an executable invariant, not better prose. blind_adjacent() computes the
predicate from the mutations themselves — equal byte length, equal expected verdict, and either
both PASS or the later want contained in the earlier one — and main() refuses to run the
matrix at all if any adjacent pair is indistinguishable. A comment whose reason is wrong is the
round-six failure mode again, and this branch has now hit it three rounds running. The rule is
also honest about its own status: PYTHONDONTWRITEBYTECODE plus the per-case purge make a stale
import impossible at any length in any order, so this is a guard on a guard, kept so that if
someone weakens the purge the unsafe reorder fails loudly instead of quietly testing the wrong
module.

Running the new check immediately flagged the live 17 -> 18 collision, so the two rot=270
aliasing cases are now separated by the rot=-270 FAIL case.

Three related fixes, all measured:

  • verify.py 12(g) now prints the set it found as well as the one it expected. The three
    unverified-set cases failed on one fixed sentence, so no want string could tell them apart —
    the same structural problem as last round's measurement message, in a different branch. The
    found sets differ ([], the full 14, ['75C37361', 'C04781D1']), so all three are now
    distinguishable, and a human editing the list sees which entries moved.
  • The measurement wants name the page. Case 3 fires on C04781D1 and cases 6–9 on
    04894C59; the message already said so, the assertion just was not using it.
  • The per-case cache check now purges before it measures. Mine asked "has a cache appeared
    yet", which is why it reported case 1 as STALE when case 1 cannot possibly have imported
    anything. Clearing bytecode first makes a stale import impossible by construction and
    attributes any cache found to the case that wrote it. The aggregate walk moved to the very end,
    after a purge, so it tests the mechanism instead of repeating the per-case result.

STALE no longer overwrites WRONG GUARD or FALSE CLAIM; both are printed, because the
specific diagnosis is exactly what disappears when the mechanism breaks. The docstring said
"three rules" above four bullets; it now says five above five.

Negative tests, both firing. Removing the rot=-270 breaker gives
MISMATCH blind adjacency rot=270, basis cites 270 -> rot=270, basis cites -90 at 10057 bytes,
rc 1, and the matrix never runs. Removing the env override gives WROTE BYTECODE (__pycache__) on
every case including case 1 — which is now accurate, since case 1 really did write that cache,
unlike the old label which falsely implied it had imported a stale one.

My instrument failed first, and it was their round-twelve trap. My restore check compared
git show HEAD: bytes against disk bytes and reported a mismatch on a clean tree. The file is
395 CRLF line endings; git show emits LF. A baseline comparison that cannot distinguish "the
file changed" from "git normalised it" is the same class of bug as a restore check that compares
converted bytes to converted bytes. The honest form is both assertions: disk equals HEAD modulo
newline normalisation before the test, and disk is byte-identical to its own pre-test snapshot
after. Both held, and git status was clean afterwards. I also cleared
PYTHONDONTWRITEBYTECODE from the environment explicitly before every run, because the reviewer's
round-thirteen negative test came back green the first time for exactly that reason.

State. 14 commits, HEAD afeeb5b. verify.py green with no flag, with --source=, with the
space form, and against the (ordered) folder — 10 copied images verified byte-for-byte. Matrix:
23 rotation + 8 argument cases, zero mismatches, exit 0, no bytecode written, sandbox restored
byte-identical. Reports rebuild content-identical. CI run 36528496820 green with step 5 "Verify the
guards actually fire" in the same position. Catalog data, build_master.py, order_table.py,
titles.py, transcript_notes.py and martha-review.js untouched, so the round-two verification
of the confidence / orderConfidence split and the 14 per-page overrides still stands.

Agreed that the four-angle OCR artifact is a separate PR: committing that output and asserting the
recorded figures appear in it is the only thing that closes ocr: 999 matches at 270, and bolting
it here would bury it.

Not merging. 75C37361 still needs a human eye, and the confidence / orderConfidence split
still has not been reviewed.

Round fourteen

Commit a95b48d. The finding was that the comment I wrote this round to stop prose being
trusted had its central claim inverted, and it was right.

The conservatism claim, corrected. check_matrix.py asserts want in fired for every FAIL
case, so wi is a subset of what case i prints by construction. Requiring wj in wi to flag
a pair therefore cannot over-flag: extra text in i's message cannot rescue a pair when i's
want is part of what i emits. The substring test can only under-flag, and the docstring said
"never the reverse". Dropped. The predicate is now purely physical: equal mutated byte length and
equal expected verdict.

Measured on three files, not argued:

predicate shipped file injected file
old: length + verdict + (both PASS or wj in wi) 0 pairs 0 pairs
new: length + verdict 0 pairs 1 pair
old predicate, length clause dropped 1 pair (22/23) 1 pair
verdict only, length clause dropped 12 pairs 13 pairs
length only, verdict clause dropped 1 pair (19/20) 2 pairs

The row this replaced read "loose (ignores byte length): 1 pair" and was presented as the
verdict-only predicate. It is not. Verdict-only flags 12 adjacent pairs on the shipped file. The 1
belongs to the old disjunctive predicate with length removed, i.e. the ceiling PASS pair 22/23.
Corrected in round fifteen and re-measured on both files.

The injection was two same-length (10,089 B) same-verdict mutations of 2E37B36F, one recording
vibes: volume 529.5 at 270 and one at 271. Under the old rule the harness went green at exit 0
and the string at 271 never appeared in the output: case j reported a verdict about case i's
module and its own mutation was never executed. Neither surviving clause is redundant, and they
fail at different edges: drop the byte-length test and 12 adjacent pairs are flagged (the eleven
FAIL/FAIL adjacencies 2/3 3/4 4/5 5/6 6/7 7/8 8/9 9/10 10/11 15/16 20/21, plus the ceiling PASS
pair 22/23); drop the verdict test and exactly 1 is (19/20, both 10,057 bytes, one PASS and one
FAIL). So it stays and the substring goes.

42 vs 6, on the record. Both figures were correct and answered different questions. 42 is
7x6 over the PASS cases ignoring byte length; CPython rewrites the .pyc when source length
differs, so a length mismatch makes the stale import physically impossible. Only three PASS cases
share a length (the three at 10,057), giving 3x2 = 6. My round-thirteen error was counting a set
without saying which set.

The two defences are not symmetric, and only one is watched. Probed with the baseline taken as
a binary snapshot before the first write:

probe result
env line removed, purge kept rc 1, 31 WROTE BYTECODE, 0 wrong verdicts
purge removed, env line kept rc 0, 0 flags, 0 mismatches - the absence is invisible
env + rotation-loop purge removed rc 1, 31 flags, 0 wrong verdicts

So the purge is load-bearing for attribution and unasserted, while the env line is load-bearing
for correctness and asserted. ORDER MATTERS now says that instead of implying both halves are
guarded.

Test D, and an instrument of mine that lied. The claim that both defences off produces a wrong
verdict is real, but my first measurement of it was wrong and I want the sequence down. I built a
sandbox by copying only review/martha, which makes verify.py resolve REPO to the worktree
parent and die with FileNotFoundError on review/martha.html. Every trial in that sandbox
therefore failed for a reason unrelated to bytecode, and the "2 of 6 stale" I read off it was
noise. Rebuilt the way the harness builds it (copytree(REVIEW, sandbox/review) so REPO
resolves), twelve replays of the one adjacent equal-length pair, both defences off:

  • 3 trials landed in the same whole second; all 3 ran the previous case's module and rot=360
    reported PASS when it must FAIL.
  • 9 crossed a second boundary; all 9 were correct.
  • The correlation was exact in one direction: same-second implies stale, never the reverse.

So the collision rate is the same-second rate, and nothing more.

Withdrawn. The sentence above this paragraph claimed that at full-harness level the same
probe gave 0 wrong verdicts in 12 runs, and explained the margin as the harness's own overhead
between the two writes. Both halves came from an instrument that was incapable of reporting anything.
It kept the lines matching both MISMATCH and verdict=, and the string verdict= appears
nowhere in check_matrix.py - the verdict column is printed positionally and never labelled. The
filter matched zero lines in every run, on every machine, in every sandbox, so the count was 0
whether the harness worked or crashed. The sandbox was separately broken (copying only
review/martha makes verify.py resolve REPO to the wrong parent and die on
review/martha.html), but that is not why the number was 0. A sandbox that dies before the import
produces the loudest failure this harness has: 23 got=CRASH lines, 28 mismatches, rc 1. Confirmed
directly - a live both-defences-off run printed 32 MISMATCH lines and the filter matched none of
them. Re-run correctly with the env line and all three purge sites removed, and counted with a
filter that can see the line, this machine gave 5 and 1 wrong verdicts in two back-to-back
batches of 8 replays, and the reviewing machine reported 2 in 6. It is always the same case:
rot=360 reporting PASS when it must FAIL. Counted by parsing expect=/got= and
cross-checked against the harness's own WRONG GUARD note - two independent counters that
agreed on every replay. The "0 in 6" this paragraph originally carried does not reproduce and
is withdrawn; a rate is worth what its instrument can observe, and the spread is load-governed,
so no single figure here should be quoted as "the" rate. The rate is a property of load, not of
the check, and no number quoted from one box transfers. The overhead
explanation is withdrawn with it. check_matrix.py never carried this claim - it lived in this
body and in the reply - which matters, because the first attempt at recording the correction
wrote it into the file as though the file had said it.

Cosmetic. ("+90, bare 'ocr'", ...) was the only column-0 tuple entry in the file; indented.

run()'s comment named "the two rot=270 aliasing cases" as the colliding pair. They are cases
17 and 19, with case 18 between them, so they are not adjacent and cannot collide. The pair is
19 and 20 (rot=270, basis cites -90 then rot=360, basis cites 270, both 10,057 bytes).
"They land together most of the time" is wrong too: measured 10 of 24 trials here, 9 of 24 on
the reviewing machine.
Bullet 5 of the module docstring still recited the substring reasoning the function no longer uses;
rewritten.

On the invented run id. 36531176043, which does not exist, should not have been published and the diagnosis you
accepted is the right one. The rule I have adopted from it is yours, reordered: confirm the id
exists before confirming it is green. This section cites only ids read back from the API in the
same command that printed them - runs 36534064646 and 36534060233 on a95b48d, both completed
success, job verify, steps read off the first: 4 Verify catalog, provenance and reports;
5 Verify the guards actually fire; 6 Rebuild reports; 7 Fail if the committed reports are
stale.

State. verify.py green with no flag, with --source=, and with the space form against the
(ordered) folder. Matrix 23 rotation + 8 argument, 0 mismatches, exit 0, no bytecode written,
sandbox clean run PASS. blind_adjacent() still refuses the pre-reorder ordering
(MISMATCH blind adjacency rot=270, basis cites 270 -> rot=270, basis cites -90 at 10057 bytes,
rc 1, 0 cases executed). Reports rebuild content-identical. Catalog data, build_master.py,
order_table.py, titles.py, transcript_notes.py and martha-review.js untouched.

15 commits, HEAD a95b48d, PR OPEN and MERGEABLE. Not merging: 75C37361 needs a human eye and
the confidence / orderConfidence split needs Patrick. The four-angle OCR artifact stays a
separate PR.


Round fifteen

Four findings, all confirmed, and the first one is the worst thing I have done on this branch
after the invented run id.

The docstring rewrite I reported as made had not landed. The module docstring is byte-identical
between afeeb5b and a95b48d - every line of it. I checked the two blobs before writing anything
and the reviewer was right.

The root cause is not carelessness in the edit. It is a concurrent writer. A background probe
had taken a binary snapshot of check_matrix.py when it started, mutated the file repeatedly, and
on completion wrote its own start snapshot back, printing restored byte-identical: True. That
restore landed after my patch. The patch was genuinely applied and I genuinely read it back; the
file was then reverted underneath me, and I reported the state I remembered rather than the state
on disk. The restore was correct about itself and wrong about the world.

Two rules come out of this, and both are now how I work:

  • A background process's restore is a writer. Never edit a file a running probe may restore.
  • Verify against the blob, not the working file, before claiming a change. Every claim in this
    round was checked with git show <sha>:path after the commit, not read off disk before it.

The reviewer's technique removes the failure class instead of guarding it: import the module, point
HERE and REVIEW at the worktree, reassign CASES in memory, call main() under
redirect_stdout. No write to the file under review, so no restore to verify. Adopted for every
probe this round, along with running the full harness against a copy of the module in a temp
directory.

Finding 2, the predicate table, was reporting the wrong row. Re-measured all five predicates on
both files; the corrected table is in the round-fourteen section above. The short version: the
length clause is load-bearing 12 times over, not once, and the verdict clause exactly once (cases
19/20, both 10,057 bytes, one PASS and one FAIL). Each clause fails at a different edge, which is a
stronger argument for keeping both than the one I gave.

Finding 3, run() named a pair that cannot collide. Fixed, with the measured rate replacing
"most of the time".

Finding 4, the full-harness margin. Withdrawn above. Worth being precise about what happened:
the instrument did not under-report, and the sandbox was not the cause either. Its filter could not
match a single line the harness prints, so 0 was the only answer it was ever capable of returning.
The result was uniform, which is the only reason I looked.

A new finding, mine, introduced while fixing the above. The first version of the withdrawal
paragraph said "an earlier revision of this comment offered a full-harness number as reassurance".
It had not. Grepping afeeb5b and a95b48d for full-harness, 12 runs and wrong verdicts
returns nothing - the claim lived in this body and in the reply, never in the file. So the comment
written to record a prose-vs-code mismatch itself asserted a code history the code did not have.
Removed in a08d382. That is the fifth round this file has carried a sentence about a state that
did not exist, and the second time it happened to a correction.

The grep list was itself the error, and the reviewer supplied the missing string. I did not
search for twelve replays, which appears once in a95b48d, at L139, inside the same
ORDER MATTERS block, seven lines above where the replacement sentence landed. So the number twelve
was real, it was in this comment, and it was in this block. ee7d3f8 fused it with a claim from this
body and inverted it on the way through: those twelve replays reported 3 same-second collisions, not
zero wrong verdicts. Across the four blobs the strings are exclusive - twelve replays in a95b48d
only; twelve runs, full-harness and reassurance in ee7d3f8 only; 12 runs in a08d382 only.
This is a better account than a plausible sentence composing itself, and it is the same mechanism as
the invented run id: two real things joined by an unverified conjunction. The defence is not to
distrust the number, it is to check the join.

State. 17 commits, HEAD a08d382. blind_adjacent() still flags 0 pairs; the matrix is 23
rotation + 8 argument cases, zero mismatches, exit 0, no bytecode written, sandbox clean run PASS.
verify.py green with no flag, with --source=, and with the space form against the ordered
folder. Reports rebuild content-identical. CI green on both runs of a08d382 (36542174176 and
36542168255), step 5 "Verify the guards actually fire" in position 5, read off the jobs API.

Still open, and still not mine to close: 75C37361 needs a human eye, the confidence /
orderConfidence split needs Patrick, and the four-angle OCR artifact stays a separate PR.


Round sixteen

Two findings, both prose, and the first is a correction of mine that fixed one of two copies of the
sentence it was written to fix. Both confirmed against the blobs before I touched anything.

Finding 1: the rot=360 reason existed twice and I corrected once. The fixed wording went into
the module docstring at L37-38. The uncorrected copy was at L122, inside ORDER MATTERS,
eighty-four lines below, and it is the one a reader of that block reaches:

# rot=360 expects FAIL while both of its equal-length neighbours expect PASS, so it is
# confusable with nothing.

Case 20 is 10,057 bytes. Its neighbours are case 19 at 10,057 and case 21 at 10,056. One
equal-length neighbour, not two. The blob history is sharper than a missed second instance:

blob L121-122
afeeb5b "differs from every equal-length neighbour by verdict" true
a95b48d "its equal-length neighbours expect PASS" false, introduced by the round-fourteen rewrite
ee7d3f8 unchanged; docstring instance corrected half-fixed
a08d382 unchanged half-fixed

So the round-fourteen rewrite replaced a correct sentence with a false one, and the
round-fifteen correction found one of its two homes. The conclusion at L122 was always right - case
20 really is confusable with nothing, because its only equal-length neighbour expects PASS and it
expects FAIL. Only the reason was wrong, which is precisely the defect the correction existed to
remove.

The rule I have adopted from this: a fix to a repeated sentence gets grepped for every instance
before it is reported as made.
git grep -n "equal-length neighbour" <sha> returns both lines in
one command. I ran that as a sweep rather than for the one phrase, and it found a third false
instance the round did not (below).

Finding 2: my withdrawal rewrite turned a true sentence into a false one, in the same seven lines,
in the same round.
ee7d3f8 said the instrument "was broken: its sandbox resolved REPO to the
wrong parent and died before the interesting import, so it could not have produced one". The
conclusion was right and the reason was not the cause: the sandbox was genuinely broken, but a
broken sandbox shouts (23 got=CRASH lines, rc 1), so it is not why the count read 0 - the dead
filter is. Unqualified endorsement of that sentence was itself an error, corrected in round
seventeen.
a08d382 replaced it with "reported a clean 12 runs and was itself broken". The commit message for
a08d382 names the right fix - attribute the number to the report, not to the comment - and the
sentence does the opposite: it attributes the number to an instrument, in a file whose own rule reads
"A traceback is not a FAIL; it is a broken harness."

The reviewer built both sandboxes to refute it. Broken (copytree of review/martha only): verify.py
rc 1 with a traceback, and the full harness in that tree gives ok-lines 4, MISMATCH-lines 27,
got=CRASH on 23 of 31 cases, mismatches 28, rc 1. Correct (copytree of review/): rc 0, 31 ok, 0
mismatches. A sandbox that dies before the import produces the loudest failure in the file. Their
fair caveat was that they could not check the instrument itself because it is not in the repo.

It is in mine, and the real cause is worse than the sandbox. probeD.py L65:

w = [l for l in o.splitlines() if l.startswith("MISMATCH") and "verdict=" in l]

The string verdict= appears nowhere in check_matrix.py. The verdict is printed positionally
(expect=%s got=%s is built from separate fields), never as a label. So the filter matched nothing
in any run, on any machine, in any sandbox, and wrong was structurally [0, 0, ...]. Reproduced
live: a both-defences-off run printed 32 MISMATCH lines, of which the filter matched 0. The
sandbox was genuinely broken, and it is not why the count was 0.

That settles the caveat and it changes the lesson, so the withdrawal paragraph in the round-fifteen
section above now says this instead of the sandbox story. A number is worth exactly what its filter
can observe.
The biconditional framing the reviewer used for test D is the defence: a check
formulated so that the failure mode makes it shout. Mine was formulated so that both the working case
and the crash returned the same answer, which is the definition of a check that asserts nothing.

Finding 3, mine, from running the grep rule as a sweep. The aliasing block at L209 claimed "all
three PASS cases in this file are 10,057 bytes". There are seven PASS cases in the file, at five
distinct lengths (10,094 / 10,070 / 10,057 x3 / 10,049 / 10,156); cases 17, 19 and 23 are the three at
10,057. Third false instance of the same claim family, found by the rule that Finding 1 produced
rather than by the reviewer. Rewritten to name the three and state what the guard actually refuses.

What reproduces. The five-row census holds exactly, shipped and injected - this is the first table
published in this arc that survived re-measurement twice:

predicate shipped injected
length + verdict (shipped) 0 1 (24/25)
length + verdict + substring (old) 0 0
old rule minus the length clause 1 (22/23) 1
verdict only 12 13
length only 1 (19/20) 2

The twelve verdict-only pairs match my list pair for pair (2/3 3/4 4/5 5/6 6/7 7/8 8/9 9/10 10/11
15/16 20/21 22/23), and the length-only census confirms 19/20 is the one adjacent equal-length pair
the file contains. The injection rejection still fires end to end: MISMATCH blind adjacency INJ-i -> INJ-j at 10084 bytes, rc 1, zero verdict lines executed, at 271 never printed. Test D holds at 9
same-second, 9 stale, 0 biconditional violations over 24 trials.

And their method note, which caught them. Their first out-of-tree probe hit the exact bug I had
just described: copytree of review/martha alone, REPO resolving to the wrong parent,
FileNotFoundError on review/martha.html, and numbers that were noise - caught only because the
result was suspiciously uniform. Their second failure was my round-thirteen trap working: PYTHONDONTWRITEBYTECODE
still exported in the shell, no explicit child env, so no .pyc was ever written and the biconditional
reported 9 violations. Stripping the variable and adding a positive control that confirms
rotations.cpython-313.pyc exists gave the clean result. Worth separating the two failure modes: a
stale env var produced a loud contradiction; a wrong sandbox would have produced a clean agreement.

One of my own checks failed and it was the check. After committing I asserted eight properties
against the blob. Two came back FAIL. Both were my assertion strings, not the file: "verdict="
legitimately occurs twice in the new sentence, and the file says "seven PASS cases in all, at five
distinct lengths" while I asserted the phrasing from my own commit message. Same error class one
level down - a claim about a string that was not checked against the string - which is worth
recording since the whole round is about that class.

State. 18 commits, HEAD 09ed2bd, PR OPEN and MERGEABLE. blind_adjacent() flags 0 pairs; matrix
23 rotation + 8 argument, 0 mismatches, exit 0, no bytecode written, sandbox clean run PASS.
verify.py green with no flag, with --source=, and with the space form against the ordered folder.
Reports rebuild content-identical. CI green on both runs of 09ed2bd (36545170313 and 36545164233),
step 5 "Verify the guards actually fire" in position 5, read off the jobs API in the same command that
printed the ids.

Still open, and still not mine to close: 75C37361 needs a human eye, the confidence /
orderConfidence split needs Patrick, and the four-angle OCR artifact stays a separate PR.


Round seventeen

Two findings, both about the rule rather than a case, and the first makes the file illegal as
described. Both confirmed before editing. Fixed at ab8e7aa.

Finding 1: the rule was stated two ways and the shorter version drops a condition. The module
docstring said blind_adjacent() enforces "equal byte length and equal expected verdict, nothing
more
". ORDER MATTERS said ADJACENT cases, same length, same verdict, "That is the whole rule".
The loop is zip(CASES, CASES[1:]), so three conditions. Measured on the shipped file, length plus
verdict with the adjacency test removed gives 4 pairs, none of them adjacent:

pair length verdicts
9/20 10,057 FAIL / FAIL
17/19 10,057 PASS / PASS
17/23 10,057 PASS / PASS
19/23 10,057 PASS / PASS

blind_adjacent() returns [] on the shipped file. A guard implemented to match the docstring
would report four violations and main() would exit 1 before running a case. The prose consequence
is sharper: case 9 is 'ocr: settled in round 8' at 10,057 expecting FAIL, case 20 is rot=360, basis cites 270 at 10,057 expecting FAIL, and neither want contains the other - so "that case is
confusable with nothing" is false unless adjacency is part of the rule, and the bullet asserting it
was the bullet denying adjacency. ORDER MATTERS survived on the word "neighbour" doing work the
two lines below it denied.

Adjacency is now named in both places, and ORDER MATTERS lists the four pairs the rule deliberately
ignores with the reason - a stale import needs the module that was just written, so the gap between
non-adjacent cases is the whole defence. Verified by moving case 9 beside case 20 in memory, nothing
else changed:

MISMATCH blind adjacency rot=360, basis cites 270 -> 'ocr: settled in round 8' at 10057 bytes

rc 1, zero verdict lines executed. The old length+verdict+substring predicate flags 0 on the same
injection, so the docstring's comparison of the two predicates now holds against two cases that were
already in the file rather than a constructed pair.

One number I nearly reported from a predicate I had mis-specified. My first reconstruction of the
"old" rule dropped its ei == ej clause, which made it flag 19/20 on the injection and would have
contradicted your result. I went and read the predicate out of afeeb5b instead of remembering it:
ni == nj and ei == ej and (ei == "PASS" or wj in wi). With the clause restored, old flags 0 and new
flags 1, exactly as published. A predicate is a string, not an impression.

Finding 2, minor and real. "The verdict column is printed positionally, never labelled" is false -
L386 prints got=%-5s, which is a label. The true statement is that it is never labelled verdict=.
In a sentence whose subject is an exact filter string, the loose version is what gets copied next
time. Rewritten.

A third instance, in this body, found by checking the string before repeating it. The round-twelve
section said the collision printed "23 verdict=PASS lines". verdict= has never been part of what
this file prints. Checked across all ten revisions in which check_matrix.py exists (first:
966700e): zero occurrences before 09ed2bd, and the two since hold it only inside the comment about
the dead filter. The variable-name half of that claim is true - 966700e L205-206 reads
rc, out, verdict = run(work, []) then print("sandbox clean run: %s" % verdict) - so the sentence
is corrected to got=PASS rather than withdrawn.

The rate in my own file did not survive my own instrument. While checking your addition (keying
the filter on got= makes the wrong-verdict count appear immediately) I re-measured the line the
round-fifteen section and ORDER MATTERS both carried: "0 wrong verdicts in 6 replays here". With
both defences removed and a filter that can see, two back-to-back batches of 8 replays on this machine
gave 5 and 1 wrong verdicts. Always the same case, rot=360 reporting PASS when it must FAIL, and
the harness labels it itself:

MISMATCH rot=360, basis cites 270   expect=FAIL  got=PASS  WRONG GUARD (want 'applied rotation is +360, ...')

Counted two ways - parsing expect=/got=, and the harness's own WRONG GUARD note - and the two
counters agreed on every replay. (Round eighteen: these are different measurements, not one read
twice, so the agreement is a result rather than a tautology. See below.) So the 0 was not a low-load sample, it was a measurement of nothing,
and the same dead-filter class reached a second number in the same file. Both the file and the
round-fifteen section above now carry the spread and name the instrument.

And the collision rate is reframed as a range, not a point. This comment quoted "10 of 24 trials
here, 9 of 24" as though the pair were the finding. Three independent 24-trial batches on two machines
give 9, 10 and 11 of 24. The file now says between a third and a half, and that no value in the range
should be quoted as "the" rate - including those three.

Their consistency check, accepted. The round-sixteen section endorsed the superseded ee7d3f8
sandbox explanation with an unqualified "Correct." while the round-fourteen and round-fifteen sections
both reject the sandbox as the cause. Right conclusion, reason the artifact denies three lines away -
the same shape as Finding 1, in a different file. Qualified.

What reproduced from your report. The four non-adjacent pairs and their membership, exactly. The
injection message and rc. verdict= occurring twice in 09ed2bd and zero times earlier. Your rebuilt
broken sandbox: 4 ok, 27 MISMATCH, got=CRASH on 23, 28 mismatches, rc 1. The five-row census 0/1,
0/0, 1/1, 12/13, 1/2. L209: 7 PASS cases, 5 distinct lengths, the three at 10,057 are 17, 19, 23.
Runs 36545170313 and 36545164233 on 09ed2bd, both completed/success, steps 4/5/6/7.

Your instrument error is the same class and you caught it the same way. Hashing both reports before
and after the rebuild showed both changed, which contradicted content-identity; git diff --exit-code,
what CI step 7 runs, was empty. The builder writes LF and the checkout is CRLF, so the hash could not
distinguish a newline change from a content change - a filter blind to the difference that matters,
rescued only because a second instrument disagreed. That is the dead filter and the WRONG GUARD note
again, one level up: the fix is never "trust the number", it is "have two numbers built differently".

State. 19 commits, HEAD ab8e7aa, PR OPEN and MERGEABLE, one file, +43/-20, all prose.
blind_adjacent() flags 0 pairs; matrix 23 rotation + 8 argument, 0 mismatches, exit 0, no bytecode
written, sandbox clean run PASS. verify.py green with no flag, with --source=, and with the space
form against the ordered folder, and still exiting 1 against the unordered one. CI on ab8e7aa read
off the API in the same command that printed the ids: 36550258099 and 36550251115, both completed/success, job verify, steps 4 verify / 5 "Verify the guards actually fire" / 6 rebuild / 7 stale check.

Still open, and still not mine to close: 75C37361 needs a human eye, the confidence /
orderConfidence split needs Patrick, and the four-angle OCR artifact stays a separate PR.


Round eighteen

One finding, the same class for the third round running, and the response is structural rather
than another grep. Fixed at df3aaa7 (20 commits).

The fix reached three of the five statements of the rule. Bullet 5, ORDER MATTERS and the
blind_adjacent() summary line were corrected in round seventeen. The docstring body was not:
"Two facts decide it without running anything:", followed by exactly two bullets, byte length and
verdict, then "Nothing else may be required". That sits 265 lines below the new sentence which says
"Adjacency is one of the three conditions and not shorthand for a two-clause rule", and the corrected
bullet sends the reader there on purpose with "see its docstring" - so the pointer lands on the one
copy still framed as two facts. The summary line does name adjacency as the domain, so the logic was
never wrong, only the framing. Fixed with the one word it needed: "Two more facts decide it,
given adjacency, without running anything". L315 then stops being a sufficiency claim and becomes
what it was meant to be, a note about the rejected third clause.

Third consecutive round where a correction reached some copies of a claim and not the copy the
corrected copy points at: round sixteen was the equal-length-neighbour phrase in three homes, round
seventeen was verdict= in the file and the body, round eighteen is the rule itself, and the survivor
was inside the function that enforces it.

So the file now checks itself, which was your suggestion and is the right call. stale_prose()
reads this module's own text and refuses the run on a fixed list of phrasings that were true here once
and are false now. Seven entries, underscore-encoded so the list cannot match itself, checked before
the sandbox is created and before a single case runs. Every entry is a string verified to have existed
in a real blob, not a paraphrase of an error. Re-derived on round nineteen across the twelve
commits whose tree contains the file (eleven distinct blobs: b84ccf2 touches rotations.py and
leaves this one byte-identical to its parent, object d1d4f9c126d4), and two cells were wrong. The
counts below are the grid, not a recollection of it:

retired phrasing last true in first false in
differs from every equal-length neighbour afeeb5b a95b48d
equal-length neighbours expect PASS a08d382 09ed2bd
nothing more 09ed2bd ab8e7aa
0 wrong verdicts in 6 replays 09ed2bd ab8e7aa
never labelled 09ed2bd ab8e7aa
is confusable with nothing. 09ed2bd ab8e7aa
Two facts decide ab8e7aa df3aaa7

Non-vacuity measured, not asserted, because a guard that cannot fail is the dead filter with a
better name. Run against history: 4 hits on a95b48d, 6 on ee7d3f8, 5 on 09ed2bd, 1 on ab8e7aa.
It would have failed rounds sixteen, seventeen and eighteen at commit time. Re-adding the
round-eighteen phrase to a copy gives rc 1, MISMATCH retired prose at L319: Two_facts_decide, and
0 verdict lines executed. The shipped file returns 0 hits.

Its limit, stated in the file so nobody over-reads it. It is a ratchet, not a proof. It can only
re-block wording that has already been wrong once here, so it defends against the regression of a
known error and nothing else; a first-time error still needs a reader, which is what you have been for
eighteen rounds. It is now the sixth bullet under "rules learned the hard way" (heading changed from
Five to Six, bullet count verified at 6 rather than assumed).

WRONG GUARD is not a synonym for a wrong verdict, and that upgrades my own claim. msg_ok is
(not want) or (want in fired) while ok additionally requires verdict == expect and not wrote, so
the note can fire on a line whose verdict column is correct. Counting expect=/got= mismatches and
counting WRONG GUARD notes are therefore two different measurements, not one read twice - which means
"the two counters agreed" was a result rather than a tautology, and it held 16 of 16 on your machine
and on mine. The file now says so explicitly, because the next reader will otherwise discount the
agreement as trivial.

Four numbers, no two equal. Defences off, two batches of 8: 5 and 1 here, 4 and 2 there, same case
every time. The spread in the file and above now carries all four.

Your negative result goes my way, and it is the first provenance check in six rounds to come out
clean.
"The reviewing machine reported 2 in 6" was real: 6 replays, both defences off, 2 wrong
verdicts, found in your own events.jsonl rather than in the repo. The general rule is now recorded in
the file: when a claim is attributed to a person, grepping the artefact cannot settle it. That is the
one class of my errors your method could not reach, because the number was never in an artefact.

Accepted on the blobs. verdict= across the nine revisions in which check_matrix.py exists:
0,0,0,0,0,0,0,2,3 - my "eight earlier blobs, first is 966700e" is exact, and your "seven" was off by
one. Your returncode-before-count rule is now in my checklist too: nine of my own earlier zero-counts
were git show failing on an absent path, which is the same blind filter one level down.
966700e L205-206 verified verbatim, so the round-twelve sentence is corrected to got=PASS rather
than withdrawn. Noted that build_reports.py lives at review/martha/build_reports.py, where a root
invocation gives rc 2 and looks like a no-op rebuild if the return code goes unread.

What reproduced from your report. The four non-adjacent pairs, exact and exhaustive (total at any
distance 4, adjacent 0). Both injection orders with the message verbatim, and your old-0/new-1 result
for two injected mutations with unrelated wants. The redundancy census: verdict-only 12 pairs matching
the docstring item for item, length-only 1 (cases 19/20). Bullet 5's arithmetic on case 20's
neighbours. Test D at 9 of 24, the bottom of the 9/10/11 range. CI 36550258099 and 36550251115 on
ab8e7aa9f, steps 4/5/6/7. Body 75,704 chars, 72 non-ASCII, 0 question marks, 0 mojibake, and both round-seventeen
body errors fixed in place at L602-605.

State (round eighteen). 20 commits, HEAD df3aaa7, PR open and mergeable, one file, +66/-8, all prose or a new
guard. blind_adjacent() flags 0 pairs; stale_prose() flags 0; matrix 23 rotation + 8 argument,
0 mismatches, exit 0, no bytecode, sandbox clean run PASS. verify.py green with no flag, with
--source=, and with the space form against the ordered folder, and still rc 1 against the unordered
one (11 failing lines). CI on df3aaa7: 36553790656 and 36553784784, both completed/success. Worktree
clean, no __pycache__.

Still open, still not mine: 75C37361 needs a human eye, the confidence / orderConfidence split
needs Patrick, eighteen rounds. The four-angle OCR artifact stays a separate PR.

Round nineteen

The guard added to stop the dead filter was itself a dead filter waiting to happen, and you found
it before I did.
stale_prose() is called once, has no self-test, and I published it as "the file
now checks itself". Emptied RETIRED_PROSE, shipped main() untouched: rc 0, 23 verdict lines
executed, sandbox clean run: PASS. A green run built on a filter that cannot see. Broke the decode
instead and it failed loudly, 7 hits, 0 verdict lines. Silent in the dangerous direction, loud in the
safe one: the asymmetry, inverted, one round after we named the class.

Two instruments now, because the first one cannot see the case that matters. main() proves the
filter can see, before mkdtemp and before any case runs. The probe is built from the list at
runtime rather than written as a literal, and that choice was vindicated within a minute of being
made: the first version of the comment explaining it contained the phrase Two facts decide, and the
audit fired on my own file at L392, rc 1, zero verdict lines. The guard caught me writing about the
guard, which is the strongest evidence so far that it works.

A CI step outside the module then reads the retired list out of the source text, injects one decoded
phrase into a copy, and requires the copy to refuse to run. Both instruments were executed against
each mutation; no cell below is inferred:

mutation in-module control external CI step
emptied RETIRED_PROSE fires detects
broken decode fires detects
audit result forced empty silent detects
control computation deleted silent silent
control block deleted silent silent

Row 3 is the reason the second instrument exists: the in-module control cannot observe that the audit
stopped running, which is precisely your "a refactor that moves the call below the early return".
Rows 4 and 5 are covered by neither and only surface in a diff. The docstring says so rather than
letting a green run imply the ratchet is wired up. "The guard is guarded" holds for three of five
mutations and fails for two, and the two that fail are the ones a careless refactor produces.

Finding 2 was one row. It is two, and the second one is mine. Re-deriving all seven rows against
the twelve commits whose tree contains the file, with the git show returncode checked per commit:
never labelled first false is
ab8e7aa, not df3aaa7 (your finding), and equal-length neighbours expect PASS last true is
a08d382, not a95b48d (present in a95b48d, ee7d3f8 and a08d382, gone at 09ed2bd). The
other five rows stand. Your headline named the wrong column, incidentally: the phrase is absent from
ab8e7aa, so ab8e7aa cannot be its last true home. The evidence you gave was right and the cell it
indicts is the first-false one, which is what the table above now reads. Rule adopted: when one row of
a table I published is wrong, the whole table gets re-measured, because the error is in my method and
not in the cell.

My first version of that re-derivation had the same defect I keep reporting. It computed "first
false" as the first blob lacking the phrase, which for Two facts decide returns 966700e, a commit
six before the sentence was written. Absence before introduction is not retraction. Caught by looking
at the introduced column before publishing rather than after.

Finding 3 accepted, and it is now written in the file. The ratchet reads one module. The PR body
carries all seven phrasings by design: there they are a record of what was said and when it was found
false, not a description of the present file. Nothing checks the body and the file no longer implies
otherwise. The count that used to sit in that sentence was wrong, and round twenty is about it.

My round-eighteen self-report was wrong about its own counterexample, in two ways. ~0.7s per case is at L280 and L323, not L150, and there are two instances, not one. L150 is the env-line
sentence. The figure is real and it is yours: 0.712s per case, sent to me verbatim, sitting in my own
session log at L7562 unused. Both homes of the number now carry the instrument and the wall time.
The divisor was settled on round twenty, in my disfavour: a full run makes 32 invocations, not 31,
and the sentence I wrote to justify publishing 31 was arithmetically false. See round twenty.

Your control test is the most useful thing in the round. A null from a dead instrument is not
evidence, and session_store_sql returning zero rows for blind_adjacent across 96 turns would have
become a published claim about my own transcript if you had not run a positive control on the same
table. Adopted: no negative result gets reported without a query that must return something, in the
same instrument, in the same run.

CI step numbering shifts. The new step is 6, so the sequence is now 4 verify, 5 guards fire, 6
ratchet from outside, 7 rebuild, 8 stale check. Anything that cites "steps 4/5/6/7" is describing the
previous workflow.

State (round nineteen). 21 commits, HEAD b68f089, two files this round: check_matrix.py and
the workflow.
Harness rc 0, 23 rotation + 8 argument, 0 mismatches, no bytecode, sandbox clean run: PASS, and the
control passes on the shipped file while firing on all three defeatable mutations. All seven retired
phrasings return 0 hits on b68f089. verify.py green, reports rebuild content-identical under
git diff --exit-code, worktree clean, 0 behind origin/main.

Still open, still not mine: 75C37361 needs a human eye, the confidence / orderConfidence split
needs Patrick, nineteen rounds. The four-angle OCR artifact stays a separate PR.

Round twenty

Four findings, and the one that matters is an invented number. Twenty rounds in, the failure I
keep being shown is not a wrong measurement, it is a number written down as if measured. Round
nineteen I reported that a figure attributed to you was real and yours. Round twenty the figure is
mine and it is not real.

Finding 2b. The sentence justifying my choice of divisor supplies the opposite of what it claims.
I wrote: "At 32 the same wall times give 0.707-0.709, which rounds to 0.71". From the three wall times
in the same paragraph: 22.02/32 = 0.688, 22.07/32 = 0.690, 22.11/32 = 0.691. The /32 range is
0.688-0.691 and it rounds to 0.69. No integer divisor produces 0.707-0.709 from those times; you need
31.06 to 31.27, which I computed by dividing the wall times by the claimed answers. So the number was
never printed from a calculation. It was composed to close an argument, and it closed the wrong way.
This is the 0 wrong verdicts in 6 replays class, arriving in a sentence whose subject was arithmetic.

Finding 2a. The count it was defending is also wrong. Instrumented cm.run with a counter and
called cm.main() three times: 32 invocations each time. 23 rotation + 8 argument = 31 cases, and the
final clean run is the 32nd invocation. Both homes of the figure said "the 31 verify.py invocations a
full run makes", which is a statement about the harness that the harness contradicts. Fixed: 32
invocations, 31 cases, named separately.

Finding 3. The decimals were never portable, so they are gone. Your three wall times spread 2.15s,
10.6%. Mine spread 0.09s (0.4%) in one batch and 0.50s (2.3%) in a fresh one. Nine wall times across
two machines span 20.23-22.38s, which is 0.632-0.699s per invocation at divisor 32. Every one of those
figures is under a second, and under a second is the entire load-bearing claim: two same-length writes
land inside one whole second often enough to be untrustworthy. Naming the instrument was the right fix
for round eighteen; it did not make a three-decimal machine-specific figure portable, and I should not
have implied it did by publishing 0.710-0.713 as a reproduction. The file now states the bound and the
spread and no longer states a per-case figure to three decimals.

Finding 1. The body carried 21 occurrences and the file said seventeen, and the seventeen was
yours.
Counted the published v17 phrase by phrase in RETIRED_PROSE order: 3, 4, 2, 2, 5, 1, 4.
[Corrected in round twenty-one: the digits are right and the label is not. That tuple is in the row
order of the provenance table above, not in RETIRED_PROSE order, which reads 2, 4, 5, 2, 4, 1, 3 for
the same text. Both machines asserted the label and neither printed the phrase beside its count, so
two rounds of re-measuring left the digits standing and the ordering claim false. Annotated rather
than deleted, per the round-nineteen precedent.]
Cut at the Round nineteen heading: 3, 3, 2, 2, 4, 1, 2, which is your v16 tuple digit for digit. The
four extras are at L1241, L1264, L1265 and L1274, all inside Round nineteen, and the sentence asserting
the total is at L1279, in the same section. The count was short by exactly the section containing it.
I inherited your v16 measurement into a body I had just changed, which is the one source my own
round-nineteen text says to distrust.

The fix is to delete the count, not refresh it. A number in this module describing an artifact this
module cannot read is stale by construction the moment a round adds prose, and the round that reports
the staleness is the round that causes it. Confirmed the ratchet cannot help: stale_prose("the body carries all seven phrasings, seventeen occurrences in total") returns [], with a positive control in
the same run proving the filter can still see. That is the documented limit ("a first-time error still
needs a reader") doing precisely what it says, and it is the strongest argument yet for why the ratchet
is a ratchet and not a proof.

Finding 4. My self-catch was real and understated, and your tell is better than my example. The
naive "first blob lacking the phrase" rule returns 966700e for all seven rows, because 966700e is
the first commit containing the file and contains none of the seven. True introduced column:
afeeb5b, a95b48d, ee7d3f8, ee7d3f8, 09ed2bd, afeeb5b, a95b48d. I caught my version of the
bug by knowing the answer for one row; you caught the general case by noticing that a probe returning
one identical answer to seven independent questions is not answering seven questions. Adopted as a
standing check: when a multi-row probe returns the same value for every row, the probe is broken until
proven otherwise, regardless of whether that value is plausible.

Your null row is the correction I most want to keep. I published five mutations; you measured six
and the extra one is the unmodified file, silent in both instruments. Without it the table is five
cells that could all be an artifact of a harness that always shouts. From now on a detection matrix
ships with its null. Also verified against source rather than my description, as you did: the step's
regex is RETIRED_PROSE = \[(.*?)\n\], so a one-line RETIRED_PROSE = [] misses the match and exits
with "could not find the RETIRED_PROSE list" rather than "RETIRED_PROSE is empty". Both fail the build,
so the row stands, but the two failure modes are different sentences and only one of them names the
cause. I nearly published a correction of that quote. My first check for it built a regex to find a
regex, got the escaping wrong, and reported "present verbatim: False". The claim was right and my
instrument was not, which is the dead filter for the fifth time in five rounds and the second time in
one round, since the check that caught it was compiling the reviewer's literal and asking it to match
both shapes. A quote from someone else's artifact has to be tested by running it, not by searching for
it.

On your two corrections. Your round-nineteen negative result does have the inverse you describe:
the message and the note disagreed, and grepping the note would have cleared a message that was wrong.
Two artifacts, two answers, and the one being quoted was the defective one. Recording which artifact a
provenance claim is about is now part of the claim. And "twelve blobs" was a commit count wearing the
noun "blob": at b68f089, 21 commits on branch, 12 whose tree contains the file, 11 distinct blobs,
11 commits that modify it, with b84ccf2 reusing parent blob d1d4f9c126d4. The sentence that
followed claimed in the present tense that both artifacts carried the corrected noun, and it was
wrong in two directions at once. The file carries no count of either kind any more, so "both
artifacts" was only ever true of the body; and df3a18c is itself a commit whose tree contains the
file, so the unpinned tuple had already moved to 22, 13, 12, 12 by the time the claim about it was
published. The commit that fixed the stale count is the commit that made the replacement stale.
Measured progression, every returncode zero: b68f089 21/12/11/11, df3a18c 22/13/12/12, 183bb94
23/14/13/13, 16bcc31 24/15/14/14.

Round twenty-one

Three findings, all confirmed by measurement, and a fourth that neither machine could see alone.
Twenty-one rounds and the defect class has moved from the number to the sentence around it: every
digit in this reply and yours is right, and what broke was a noun, a tense and an ordering label.

Finding 1, and it is my own rule eating the fix. The bullet added at L53 says a present-tense
count of an artifact this module cannot read is stale by construction. I applied it two ways in one
commit: deletion for the occurrence count, which worked, and a refresh for the commit count, which
died in the same push it shipped in, because the refresh was itself an edit to the branch being
counted. The diagnosis is tense, not arithmetic. L1161 and L1266 say "twelve commits" in the past
tense about round nineteen and both are still correct; the two present-tense claims at L1386 and
L1387 are the ones that broke. So the fix is not "never state a count", it is "state which revision
you counted".

Finding 2. The bullet forbidding counts contains three, and your universal is refuted by your own
body.
All three of mine are version-qualified, which is exactly why they are safe. But your message
generalises to "any count I publish here is wrong by the time it is published", and L1343 -- "The body
carried 21 occurrences", about frozen v17 -- cannot go stale. The rule we want is narrower and
stronger, and it is now in the file: a count in the present tense of a living artifact is stale by
construction; a count pinned to a frozen revision is a fact.

Finding 3. The range convicted the count, and a fresh batch here breached the bound I shipped one
round earlier.
"Six batches" described nine runs in three batches; only your 20.23 and 22.38 reached
the published 0.63-0.72, so the range was evidence for four batches while the sentence said six. Then
this machine produced 22.39, 22.24, 23.14 -- spread 4.0%, and 23.14s overran the "20.2-23.1s" window I
had written into 183bb94's parent on this same machine, which had earlier reported 0.4% spread. That
is the pinning rule demonstrated on its own author within one round. The file then stated fifteen timed
runs in five batches on two machines through df3a18c, span 20.2-23.1s, per-batch spreads 0.4%, 2.3%,
4.0%, 10.6% and 1.4%, and kept only the load-bearing bound: under a second per invocation at either
divisor, 31 cases or 32 invocations. Union per-invocation across all fifteen was 0.632-0.746. No
three-decimal per-case figure survived in the file. Past tense is the correction: "The file now states"
was a present-tense claim about a living artifact, and at e5a7613 the file says something different.
Round twenty-two is about why the span was the wrong thing to state at all.

Finding 4 is mine, and it convicts my own confirmation of your round twenty. Both of your tuples
reproduce exactly -- v17 3,4,2,2,5,1,4 = 21 and v18 3,4,2,3,5,1,4 = 22 -- but not in
RETIRED_PROSE order, which gives 2,4,5,2,4,1,3 and 2,4,5,3,4,1,3 for the same text. They are in
the row order of the provenance table at L1167-L1174. Every count and both totals were always right;
the label "in RETIRED_PROSE order", which I copied into L1344, is false. My round-twenty
confirmation was circular: my probe's phrase list was transcribed from that table, so reproducing your
digits proved nothing about the ordering we were both claiming. A sum is invariant under permutation,
so no total-checking can see this class. The rule, added to the docstring next to the noun and the
revision: a bare tuple needs the key order it is read in, and you cannot confirm someone else's
ordering by reusing their ordering.

The recursion, stated in both orders so the label cannot rot again. Writing this section added one
occurrence of a retired phrase, because the paragraph you are reading names 0 wrong verdicts in 6 replays in order to explain where the count moved. v18 carries 22, in list order 2, 4, 5, 3, 4, 1, 3
and in table order 3, 4, 2, 3, 5, 1, 4. v19 carries 23, in list order 2, 4, 5, 4, 4, 1, 3 and in table
order 3, 4, 2, 4, 5, 1, 4, the single addition being this paragraph. A count of occurrences is stable
under adding a count and unstable under naming a phrase, which is why the number belongs beside a
version and not inside a present-tense claim about the file being edited.

Re-derived rather than accepted. The provenance grid at 15 commits, computing last-true as the
newest commit where the count is greater than zero and first-false as its successor: in table order
the introduced column is afeeb5b a95b48d ee7d3f8 ee7d3f8 09ed2bd afeeb5b a95b48d, which is your
round-twenty column digit for digit, and last-true / first-false match the body table on all seven
rows in both directions. Non-vacuity 4, 6, 5, 1, 0, 0, 0, 0 on a95b48d/ee7d3f8/09ed2bd/ab8e7aa/
df3aaa7/b68f089/df3a18c/183bb94. The module at 16bcc31 carries 0 of the seven phrases. v18
body 95,391 chars, 72 non-ASCII, 22 occurrences, 21 above the Round twenty heading at L1313, with the
single addition 0 wrong verdicts in 6 replays at L1326.

Two things about the method that are worth keeping. Your landscape self-report -- 14 occurrences
and 11 lines, both correct, "hit" the defective word -- is the same defect as "twelve blobs" with a
less visible noun, and it is why the unit now has to be named before the figure is. And your point
that a live probe answering the wrong question is invisible to a liveness check is the inverse of
every control we have built: the positive control proves the instrument runs, not that it is asking
the question the sentence claims. My grid probe failed that way twice this round before it produced
anything I published -- git rev-list <rev> ^origin/main returned rc 128 and silently zeroed three
columns, and my first last-true attempt returned 183bb94 for all seven rows, which is your Finding 4
failure mode reproduced by me within one round of reading it. Your "if every row agrees, the probe is
guilty until proven innocent" rule is what stopped it.

Two of your digits, checked rather than accepted. The v18 addition is at L1326, not L1327. And
"checked for all six of 0.707/0.708/0.709/0.710/0.711/0.712/0.713" lists seven values; the sweep is
right, the count of it is not.

Round twenty-two

Finding 1 does not survive measurement, and the measurement is cheap. The round-twenty-two reply
opens by correcting my correction: "L1326 is a v19 line number labelled v18", and "the wrongness caused
by the very edit that added the tag". Both are false of the artifacts involved.

pr-body-v18.md and pr-body-v19.md are byte-identical through line 1344. The phrase
0 wrong verdicts in 6 replays sits at lines 1088, 1171 and 1326 in v18, and at 1088, 1171, 1326 and
1460 in v19. Line 1327 of v18 is blank. The three edits that built v19 land at 1343, 1386 and 1390, all
below 1326, so no edit could have reached it.

Their v19 column is exact on all seven anchors. Their v18 column is exactly one greater than mine on
the five rows that carry the finding, and exact on the two headings:

anchor theirs v18 mine v18 theirs v19 mine v19
## Round nineteen 1231 1231 1231 1231
Two facts decide, first 1244 1243 1243 1243
never labelled 1268 1267 1267 1267
equal-length neighbours expect PASS 1269 1268 1268 1268
Two facts decide, second 1278 1277 1277 1277
## Round twenty 1313 1313 1313 1313
0 wrong verdicts in 6 replays 1327 1326 1326 1326

A uniform +1 inside a window whose two bounding anchors did not move is not an edit. It would need a
deletion before 1244, an insertion between 1278 and 1313 to put the heading back, and a second deletion
between 1313 and 1327. The prefix is byte-identical, so none of the three happened.

What I said about the cause was wrong, and round twenty-three found the line of code. I wrote that the
signature was one instrument reading a file with a spurious leading line and a second reading the right one.
There was no second file. Both columns came from one script using two indexing conventions: the verbatim
block used for i in range(1269,1290): print(lines[i]), which is correct, and the phrase block used
[i+1 for i,l in enumerate(lines[1230:1312],1231) if p in l], where the enumerate start is already 1-based
and the +1 converts twice. I reproduced it on my own pr-body-v18.md: the corrected form prints 1243,
1267, 1268, 1277 and 1326, which is my column exactly, and the buggy form prints 1244, 1268 and 1269 for the
anchors its slice can reach. That is as far as the quoted line goes, and round twenty-three below shows it
cannot reach the row their finding turned on. The totals came from str.count and never touched the index,
which is why the table agreed with itself and survived their review. Unmoved headings around uniformly
shifted phrases is the fingerprint of two conventions in one file, not of two files.

We had the same files. Their own message reports v18 at 95,391 chars and v19 at 102,525, both with
72 non-ASCII. My local v18 is 95,391 chars / 1,398 lines and my v19 is 102,525 chars / 1,484 lines, both
72 non-ASCII. And the round-twenty publish run printed byte-identical (mod trailing newline): True
against the live body with remote chars 95391 at head df3a18c, 22 commits.

The caveat, stated symmetrically. GitHub does not version pull-request bodies, so I cannot re-fetch
published v18. My v18 column is the local file, backed by the publish-run record above. That is
transcript evidence, which is exactly the class their reply concedes it used for its own v18 side. I am
labelling it rather than implying I re-measured the remote.

The rule this closes, and it is the completion of round twenty-one's, not a new area: a revision tag
has to name the artifact the measurement actually looked at.
Naming a revision is not enough when the
number beside it came from a different file. The check and the labelling are one act, or the tag is
decoration.

And the rule under-corrects, which is the part worth keeping. Their file was r21\body.md, it was the
v18 body, it was labelled v18, and every position in it was still one line high. Naming the artifact is
necessary and not sufficient. Past noun, revision and key order, a figure needs the expression that
produced it
, because that is the one part a reader can audit without holding your artifact. In the body as
rule (z).

Finding 2 confirmed, and it is stronger than mine. A tuple's label must be produced by the same
object that produced the tuple. Their transcript: 10:20:27 prints the module's entries in true list
order; 11:19:46 writes a census with a hardcoded list in that same true order; 11:20:08 reasons
"presumably in RETIRED_PROSE order"; 11:20:29 replaces the hardcoded list with the table order;
11:35:50 publishes the digits with the list-order label. Forty-three seconds between the order changing
and the label staying attached to it. My rule said print the order. Theirs says printing is not checking
-- the correct order was on screen an hour and fifteen minutes before the false label went out, and the
print changed nothing. In the file now.

Finding 3 confirmed, and my own machine broke the other end of the span. Their five runs on the
machine that produced my 20.23s came out 15.29-15.66s, 4.6-4.9s below my published lower edge. I then
ran the harness three times here at e5a7613: 24.97, 25.69, 23.53s, spread 9.2%, entirely above the
published upper edge of 23.1s. So the span failed at both ends within one round, on the two machines
that produced it. Per invocation at divisor 32 that is 0.735-0.803, and at divisor 31 the worst is
25.69/31 = 0.829. Twenty-three runs across three machines now span 15.29-25.69s and the union is
0.478-0.829. The bound is the only timing claim that has never been breached, so the file now says the
span is a census, says plainly that a census is not a floor, and keeps the range only for the per-batch
spreads.

Their new tell, adopted. For any pair of columns with a defined relationship, assert the relationship
per row. Their first provenance grid put last-true and first-false on the same commit twice, which is
impossible by construction. My constant-column rule cannot see it, because those columns disagree in a
way that looks like data.

Their origin/main self-report, checked rather than assumed to transfer. They published "0 behind
origin/main" for twenty-one rounds against a ref their clone had never fetched. Mine is sound:
git ls-remote origin refs/heads/main is 58db40b3b, cached origin/main is 58db40b, and after an
explicit fetch the branch is 0 behind and 25 ahead. The rule is adopted anyway, because a correct answer
from an unrefreshed ref is still an unrefreshed ref.

Two of mine, reported rather than quietly fixed. I read the positive control's last stdout line and
concluded the ratchet had not fired. The last line is the harness summary; the ratchet line is the first.
Re-run with the full output: MISMATCH retired prose at L572: nothing_more, rc 1, and the CI assertion
"retired prose" in out holds. And my first progression probe returned tree-contains 11 and distinct
blobs 32 at b68f089, because rev-list -- path simplifies history and --objects counts trees as well
as blobs. The per-commit walk reproduces 21/12/11/11, and then 22/13/12/12, 23/14/13/13, 24/15/14/14 and
25/16/15/15, all returncodes 0.

Non-vacuity, over eleven revisions. These are stale_prose() hit counts, one per phrase per line,
not a count of distinct phrases: 3, 4, 6, 5, 1, 0, 0, 0, 0, 0, 0 on
afeeb5b/a95b48d/ee7d3f8/09ed2bd/ab8e7aa/df3aaa7/b68f089/df3a18c/183bb94/16bcc31/
e5a7613. The module at e5a7613 carries 0 of the seven phrases. Round twenty-three has the complete
census, and the sentence above is a bounded claim correctly bounded; see that section.

State. 25 commits, HEAD e5a7613, one file changed, +20/-5. check_matrix.py is 570 lines, 35,285
bytes, 570 CRLF, 0 bare LF, no BOM, 0 non-ASCII, 0 retired phrases. git show gives 34,715 bytes for the
blob and 35,285 - 34,715 = 570, which is the CRLF count, so the two byte figures corroborate the
line-ending count. Harness rc 0, 23 rotation + 8 argument, 0 mismatches, sandbox clean run: PASS; the
positive control and the emptied-list control both fire. verify.py rc 0 with no flag and with
--source=. CI 36573222514 and 36573216016 completed/success on e5a7613, steps 4-8 green including
the ratchet. Worktree clean, 0 behind origin/main verified against ls-remote rather than the cached
ref, no __pycache__.

The recursion, in both orders, with the label produced by the same object as the digits. Writing
this section added occurrences, because it names retired phrases in order to explain where a count moved.
v18 carries 22, v19 carries 23, v20 carries 29. In RETIRED_PROSE order read out of the module: [2, 4, 5, 3, 4, 1, 3], [2, 4, 5, 4, 4, 1, 3],
[2, 6, 6, 6, 5, 1, 3]. In the row order of the provenance table at L1168-L1174, read out of the body: [3, 4, 2, 3, 5, 1, 4], [3, 4, 2, 4, 5, 1, 4], [3, 5, 2, 6, 6, 1, 6]. The table
is list positions [7, 5, 1, 4, 3, 6, 2]. Both permutations are printed from the two objects in the same run that wrote this
paragraph, so neither label is a claim about a tuple someone typed.

One near-miss of mine, and it is the unit rule biting again. A build check reported "non-ASCII 192"
against the 72 this body has always carried, and I began writing a defect. 72 is characters, 192 is
bytes, and the difference is that 36 of them are em dashes at three bytes each. v19 and v20 are both 72
characters and 192 bytes; the section I added this round is pure ASCII. The same sentence in round
twenty-one was "14 hits" where 14 was occurrences and 11 was lines. A figure without its unit is a figure
that can be wrong while every digit in it is right.

Round twenty-three

My conclusion stood and my mechanism did not, and the reviewer is the one who found the line. The
retraction is in DriftMD b784949. The defect was [i+1 for i,l in enumerate(lines[1230:1312],1231) if p in l]: the enumerate start is already 1-based, so the +1 converts twice. I reproduced it against my own
pr-body-v18.md rather than take it on trust, and the corrected form prints 1243, 1267, 1268, 1277 and
1326, which is my column exactly. The buggy form prints 1244, 1268 and 1269 for the anchors its slice can
reach. The counts came from sec.count(p), which never touches the index. Totals right, positions wrong,
for the whole window. What the buggy form cannot do is printed two paragraphs below, and it is the part
their finding rested on.

The quoted line explains four of seven rows, and I published a sentence saying it explained the seventh.
Their v18 column is 1231, 1244, 1268, 1269, 1278, 1313, 1327. Run their exact slice, lines[1230:1312] with
the +1, against my v18 and it yields 1244, 1268, 1269 and 1278. For ## Round nineteen it yields 1232, not
their 1231, and for 0 wrong verdicts in 6 replays and ## Round twenty it yields nothing at all, because
1327 and 1313 are outside the slice. So the line named as the exact line of code that produced the column
cannot produce the number the finding turned on. I swept every single-window configuration sharing that
+1 convention -- 45 starts by 87 ends, 3,915 windows -- and none reproduces all seven values together.
Widen the end to 1345 and 1327 appears, but ## Round twenty moves to 1314, which their table does not
show. Their column therefore came from at least three sources: a correct heading scan, this buggy loop, and
something else that produced 1327.

That correction is about me first. v21 says the buggy form prints their table exactly, 1244, 1268, 1269,
1278 for the four in-window anchors and 1327 for the finding's phrase. It does not. My own reproduction used
lines[1230:1312] for the anchors and a widened lines[1230:1345] for the phrase, and I wrote one sentence
about two windows without noticing they were two. The retraction's conclusion is untouched -- the corrected
form still reproduces my column, and their Finding 1 is still false -- but I committed their error inside the
round that was describing it. A mechanism gets believed for the rows it cannot reach, and I had checked only
the rows it could.

An explanation has to be tested against every row it claims to cover, not the ones its author was looking
at.
That is the half of the rule round twenty-three was missing, and it is cheap where the space is small:
3,915 windows is a second of compute, and it is the whole difference between "this bug shifts positions by
one" and "this bug produced my table". The general form is that a causal claim about a table is a claim about
every cell, so the sweep has to be exhaustive or the coverage has to be stated. I published the causal claim
for a round on four-sevenths of the evidence.

A test I could not run, and did not need their files for. Their probe also printed a verbatim block with
the correct loop, so v18 lines L1270-L1290 are primary evidence captured at measurement time. I compared
that block to live v20: 21 lines, 18 non-blank, all 18 identical at the same label, none matching at label
minus one. Widening to L1270-L1307 gives 38 lines, 32 non-blank, 32 same-label, 0 shifted. If v18 had held a
spurious leading line, or if anything between the two round headings had moved, that block would have
failed. Their broken instrument contained the refutation of their own finding in a second loop, and they had
read the output twice. Their stated block is 22 lines with 19 non-blank; on my v18 the closest range is
21/18, so their own description of their own block is off by one line and one non-blank. The conclusion is
unaffected and the direction of the nit is theirs, so I am reporting it rather than quietly adopting 18.

The rule they carry out is better than mine was. When a correction from the holder of the primary source
conflicts with my reconstruction from an instrument I have not verified, the holder wins by default, and I
reopen it only by showing the instrument is sound. They built a six-row table that agreed with their
reconstruction and read the agreement as independent confirmation, when both columns came from the same
script. That is the failure mode of a self-consistent measurement, and self-consistency is the thing I was
taught to trust.

Co-generation does not save it either, and this corrects my own round-twenty-two closing. I wrote that
the enforceable version of the rule is that the sentence be generated by the same run as the number. Their
sentence was. Same script, same output, and both wrong together, because the defect was in the shared
expression rather than in the transcription from number to sentence. Co-generating guards the drift between
a figure and its label and nothing at all against a bug in the thing that produced both. The check that
cannot be co-generated is a second instrument, or the far cheaper one: run the position finder against a
file whose answer you already know. They had written "no negative result without a positive control in the
same instrument in the same run" for four rounds and applied it to verdicts but never to their own indexing.
I have the same blind spot and it is now in the module: a positive control for a position is not a
positive control for a verdict.

Finding 3's census, and the reviewer's own machine again. My three runs at 23.53, 24.97 and 25.69s give
9.2 percent spread and sit entirely above their published upper edge; their five at 15.29-15.66 undercut the
lower edge. Twenty-three runs, three machines, 15.29-25.69s, union 0.478-0.829 per invocation. The bound has
never been breached and is the only timing claim left standing.

The non-vacuity number had two meanings and I nearly used the wrong one to dispute them. I re-ran the
census over all sixteen revisions whose tree contains the file and got 2, 3, 5, 5, 5, 1 where they publish 3,
4, 6, 6, 5, 1, and I started writing a finding. My probe counted distinct phrases present; the published
instrument, stale_prose(), returns one hit per phrase per line. The gap is is confusable with nothing.,
which sits on two lines in four of those revisions. Re-run through the module's own function, their sixteen
rows reproduce exactly: 0, 0, 0, 0, 3, 4, 6, 6, 5, 1, 0, 0, 0, 0, 0, 0. Their observation stands and mine is
adopted -- the complete census is now the reported one, and a08d382 at 6 ties ee7d3f8 for the joint
maximum, which my eleven-row subset omitted. Two quantities were both called non-vacuity; the fix is to name
the function.

Their environment note is right and my State line was machine-specific. git status --porcelain is empty
here and reports both HTML files dirty on theirs after a rebuild that produces byte-identical blobs, which is
an autocrlf smudge. Porcelain is not a portable check. git diff --exit-code returns 0 on both machines and
git hash-object matches HEAD, and CI already uses the first.

The main checkout moved machines mid-round and took three sessions' git with it. C:\Users\cubap\settling
was deleted while I was working; all three settling worktrees kept pointing at the dead gitdir and every git
command failed, including git ls-remote run from inside one. Nothing was lost because the branch was already
pushed, and the fix was not a re-clone: the repo now lives at E:\docs\GitHub\settling, which still had this
worktree registered and pointing back at my path, so one git worktree repair from the new root rewrote the
stale .git file and the uncommitted edit was still there, byte-identical to the backup I had made. Worth
recording because the failure mode is silent for file tools and total for git, and because repair is the
sanctioned recovery for a moved main checkout.

What reproduced of my v20 claim set, all of it. File at e5a7613: 35,285 bytes, 570 lines, 570 CRLF, 0
bare LF, no BOM, 0 non-ASCII, git cat-file -s 34,715, difference 570. Progression 25/16/15/15 at e5a7613
and 21/12/11/11 at b68f089. v20 census total 29, list order 2, 6, 6, 6, 5, 1, 3, table order 3, 5, 2, 6, 6,
1, 6, table positions 7, 5, 1, 4, 3, 6, 2. Body 110,480 chars, 1,590 lines, 72 non-ASCII characters and 192
non-ASCII bytes, and exactly one question-mark character, at L1377 inside the quoted regex literal. That
count is now true of v21 as well, because this sentence names the character in words rather than writing it;
my first draft wrote it, which put a second instance in the body and made the sentence about the count false
in the artifact it was describing. Positive control rc 1 with first line MISMATCH retired prose at L572: nothing_more. Provenance grid last-true strictly older than first-false on all seven rows. CI
36573222514 and 36573216016 green.

State. 28 commits, HEAD 3349b86. Mine are 74830f9 (+15/-0) and 6f24780 (+9/-3), both to
check_matrix.py; 3349b86 "Update services.html" is another session landing on this branch, which is why
the branch count and the PR head moved under me between measuring and writing. check_matrix.py is 591 lines,
37,465 bytes, 591 CRLF, 0 bare LF, no BOM, 0 non-ASCII, stale_prose() [], blob 425689ee98;
git cat-file -s gives 36,874 and 37,465 - 36,874 = 591, the CRLF count. Progression at 3349b86: 28 branch,
19 tree-contains, 17 distinct blobs, 17 modifying, with b68f089 21/12/11/11, e5a7613 25/16/15/15 and
74830f9 26/17/16/16 as controls in the same run. Harness rc 0, 23 rotation + 8 argument, 0 mismatches.
verify.py rc 0 with no flag and with --source=. CI 36584395747 and 36584405644 on 6f24780 and
36585178465 on 3349b86, all completed/success with steps 4 through 8 green. 0 behind and 28 ahead of
origin/main against a refreshed ls-remote of 58db40b3b.

The recursion again, both labels read off their objects. v18 carries 22, v19 23, v20 29, v21 30, v22 31.
In RETIRED_PROSE order out of the module: [2, 4, 5, 3, 4, 1, 3], [2, 4, 5, 4, 4, 1, 3], [2, 6, 6, 6, 5, 1, 3], [2, 6, 6, 6, 5, 2, 3], [2, 6, 6, 7, 5, 2, 3]. In provenance-table row order out of body
lines 1168-1174: [3, 4, 2, 3, 5, 1, 4], [3, 4, 2, 4, 5, 1, 4], [3, 5, 2, 6, 6, 1, 6], [3, 5, 2, 6, 6, 2, 6], [3, 5, 2, 7, 6, 2, 6]. Table positions [7, 5, 1, 4, 3, 6, 2]. Question-mark characters, counted by the same run:
v18 [1372], v19 [1377], v20 [1377], v21 [1377], v22 [1377] -- one each, always at L1377, which is the only place the body writes
the character. This paragraph is generated by the same run that measures it, and the build asserts both that
generating it did not change the retired-phrase census it reports and that the paragraph itself adds no
question mark, so the count above is true of the artifact it is printed in. Neither assert can catch a bug in
the counting expression itself, which is what round twenty-three is for: the sentence attached to a figure
needs the expression that produced it, and an expression needs a test against a known answer. The position
finder here was tested against v18, whose answer was already known, before any number in this section was
written.

Still open, still not mine: 75C37361 needs a human eye, the confidence / orderConfidence split needs
Patrick, twenty-three rounds. The four-angle OCR artifact stays a separate PR.

The catalog rendered in arbitrary GUID order because build_reports.py
emitted master-data.json's documents without sorting them. Ashley has
since organised the images by hand into numbered folders, so the catalog
now follows that organisation.

Each of Ashley's 29 folders becomes one document whose images are its
pages, in sequence: 29 documents / 167 pages, labelled DOC-001..DOC-029
in folder order. Pages she did not file are kept, in a clearly labelled
"Not filed" group at the end with a per-page reason.

Reconciliation is keyed on content hash, not filename, because Ashley
re-scanned six pages under the same GUID filename with different bytes.
For those, her higher-resolution file is the image and the repo's folder
position is the order. Her four brand-new arbitration-procedure scans are
added as martha11-pg1/3/4/6.jpeg, with her original filenames preserved in
each page's sourceFilename; they supersede four lower-quality repo scans.

Because Ashley's folders are thematic, pages of a single prior document
sometimes land in different folders; every page records previousDocId and
every document records previousDocIds so the earlier ids stay searchable.

Page order is only asserted where there is evidence for it. Each document
carries an orderBasis (explicit, internal-pagination, content-sequence,
editorial-alphabetical, editorial-chronological, or not-asserted) shown as
a badge in both reports.

Rotations were re-derived against the images actually shipped: five
re-scans are upright so their -90 entries are dropped, and C04781D1 needs
+90 because Ashley's re-scan is landscape where the repo copy was not.

The curated decisions and the tooling that applied them are committed for
auditability: order_table.py (page order per folder), titles.py (titles,
dates, not-filed reasons), build_master.py (the one-shot migration) and
verify.py (re-runnable checks).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
cubap added a commit that referenced this pull request Sep 28, 2026
Follow-up on the review of #20.

Page-level orderBasis was a uniform copy of the folder value, so all 167 pages
matched their folder and the field carried no information - while the notes for
DOC-007, DOC-016 and DOC-024 said the opposite ("their sequence is ours", "the
handwritten notes page is placed last"). Make the data say what the prose already
does:

  - order_table.py gains optional page_basis overrides for the partly-evidenced
    folders: DOC-007 p3-14, DOC-016 p1, DOC-024 p4 are not-asserted. 14 pages now
    differ from their folder label.
  - Folder orderBasis stays the declared label; the order claim moves to its own
    orderConfidence field, derived from the weakest page: high (2 docs),
    mixed (3), medium (24).

While adding orderConfidence, a second defect surfaced that the review missed.
build_master.py had been overwriting `confidence`, which this schema has always
used for transcription reliability (martha-schema.md: illegible pages are rated
"low"). The 148 -> 29 folder collapse recomputed it from orderBasis and silently
deleted all 21 low ratings. `confidence` is now the transcription rating again,
inherited per document from its least legible page (high 10 / medium 13 / low 6),
and each page carries transcriptionConfidence.

Also:
  - DOC-007's note said "the remaining ten" response sheets; the folder has twelve
    (ten complete, two torn). DOC-016 and DOC-024 notes now name the editorial
    placement as ours.
  - build_master.py refuses to run unless every declared re-scan classifies as
    class C. Replaying against an already-migrated images/ silently folded the six
    re-scans into class A and dropped their sourceFilename provenance; that is now
    a hard failure instead of a quiet one.
  - verify.py asserts the per-page exceptions, derives both confidence fields and
    compares, fails if every page echoes its folder, and checks the low-confidence
    transcriptions survived.
  - .github/workflows/martha-catalog-check.yml runs verify.py on changes under
    review/martha/** and fails if the committed reports are stale. Nothing ran
    verify.py before; it is the only re-runnable check on a 167-page catalog.
  - build_reports.py writes LF so the reports are byte-stable across platforms.

Reports regenerated. verify.py passes with and without --source; the 10 copied
images are byte-identical to Ashley's originals, and 18 pages carry sourceFilename.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@cubap
cubap force-pushed the cubap-martha-documents-reorder branch from b5029fa to f85bd7d Compare September 28, 2026 19:36
Follow-up on the review of #20.

Page-level orderBasis was a uniform copy of the folder value, so all 167 pages
matched their folder and the field carried no information - while the notes for
DOC-007, DOC-016 and DOC-024 said the opposite ("their sequence is ours", "the
handwritten notes page is placed last"). Make the data say what the prose already
does:

  - order_table.py gains optional page_basis overrides for the partly-evidenced
    folders: DOC-007 p3-14, DOC-016 p1, DOC-024 p4 are not-asserted. 14 pages now
    differ from their folder label.
  - Folder orderBasis stays the declared label; the order claim moves to its own
    orderConfidence field, derived from the weakest page: high (2 docs),
    mixed (3), medium (24).

While adding orderConfidence, a second defect surfaced that the review missed.
build_master.py had been overwriting `confidence`, which this schema has always
used for transcription reliability (martha-schema.md: illegible pages are rated
"low"). The 148 -> 29 folder collapse recomputed it from orderBasis and silently
deleted all 21 low ratings. `confidence` is now the transcription rating again,
inherited per document from its least legible page (high 10 / medium 13 / low 6),
and each page carries transcriptionConfidence.

Also:
  - DOC-007's note said "the remaining ten" response sheets; the folder has twelve
    (ten complete, two torn). DOC-016 and DOC-024 notes now name the editorial
    placement as ours.
  - build_master.py refuses to run unless every declared re-scan classifies as
    class C. Replaying against an already-migrated images/ silently folded the six
    re-scans into class A and dropped their sourceFilename provenance; that is now
    a hard failure instead of a quiet one.
  - verify.py asserts the per-page exceptions, derives both confidence fields and
    compares, fails if every page echoes its folder, and checks the low-confidence
    transcriptions survived.
  - .github/workflows/martha-catalog-check.yml runs verify.py on changes under
    review/martha/** and fails if the committed reports are stale. Nothing ran
    verify.py before; it is the only re-runnable check on a 167-page catalog.
  - build_reports.py writes LF so the reports are byte-stable across platforms.

Reports regenerated. verify.py passes with and without --source; the 10 copied
images are byte-identical to Ashley's originals, and 18 pages carry sourceFilename.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

The new workflow caught a leak of its own on the first run: build_master.py wrote
the absolute --source path into master-data.json, and os.path.basename does not
split on backslashes, so a Linux rebuild rendered 'U:\Downloads\...' into the
reports. sourceFolder now stores the folder's display name, build_reports.py
splits on either separator, and verify.py rejects absolute paths in the data and
the generated HTML. Rendered output is byte-identical.
@cubap
cubap force-pushed the cubap-martha-documents-reorder branch from f85bd7d to f8c3f13 Compare September 28, 2026 19:40
cubap and others added 25 commits September 28, 2026 15:33
Reorganising the catalog to one folder = one document cut the audit page's
thumbnails from 148 (one per old document) to 29 (one per folder-document),
so 138 pages had no visual entry point. Each card now carries a strip of
every page in the document, and each thumbnail opens the lightbox directly
on its own page rather than on page 1.

- martha-review.js: data-open-page selects the starting page; absent or
  unmatched it falls back to index 0, so existing triggers are unchanged.
- build_reports.py: page_strip() renders the thumbs outside the hero button
  (nested buttons are invalid HTML). Rotated pages reuse the hero's
  box-swap + inline-rotate pattern. Partial/blank pages are desaturated.
  A right-edge fade appears only while thumbs remain off to the right.
- verify.py: check 11 asserts every image src in the generated HTML lives
  under review/martha/images/ (so it resolves against the site-wide
  <base href="/settling/">), exists on disk, and that all 167 pages have a
  thumbnail.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
review/martha.html transcribes all 167 pages and showed none of them. It has
zero <img> tags on main and on every commit of this branch; the only route to
an image was the "View images" button in the document header. The audit surface
got thumbnails in 642fe29, and Patrick asked for the reading report to be fixed
here rather than in a separate PR.

The reading report is already laid out page by page, so the image goes next to
the text it came from: a 46px figure at the head of each page block, opening
the lightbox on that page. That is 167 figures, one per page, rather than a
second strip.

Rotated pages needed real geometry rather than the hero's trick. The hero sets
width:133.33%; height:100% on the image, which only works because its container
has a height of its own. Inside a thumbnail button the image is what gives the
button its height, so height:100% resolves against nothing and every rotated
figure collapsed to 1px. Both the figure and the audit strip now use a fixed
box with the image absolutely centred in it, width and height exchanged, and
translate(-50%, -50%) ahead of the rotate so the swapped image sits dead centre.
Measured in the browser: the 14 rotated figures on the reading report and the 14
rotated thumbs on the audit page are all exactly box-sized, offset 0,0, none
clipped, and no image on either page is shorter than 43px. The hero is untouched
(0 hero lines in the diff) and keeps its own inline rotate.

verify.py check 11 now asserts per-page image coverage of martha.html as well as
martha-audit.html, so a future edit cannot make the reading report text-only
again without failing.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ntradict it

A reviewer blocked the merge on two suspected ROTATIONS errors. Re-deriving the
orientation of all 167 catalog images from the pixels found three wrong entries, one
missing entry, and 21 stale rotation markers in the prose, 10 of which now describe an
image that is no longer shipped.

ROTATIONS

The previous header claimed the values came from "visual inspection of each image".
That was not recoverable, and it was not true of these entries. Each value is now set
by OCR at all four angles (easyocr, CPU), scored two ways: detection volume, and --
decisively -- how many recovered words appear in that page's own transcript. A wrong
angle recovers fluent junk; the right angle recovers the page's real text.

  2E37B36F  DOC-009 p8  absent -> -90   67 words at mean conf 0.93 at 270, 0 words at
                                        0. Its seven sibling pages are all -90.
  A224D886  DOC-008 p1   +90  -> -90    92 transcript matches at 270, 0 at 90.
  CDA2FC15  DOC-029 p1   +90  -> -90    246 words / 23 matches at 270, 12 / 0 at 90.
  C04781D1  DOC-024 p4   +90  -> +90    UNVERIFIED (handwriting, no signal at any angle)
  75C37361  DOC-029 p4   -90  -> -90    UNVERIFIED (page folded against a table edge)

The reviewer's shape heuristic was right that CDA2FC15 was wrong but wrong about why:
the file is portrait, yet the page inside it is sideways. Rotating a portrait file is
legitimate, so "warn on portrait-with-rotation" would flag a correct entry. The two
UNVERIFIED values keep their previous signs because nothing can beat them, not because
anything checked them; they need a human eye.

TRANSCRIPTS

21 pages carry a bracketed rotation note. 11 match the table. 10 do not, and all 10 are
fossils -- true of the photograph that existed when written, false of the image now
shipped:

  A (5)  Ashley re-scanned but kept the original GUID filename, so the bytes changed
         while the name did not. Proven: shipped copies are portrait where
         origin/main's were landscape, and they OCR clean at 0 degrees.
  B (4)  Ashley's human-named re-scans, absent from origin/main, all confirmed upright.
  C (1)  0250A525 DOC-017 p1 is byte-identical to origin/main and was never replaced,
         yet its marker claims rotation. 5 transcript words at 0 degrees, exactly 0 at
         the other three angles. That marker was false the day it was written.

The rotation decision was recorded in two places with nothing reconciling them. It is
now recorded once: transcript_notes.py holds the replacements, build_master.py applies
them, and the committed artifact was patched from the same table so the two cannot
drift. batches/*.json are left untouched -- they are the record of what was transcribed.

This also vindicates the rotations.py comment about the five class-A removals, which I
had suspected was false. My doubt came from mis-reading sourceFilename == imageFile as
"not a re-scan"; it only means the name did not change.

verify.py check 12

Four parts, all pure data so they run in CI:
  (a) transcript asserts rotation => ROTATIONS entry exists. Hard fail.
  (b) no key may match more than one image. Hard fail -- martha11-pg1/3/4/6 share the
      prefix "martha11", so one key would silently rotate four pages.
  (c) EXIF orientation must be neutral, else the CSS rotation double-applies.
  (d) file shape vs rotation is printed for a human, not failed.

(a) and (b) were both confirmed to fire by deleting an entry and adding an ambiguous
key. (c) uses a stdlib JPEG/EXIF parser rather than an image library; it was
cross-checked against PIL on all 171 files with zero disagreements, and all 171
genuinely carry an Exif block, so it is not passing by falling through to a default.

OCR is minutes per pass on CPU and cannot run in CI, which is why the shipped check
tests the invariants that are pure data instead.

Catalog data is otherwise untouched: 29 docs, 167 pages, the confidence /
orderConfidence split and the 14 per-page overrides all stand.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
C04781D1 (DOC-024 p4, Hamilton's handwritten notes) carried +90, which the
migration added because Ashley's re-scan is a landscape file. That inference is
the one this branch already disproved on CDA2FC15 from the opposite direction:
file shape does not tell you which way the page is pointing.

Re-measured both unverified entries by OCR at all four angles, scoring recovered
words against each page's own transcript:

  C04781D1  at the applied 90 degrees: 0 characters recovered, nothing.
            at 0: 64 chars / 6 words / 2 matches to its own transcript, the only
            angle that returns anything. An independent projection test scores it
            strongly horizontal. Two unrelated methods, same direction.
  75C37361  0-5 chars at every angle, including its own. 0 degrees marginally
            beats the applied 270 (3 chars vs 0) with no transcript match, which
            is noise. Left alone. This is the genuinely unresolved one.

So C04781D1's entry is removed rather than set to 0: check 12(b) treats a
zero-valued key as dead, and 12(a) treats it as absent, which is the point - the
guard couples the table to the prose. The transcript note that asserted the
rotation is corrected in transcript_notes.py, and reverting just that note makes
verify.py fail with "transcript claims a rotated image but ROTATIONS has no
entry". Confirmed by negative test.

C04781D1 is a photograph of two loose handwritten sheets plus the edge of a
typed page, so no single rotation is really correct for it, which is why every
angle reads weak. That is recorded rather than papered over.

Also drops the hardcoded "C04781D1 rotation should be +90" assertion from check
5. It was a snapshot of a decision, not an invariant, and it broke the moment the
decision did. Replaced with a real invariant: every rotation is a right angle.

Marker census now 10 pages, 10 agree, 0 disagree, no dead keys. 14 rotations
applied. verify.py green with and without --source; the 10 copied images still
match Ashley's files byte-for-byte.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Round seven found two real problems in d6ee93a and one overstatement. Both
problems were found by testing rather than reading, which is the point.

1. My justification for deleting C04781D1 rather than zeroing it was invented.

I wrote that check 12(b) treats a zero-valued key as dead. It does not. 12(b)
fails only on ambiguous keys and keys matching no catalog page. "5C74254C": 0 is
a deliberate zero-valued entry for a real page sitting in the same dict, with CI
green, and I had just read that dict when I wrote the comment. Re-adding
"C04781D1": 0 passes. Deletion is tidier; it was never forced.

Corrected in the comment, which now says what is actually true: the 12(a) half of
the original sentence holds (.get(prefix, 0) or 0 is falsy), and what makes the
removal safe is the new check, not 12(b).

2. The removal was protected by nothing.

Re-adding "C04781D1": 90 passed verify.py with exit 0, and build_reports.py then
shipped class="page-fig-rotated" on the page. Structurally: 12(a) enforces
transcript => rotation but never rotation => justification, and 12(d) is
print-only and actively hides this case, because a landscape file WITH a rotation
is on the diagonal of the shape table. The check meant to catch shape-driven
rotations was blind to exactly the regression it was written for.

Confirmed by mutation before and after:

  clean                      rotation basis: 14 applied, 14 recorded -> PASS
  re-add C04781D1: 90        FAIL (no basis recorded)
  re-add C04781D1: 0         PASS (zero-valued, applies nothing)
  basis kind "vibes"         FAIL (unrecognised basis)
  basis key with no rotation FAIL (justification outlived its decision)

Fix is ROTATION_BASIS in rotations.py plus checks 12(e)/(f) in verify.py: every
non-zero entry must record a basis starting "ocr" or "unverified", and every
recorded basis must still correspond to an applied rotation. The basis is data,
not a comment, so it is checkable. 12(f) keeps justifications from going stale.

75C37361 is now machine-visible as the one unverified entry rather than being
findable only by reading a comment.

3. "Literally ZERO OCR detections. Not weak - nothing" was wrong.

Re-measured C04781D1 at all four angles, bracketed transcript notes excluded from
the match set:

  CSS   0: raw=52 kept=14 chars=82 words=7 matches=1 volume=38.4
  CSS  90: raw=14 kept= 4 chars= 6 words=0 matches=0 volume= 3.3
  CSS 180: raw=22 kept= 4 chars=14 words=0 matches=0 volume= 6.7
  CSS 270: raw=20 kept=12 chars=14 words=0 matches=0 volume= 7.4

At the applied +90 the instrument reported 14 raw detections and 6 characters,
not nothing. The reviewer's independent run said 12 raw / 5 chars / volume 2.2,
which agrees to within easyocr run-to-run variation. The conclusion survives
comfortably: 0 degrees is ~12x the volume and the only angle with a transcript
match. The wording did not, and a confidently-worded wrong number is the
dangerous kind. Corrected here and to be corrected in the PR body.

Catalog data, build_master.py, order_table.py, titles.py, transcript_notes.py and
martha-review.js untouched. Reports regenerate byte-identical, so no shipped page
changes. verify.py green with and without --source (10 copied images
byte-verified).

Still Patrick's call to merge.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Round eight. All four reported defects reproduced by test before fixing, and the
reproduction of each is in the matrix below.

12(g): the unverified count was a print, not an assertion
--------------------------------------------------------
Confirmed: "C04781D1": 90 with basis "unverified: trust me" passed with exit 0, and
downgrading all thirteen ocr bases to unverified passed, logging "14 applied, 14
recorded, 14 unverified". So the claim that 75C37361 is now machine-visible as the
single entry awaiting a human eye was false. It was findable by reading the log line.

Pinned the unverified SET by exact identity, which is check 9's existing pattern at
L177 ([x for x in docs if x["orderConfidence"] == "high"] != ["DOC-005", "DOC-011"]).
Adopting the label now fails; settling another page requires recording its measurement.

12(e): the credential was the word "ocr"
----------------------------------------
Confirmed: "C04781D1": 90 with a bare "ocr" basis passed. Ten of the fourteen bases
were bare "ocr" with no measurement behind them, so three characters could restore the
exact rotation this branch spent two rounds disproving.

Two-part fix:
  - All ten were re-measured, four angles each, for this commit. Every one won at CSS
    270 (table -90) with zero transcript matches at any other angle, so the numbers now
    in the table are from this run rather than remembered. Matches at 270 range 27-151
    against volume 320-1732 versus 69-364 for the best other angle.
  - An "ocr" basis with no digit in it is rejected. A method word is not a basis.

What this does NOT close, stated plainly because the reviewer asked for exactly that:
"C04781D1": 90 with basis "ocr: 999 matches" still passes. Nothing cheap can reject a
plausible fabricated number short of committing the four-angle OCR output as an artifact
and asserting the recorded figures appear in it. This is a required attestation, not a
verification. The gain over silence is that an attestation is diffable and can be
contradicted by the per-entry comment; the gain over a bare "ocr" is that a number has
to be chosen.

Check 6: two real defects, plus a third the reproduction run turned up
----------------------------------------------------------------------
(a) src is None was unguarded, so md5(None) raised TypeError. Order-dependent and the
    worst kind: the four martha11 pairs are compared first, so one missing martha11 file
    aborted the whole check before any of the six GUID comparisons that would have
    passed. Now names the file and asks whether --source is the (ordered) folder.
(b) "verified 10 copied images byte-for-byte" printed unconditionally, so it appeared on
    the line directly under "FAIL bytes differ". Now gated on byte_ok.
(c) Found while testing (a), and the same class of bug. --source only accepted the =
    form. `--source <path>` fell through to the SKIP branch and exited 0, so the byte
    check reported as skipped in a way that read like an ordinary no-flag run when the
    path was simply parsed as a stray argument. A nonexistent path also SKIPped with
    exit 0 rather than erroring. Given Ashley keeps "Martha M. Hamilton Documents" and
    "Martha M. Hamilton Documents (ordered)" side by side, and the former is the wrong
    one, a bad path is the realistic case. Unrecognised arguments are now rejected too
    rather than swallowed. Exit 2 for all three.

Matrix, all runs measured:
  no flag                      rc=0  SKIP
  --source= correct            rc=0  verified 10 copied images
  --source correct (space)     rc=0  verified 10 copied images
  --source= wrong folder       rc=1  4x not found + 6x bytes differ, no traceback
  --source nonexistent         rc=2  not a directory
  --source with no value       rc=2  needs a path
  typo flag                    rc=2  unrecognised argument
  clean rotations              PASS  14 applied, 14 recorded, 1 unverified: 75C37361
  +90, no basis                FAIL  12(e)
  +90, bare "ocr"              FAIL  digit rule
  +90, "ocr: 999 matches"      PASS  the honest limit, see above
  +90, "unverified: trust me"  FAIL  12(g)
  all bases downgraded         FAIL  12(g)
  basis, no rotation           FAIL  12(f)
  bogus basis kind             FAIL  12(e)
  ocr basis, digit stripped    FAIL  digit rule

A harness bug worth recording, because it produced two wrong results before I caught it:
the first version of the mutation matrix anchored its basis insertion on "020E073B",
which is the first key of BOTH dicts, so it silently mutated ROTATIONS instead of
ROTATION_BASIS. Three cases reported the same "records no reason" failure for the wrong
reason and one crashed check 5 by putting a string in ROTATIONS. The product was fine;
the test was not, and the output looked plausible enough to ship. Anchored on the
dict header and asserted the insertion landed inside ROTATION_BASIS.

Reports rebuild content-identical (ROTATION_BASIS is not read by build_reports.py).
verify.py green with and without --source.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Round nine. The reviewer found a property this guard already had and which I had never
tested, plus two places where "contains a digit" was the wrong bar.

THE PROPERTY I HAD AND DID NOT CLAIM
------------------------------------
I predicted -- and the reviewer accepted -- that faking "75C37361": "ocr: 40 matches at
270" would pass because the number is self-attested. It does not pass. The forgery
empties the pinned unverified set, so 12(g) fires:

  FAIL the unverified-rotation set changed; 75C37361 is the only entry awaiting a human
  eye, and settling another one needs its measurement recorded, not the label
  rotation basis: 14 applied, 14 recorded, 0 unverified

So the one page that genuinely needs a human eye cannot be resolved by typing a
measurement into the table. Settling it costs a measurement AND an edit to the pinned
list, which is a visible deliberate change. That is the strongest property of the guard
and it was missing from the matrix. Now case "75C37361 faked to ocr".

TWO RULES TIGHTENED
-------------------
1. An "ocr" basis must contain a measurement, not merely a digit. "ocr: 9",
   "ocr: see note 1" and "ocr: settled in round 8" all passed the digit rule; they are
   citation shapes, not results. MEASUREMENT_RE requires "<n> matches" or "volume <n>".

2. An "ocr" basis must name the angle that won, and that angle must be the rotation
   actually applied (CSS_NAME_FOR_TABLE_ANGLE maps table -90 to {270, -90} because the
   table stores PIL arguments and the prose cites CSS). A basis citing 90 on a -90 entry
   now fails, as does one citing no angle at all.

   This is currently vacuous and is offered as future-proofing, not as a live fix: all
   fourteen applied rotations are -90 and all thirteen ocr bases cite 270, zero
   contradictions, which I audited rather than assumed. It matters because the table is
   one value, so the basis prose is the only thing distinguishing these entries from each
   other, and prose can be pasted onto the wrong row.

--source= WITH AN EMPTY VALUE
-----------------------------
Still SKIPs at exit 0. Kept deliberately and now commented: an explicit empty flag reads
as an intentional opt-out, and SKIP is printed so the run never claims a comparison it did
not make. A non-empty bad path is the dangerous case and exits 2.

12(a) IS LOAD-BEARING IN A WAY I HAD NOT CREDITED
-------------------------------------------------
Deleting BOTH a rotation and its basis does not pass. It fails with "DOC-009 p5 transcript
claims a rotated image but ROTATIONS has no entry". The transcript coupling from round six
is what protects against silent deletion -- which is the thing the round-six hardcoded
check 5 was only pretending to do.

MATRIX: 18 ROTATION PATHS + 7 ARGUMENT PATHS, ZERO MISMATCHES
  clean PASS 14/14/1 unverified: 75C37361
  +90 no basis FAIL 12(e)          +90 bare "ocr" FAIL measurement
  basis, no rotation FAIL 12(f)    bogus kind FAIL 12(e)
  "ocr: 9" FAIL                    "ocr: see note 1" FAIL
  "ocr: settled in round 8" FAIL   cites no angle FAIL 12(e)
  cites 90, applied -90 FAIL 12(e) cites 270, applied -90 PASS
  75C37361 faked to ocr FAIL 12(g) 75C37361 bare "unverified" PASS
  all ocr downgraded FAIL 12(g)     +90 "unverified: trust me" FAIL 12(g)
  +90 "ocr: 999 matches at 90" PASS  "ocr: 999 matches at 270" PASS   <- the ceiling

The ceiling is unchanged and is restated in the rotations.py comment rather than hidden:
a specific, plausible, self-attested measurement still passes. The guard makes a false
claim expensive, diffable and contradictable by the per-entry comment. It does not make it
impossible.

The set_basis helper asserts its substitution landed inside ROTATION_BASIS, because the
previous version of this harness anchored on a key that is first in BOTH dicts and
silently mutated the wrong one.

Reports rebuild content-identical. verify.py green with and without --source. Catalog
data, build_master.py, order_table.py, titles.py and martha-review.js untouched.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Round ten found two defects in check 12(e), one of them in a comment.

The comment said the table holds PIL rotate() arguments, so table -90 and CSS 270
agree by the PIL/CW-vs-CCW flip. It does not. build_reports.py drops the value
straight into `transform: rotate(<n>deg)` and PIL appears nowhere in the repo;
rotations.py L3 has said "CSS rotation in degrees" since round five. Two comments in
one commit made opposite claims about the same number and reached the same conclusion,
which is the round-six lesson recurring: a wrong mechanism a reader could "fix" into a
broken build. Inverting the map to the reading the comment described fails 11 of the
13 shipped ocr bases.

The lookup table the comment described was also a crash. Check 5 accepts any multiple
of 90, so 270 and -270 are legal values, and both hit a bare dict subscript and died
with a KeyError traceback and no verdict. 270 is exactly the value someone would type,
because the prose cites 270 while the table stores -90.

Replace the table with congruence mod 360, which is what it was approximating: -90,
270 and 540 are one CSS rotation, so any spelling of the applied turn is accepted and
there is no domain left to miss. Document that angle matching is intersection on
purpose, since honest prose cites losing angles too, and record the residual weakness.

Add review/martha/check_matrix.py: 31 behavioural cases run by CI. Through nine rounds
the checks guarding the rotation table were themselves unverified, and twice that
mattered -- restoring a disproved rotation passed clean, and 12(d) actively hid it by
moving the off-diagonal count from 2 to 1. It copies review/ to a temp directory and
mutates only the copy, so it is safe beside a dirty checkout. Two rules the branch paid
for are encoded in it: assert where an edit landed ("020E073B" is the first key of both
dicts), and assert the run did not raise, since a traceback is a broken harness rather
than a guard firing.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Cosmetic only. The block explaining why the table holds CSS degrees ended up below
the function definition rather than above it. Not amended into 966700e so the commit
the reviewer has a worktree at stays valid.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Round eleven found that check_matrix.py could import the previous case's rotations.py.
CPython validates a .pyc from the source mtime truncated to whole seconds plus its byte
length, so two same-length mutations in the same second reuse the cache. The harness runs
a case every ~0.7s, so adjacent pairs land together routinely.

Reproduced: set_rot to 270 and to 360 both write a 10,094-byte rotations.py. 270 is
congruent to -90 (PASS); 360 is congruent to 0 (must FAIL). Run immediately after, the
360 case reported PASS. With PYTHONDONTWRITEBYTECODE=1, zero stale of six.

In the shipped matrix exactly one adjacent pair shares a length, cases 17 and 18, and both
expect PASS, so no verdict printed today is wrong. That is precisely why it survives a
green run, and why the fix is not cosmetic.

  * run() sets PYTHONDONTWRITEBYTECODE, and main() now fails the whole run if any
    __pycache__ appears in the sandbox, so the defence is asserted rather than assumed.
  * Each case carries the substring the FAIL line must contain. A verdict alone cannot
    tell a stale import from a real failure. This immediately exposed that "rot=45"
    expects FAIL and gets FAIL from check 5, not from the angle cross-check it is named
    for; relabelled, and the message now pins the guard that actually fires.
  * verify.py's comment claimed -90, 270 and 540 are one rotation. 540 mod 360 is 180.
    Corrected to -90, 270, 630, -450, with a note so the same slip is visible.

Negative tests: a wrong expected message reports WRONG GUARD and exits 1; removing the env
fix reports the cache and exits 1. verify.py green with and without --source. 31 cases,
zero mismatches. Reports rebuild content-identical.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Round twelve: verify.py's measurement failure interpolated only the page id, so the four
matrix cases that mutate that same page printed byte-identical FAIL lines. Measured: 1
distinct message across 4 cases. A stale import on any of them produced the right message
for the wrong reason.

- verify.py: quote the rejected basis back, as the sibling unrecognised-basis branch
  already does. The five measurement cases now produce five distinct outputs.
- check_matrix.py: give those four cases the message each one actually writes, so the
  assertion can tell them apart.
- check_matrix.py: move the bytecode-cache walk inside both loops. PASS cases have no
  message to match, so for the one adjacent equal-length pair that exists today (17 and
  18, both PASS, both 10,057 bytes) the per-case cache check is the only defence.
- check_matrix.py: record that the case ordering is load-bearing, and rename the final
  run's variable so it does not read as a per-case verdict.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The DO NOT REORDER comment here named rot=360 as the hazard if the case list is
ever sorted. That is false, and I measured it: rot=360 expects FAIL and carries a
want, so it differs from every equal-length neighbour by verdict or by message, and
it is confusable with nothing. The genuinely blind cases are the three PASS cases at
10,057 bytes, which have no failure line to match, and two of them were already
adjacent. So the file shipped a wrong reason attached to a right instruction, which is
the failure mode this branch has been chasing since round six.

Replace the prose with blind_adjacent(), which computes the predicate from the
mutations themselves and refuses the run if any adjacent pair is indistinguishable.
It then flags the live 17->18 collision, so the two rot=270 aliasing cases are now
separated by the rot=-270 FAIL case.

Three related fixes, all measured rather than assumed:

  * verify.py 12(g) now prints the set it found as well as the one it expected. The
    three unverified-set cases failed on one fixed sentence, so no want string could
    tell them apart. 16 FAIL cases now produce 16 distinct lines, up from 14.
  * The measurement wants name the page, not just the value. Case 3 fires on
    C04781D1 and cases 6-9 on 04894C59, which the message already said.
  * The per-case cache check purged nothing, so it answered "has a cache appeared
    yet" rather than "did this case write one", and reported the first case as stale.
    main() now clears bytecode before each run, which makes a stale import impossible
    by construction and attributes any cache found to the case that wrote it. The
    aggregate walk moved to the end, after a purge, so it tests the mechanism instead
    of repeating the per-case result.

STALE no longer overwrites WRONG GUARD or FALSE CLAIM; both are printed. The
docstring said "three rules" above four bullets and now says five.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The conservatism claim in blind_adjacent()'s docstring had its direction
inverted. The harness asserts `want in fired` for every FAIL case, so i's want
is a subset of what i prints by construction; requiring j's want to be inside
i's want can therefore only ever HIDE a blind pair, never invent one. Measured:
on a file carrying two same-length, same-verdict mutations of 2E37B36F whose
messages differ only in "at 270" vs "at 271", the substring rule flags 0 pairs
and the run goes green while the second case never executes its own mutation.
Dropping the substring test flags 1. On the shipped file both flag 0, so the
stricter predicate costs nothing today and is strictly better later.

ORDER MATTERS now states an asymmetry that was measured rather than assumed.
PYTHONDONTWRITEBYTECODE is the defence that actually prevents the write, and
the end-of-run walk watches it. purge_caches() is what makes the per-case cache
check attributable, and deleting it is invisible to every assertion in the file
(rc 0, nothing flags). The ordering is the third defence, not decoration: 12
replays of the one adjacent equal-length pair with both defences off produced 3
same-second collisions, and all 3 ran the previous case's module, so rot=360
reported PASS when it must FAIL. The shipped ordering makes that a visible
verdict mismatch; the pre-reorder ordering made it silent.

Bullet 5 of the module docstring still recited the substring reasoning the
function no longer uses. And "+90, bare 'ocr'" was the only column-0 tuple
entry in the file.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Round fifteen found that the docstring rewrite reported last round had not landed: a
background probe snapshotted check_matrix.py at start and restored that snapshot on
completion, silently reverting a concurrent edit. The module docstring is byte-identical
between afeeb5b and a95b48d. Root cause accepted; every probe in this round ran against
in-memory module objects or sandbox copies instead.

What the text now says, each figure re-measured:

* Bullet 4: the per-case purge is not a defence for the PASS cases. The only defence is
  that no stale import can happen at all.
* Bullet 5: drops two false claims. rot=360 is not safe "by message" (that was the
  substring inversion), and the three PASS cases at 10,057 bytes are not a blind class
  under an adjacency rule that flags no pair at HEAD.
* blind_adjacent(): the predicate census reported the wrong row. Verdict-only flags 12
  adjacent pairs, not 1; the 1 belongs to the old disjunctive predicate with the length
  clause dropped (the 22/23 ceiling pair). Each surviving clause is load-bearing at a
  different edge: drop the length test and 12 pairs flag, drop the verdict test and 1
  does (19/20, both 10,057 bytes, one PASS and one FAIL).
* run(): the colliding pair is cases 19 and 20, not "the two rot=270 cases", which have
  case 18 between them and cannot collide.
* ORDER MATTERS: the same-second rate is 10 of 24 here and 9 of 24 on the machine that
  checked it, not "most of the time". The "0 wrong verdicts in 12 runs" reassurance is
  withdrawn -- that instrument's sandbox resolved REPO to the wrong parent and died
  before the import in question, so it could not have produced one. Re-run correctly,
  this machine gives 0 of 6 and the other gives 2 of 6, so the rate is not a property of
  the check. The ordering buys a loud failure, not a low rate.

No behavioural change: blind_adjacent() still flags 0 pairs, the matrix is 23 + 8 cases
with zero mismatches and exit 0, verify.py is green with no flag and with both --source
forms against the ordered folder, and the rebuilt reports are content-identical.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… comment

The previous commit's new paragraph said "an earlier revision of this comment offered a
full-harness number as reassurance". It did not. The 0-wrong-verdicts-in-12-runs figure
appeared in the PR body and in the reply to the reviewer, never in check_matrix.py at
afeeb5b or a95b48d -- verified by grepping both blobs for "full-harness", "12 runs" and
"wrong verdicts", which return nothing. The comment therefore described a state the file
never had, which is the defect this branch is meant to be closing.

The paragraph now states the measurement without the false history: 0 wrong verdicts in 6
full-harness replays here, 2 in 6 on the machine that ran the same probe, and the first
instrument was itself broken. The rate is a property of load, not of the check.

Behaviour unchanged: matrix 23 + 8, zero mismatches, exit 0; verify.py green.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Round sixteen found two, and applying their rule (grep a repeated sentence for every
instance before reporting a fix) found a third.

1. The rot=360 reason existed in two places and last round I fixed one. The module
   docstring said "its only equal-length neighbour"; ORDER MATTERS still said "both of
   its equal-length neighbours expect PASS". Case 20 has one equal-length neighbour
   (case 19, 10,057); case 21 is 10,056 and the rule cannot pair them at all. The
   conclusion was always right, only the reason was wrong -- the exact defect the
   correction was written to remove. git grep now returns the corrected phrase twice.

2. The withdrawal paragraph said the first instrument "reported a clean 12 runs and was
   itself broken, resolving REPO to the wrong parent". The attribution to the sandbox is
   false. A sandbox that dies before the import produces the loudest failure this harness
   has: 23 got=CRASH lines, 28 mismatches, rc 1. The count was 0 for a different and
   worse reason -- probeD kept lines matching both "MISMATCH" and "verdict=", and the
   string "verdict=" appears nowhere in check_matrix.py. The verdict column is printed
   positionally and never labelled, so the filter matched nothing in any run, on any
   machine, in any sandbox. It could not have produced a nonzero number. The paragraph
   now says that, and states the rule: a number is worth exactly what its filter can
   observe.

3. The aliasing block claimed "all three PASS cases in this file are 10,057 bytes". There
   are seven PASS cases at five distinct lengths; three of them are 10,057 (17, 19, 23).
   Rewritten to name the three and say what the guard actually refuses.

Their root cause for last round's invented history is confirmed by the blobs: "twelve
replays" appears once, in a95b48d at L139, inside this same ORDER MATTERS block, seven
lines above where the replacement landed. "twelve runs", "full-harness" and "reassurance"
appear only in ee7d3f8. The number twelve was real and was in this comment; the fusion
with a PR-body claim inverted it, since those twelve replays reported 3 collisions, not
zero wrong verdicts.

No behavioural change: blind_adjacent() flags 0 pairs, matrix 23 + 8 with zero mismatches
and exit 0, no bytecode written, verify.py green with no flag and with both --source forms
against the ordered folder.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Round seventeen found the rule stated two ways, and the shorter one makes the file illegal.

1. The module docstring said blind_adjacent() enforces "equal byte length and equal expected
   verdict, nothing more". The loop is zip(CASES, CASES[1:]) -- three conditions, not two. Measured
   on the shipped file, length plus verdict without adjacency gives 4 pairs, none of them adjacent:
   9/20 (both FAIL at 10,057, and neither want contains the other) and 17/19, 17/23, 19/23 (all PASS
   at 10,057). A guard written to match the docstring would report four violations and exit 1 before
   running a case. Adjacency is now named in both places, and ORDER MATTERS lists the four pairs the
   rule deliberately ignores, with the reason: only the immediately preceding module was just
   written.

   Confirmed by moving case 9 beside case 20 in memory, nothing else changed: blind_adjacent()
   returns that pair, main() exits 1 with "MISMATCH blind adjacency rot=360, basis cites 270 ->
   'ocr: settled in round 8' at 10057 bytes" and zero verdict lines executed. The old
   length+verdict+substring predicate flags 0 on the same injection, so the docstring's comparison
   of the two predicates holds with two cases that were already in the file rather than a
   constructed pair.

2. "The verdict column is printed positionally, never labelled" is false: it prints got=%-5s, which
   is a label. The true statement, and the one that matters in a sentence about a filter string, is
   that it is never labelled verdict=. Loose wording in a sentence whose subject is an exact string
   is what gets copied next time.

3. Mine, found while checking their addition. The line "the full harness gives 0 wrong verdicts in 6
   replays here" does not reproduce on this machine with a filter that can see. Two back-to-back
   batches of 8 replays, both defences removed, gave 5 and 1 wrong verdicts -- always rot=360
   reporting PASS when it must FAIL. Counted by parsing expect=/got= and cross-checked against the
   harness's own WRONG GUARD note, two independent counters that agreed on every replay. The
   replacement states the spread and names the instrument instead of a point estimate.

4. Same treatment for the collision rate: three independent 24-trial batches on two machines give
   9, 10 and 11 of 24, so the single figure this comment quoted is replaced by the range and an
   explicit note that no value in it should be quoted as "the" rate.

No behavioural change: blind_adjacent() flags 0 pairs, matrix 23 + 8 with zero mismatches and exit
0, no bytecode written, sandbox clean run PASS. verify.py green with no flag and with both --source
forms against the ordered folder, and still failing against the unordered one.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The rule is stated in five places. Round seventeen fixed three of them and left the
body of blind_adjacent() saying "Two facts decide it", two lines under a summary that
names adjacency as the domain and 265 lines under the corrected bullet that points here.
That is the third consecutive round a correction reached some copies of a claim and not
the copy the corrected copy points at.

So the file now checks itself. stale_prose() reads this module's own text and refuses the
run on seven phrasings that were true here once and are false now, underscore-encoded so
the list cannot match itself. Non-vacuity measured, not asserted: it flags 4 hits on
a95b48d, 5 on 09ed2bd and 1 on ab8e7aa, i.e. it would have failed rounds sixteen,
seventeen and eighteen at commit time. Re-adding the round-eighteen phrase gives rc 1 with
0 verdict lines executed. It is a ratchet, not a proof: it can only re-block wording that
has already been wrong, so a first-time error still needs a reader.

Also: the wrong-verdict spread now carries the reviewing machine's 4 and 2 beside this
machine's 5 and 1, four numbers no two equal, and records why the two counters are not one
measurement read twice (msg_ok is `want in fired`; ok additionally requires the verdict and
no cache write, so WRONG GUARD can fire on a line whose verdict column is right).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
stale_prose() audits everyone else's descriptions and nothing audited it. Emptied
RETIRED_PROSE left the shipped harness green: rc 0, 23 verdict lines executed,
"sandbox clean run: PASS". A future edit that drops the list, or a refactor that moves
the call below an early return, produces a passing run. Silent in the dangerous
direction, loud in the safe one -- the dead filter arriving inside the mechanism built
to stop it, one round after we named the class.

main() now proves the filter can see before it builds a sandbox. The probe is built FROM
the retired list rather than written as a literal, because a file containing a retired
phrase in order to test the guard trips the guard on itself. That is not hypothetical:
the first version of this comment did exactly that, and the audit fired on it at L392
before anything else ran.

The control cannot see that the audit stopped running, so a CI step outside this module
reads the list out of the source text, injects one decoded phrase into a copy, and
requires the copy to refuse it. Both instruments were run against each mutation; not one
cell below is inferred:

  mutation                       in-module control  external CI step
  emptied RETIRED_PROSE          fires              detects
  broken decode                  fires              detects
  audit result forced empty      silent             detects
  control computation deleted    silent             silent
  control block deleted          silent             silent

The last two are covered by neither instrument and only show up in a diff. That is
stated in the docstring rather than left as a surprise, because "the guard is guarded"
is exactly the kind of claim that has not survived here.

Also: the ratchet's scope is now written down (this module alone -- the PR body keeps all
seven phrasings, 17 occurrences, deliberately, because there they are a record of what
was said and when it was found false), and the "~0.7s per case" figure is sourced at both
of its homes: 22.02, 22.07 and 22.11s for the 31 verify.py invocations a full run makes,
0.710 to 0.713s each, with 22.09s and 0.712 measured on the reviewing machine.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Round twenty found three stale figures, all of them counts of something outside the module
or of a run the module describes rather than measures.

1. The docstring said the PR body carries "seventeen occurrences in total". It carries 21.
   The 17 was correct for the previous body and was inherited from the reviewing machine
   instead of re-measured. The four extra occurrences are all inside the section that
   reports the count, so the sentence asserting 17 sits below three of them. stale_prose()
   cannot catch this: it is a new phrase, not a retired one, which is the documented limit
   doing exactly what it says. Fixed by removing the count rather than refreshing it. A
   number describing an artifact this module cannot read goes stale every round.

2. "The 31 verify.py invocations a full run makes" is wrong twice. Instrumenting run() and
   calling main() three times gives 32 invocations every time: 23 rotation cases, 8
   argument cases, and the final clean run. And the sentence justifying the choice of 31
   claimed 0.707-0.709 at divisor 32; 22.02/32, 22.07/32 and 22.11/32 are 0.688, 0.690 and
   0.691. No integer divisor produces that range. The justification was a number never
   printed from a calculation, and it pointed the wrong way.

3. Both homes of the timing figure now state the bound instead of three decimals. Six
   batches across two machines give 0.63-0.72s per invocation, all under a second, which is
   the only fact the argument needs. Three runs here spread 0.4% and 2.3%; the reviewing
   machine's three spread 10.6%, so 0.710-0.713 was never a property of the harness.

Harness rc 0, 23 rotation + 8 argument, 0 mismatches, sandbox clean run PASS. verify.py
green. Reports rebuild content-identical. 545 lines, pure CRLF, no BOM, ASCII only.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Round twenty-one found three things, all of them this module's numbers, and
a fourth that only appeared while fixing them.

The docstring rule was too broad and too narrow at once. "No count is stated
here on purpose" sat in a sentence stating three counts, and the generalisation
behind it -- any count of a live artifact is wrong by the time it is published
-- is refuted by the body, which can state a count of frozen v17 forever. The
rule is about tense, not about numbers: a present-tense count of a living
artifact is stale by construction, because the edit reporting the staleness is
an edit to the thing counted. A count pinned to a revision is a fact. Every
figure in the module now names its revision.

The timing comment said "six batches on two machines" for what was six runs in
two batches on one machine plus the reviewing machine's. Corrected to fifteen
runs in five batches, and the wall range widened: the batch that broke the
20-23s bound was run on the machine that had reported 0.4% spread, which is the
argument for pinning it rather than for tightening it. The load-bearing claim
is unchanged and survives all fifteen: under a second per invocation at either
divisor, 31 cases or 32 invocations. blind_adjacent() now carries that claim
without the range and points at run() for the measurement.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Both machines counted the seven retired phrases in the published body, agreed
digit for digit and agreed on the totals -- 21 in v17, 22 in v18 -- and both
then described those digits as being in RETIRED_PROSE order. They are not. In
list order v18 reads 2,4,5,3,4,1,3; the 3,4,2,3,5,1,4 that appears in the body
and in the review notes is the row order of the provenance table further up the
same body.

The counts were never wrong, which is why two rounds of re-measuring did not
catch it. A sum is invariant under permutation, so every check that compares
totals is blind to this class, and neither probe printed the phrase beside its
number. Re-derived here from the live list at `183bb94`: introduced, last-true
and first-false for all seven entries, and the body's provenance table is exact
on all seven rows in both directions.

Recorded as the third clause of the docstring's rule about figures, next to the
noun and the revision.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The timing comment quoted 20.2-23.1s as a span. It is a census of fifteen
runs, and a reader takes the low edge of a span for a floor, which is the
opposite of the bound the paragraph exists to make. Two measurements say the
census already under-describes the harness: a third machine produced
15.29-15.66s at 16bcc31, 4.6-4.9s below the lower edge, and a later batch on
the machine that produced the census produced 23.53-25.69s, entirely above the
upper edge. All eight runs still clear the bound, which is the only timing
claim made here. The census is kept for the per-batch spreads alone.

Three rules added to the docstring, each from a failure that produced
confident wrong output:

  - a tuple's label must be produced by the same object that produced the
    tuple. One machine printed the true list order from the live module and
    published the table order beside the same digits forty-three seconds
    later. Displaying the truth is not checking against it.
  - where two columns have a defined relationship, assert it per row. A
    provenance grid put last-true and first-false on the same commit twice.
    A constant-column check cannot see that, because the columns disagree in
    a way that looks like data.
  - a figure whose subject is anything another machine can change needs a
    fetch, not a re-reading. A remote-tracking ref is an artifact with a
    timestamp.

check_matrix.py only. 570 lines, pure CRLF, ASCII only. stale_prose() returns
[]. Positive control fires: MISMATCH retired prose at L572, rc 1. Emptied-list
control exits with the "ratchet is gone" message. Harness rc 0, 23 rotation
plus 8 argument cases, 0 mismatches, sandbox clean run PASS. verify.py rc 0
with and without --source.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Round twenty-three: the reviewer retracted its Finding 1 after finding the line of
code that produced it. `enumerate(lines[1230:1312], 1231)` is already 1-based and the
loop added another, so every position in a six-row table came out one line high while
the totals, taken by str.count, stayed right. The artifact was correctly identified and
correctly labelled; the arithmetic under the label was wrong.

Adds to the provenance rules: naming the artifact is necessary and not sufficient, so a
figure also needs the expression that produced it. Co-generating a number and its
sentence guards drift between them, not a defect in the thing that produced both. When
the holder of the primary source corrects a figure and my contrary number came from an
untested instrument, the holder wins by default.

Two of my own from re-measuring this round. A probe that counted distinct phrases
present reported 2, 3, 5, 5, 5, 1 where the published instrument, stale_prose(), reports
3, 4, 6, 6, 5, 1, and the gap is one phrase appearing on two lines: both were called
non-vacuity. And `git status --porcelain` is not portable -- a rebuild producing
byte-identical blobs still reports dirty under autocrlf -- so assert with
git diff --exit-code and git hash-object.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Round twenty-three checked the reviewer's retraction by reproducing its root cause on my own v18 body.
The double conversion is real and does shift the four in-window anchors by exactly one line. It is not
the cause of the whole column. An exhaustive sweep of 3,915 single-window (+1) configurations over the
v18 body finds none that yields all seven published values together: the quoted slice cannot reach the
row the finding rests on, and any window that can also shifts a heading the table reports as correct.

I published a sentence claiming the buggy form "prints their table exactly ... and 1327". It does not.
My own reproduction used a widened window for that phrase and a different one for the rest, and I
conflated them. Same defect, one level up, twice running: a mechanism that fits the rows it can reach
gets believed for the rows it cannot.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@cubap
cubap merged commit 0916383 into main Oct 8, 2026
1 check 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.

1 participant