EqualifyEverything / equalify-iris

feat(tables): a declined join logs the two halves, so a looser guard is scored for $0 (#449)
#326's last open half. `3e2a620` (#419) made the decline line say *why* a table shipped in two pieces; it could not make the decline *re-scorable*, because the only thing a modified guard can be run against — the two halves as the join stage saw them — was not persisted anywhere. So loosening a rule meant buying a round to find out, and the docstring said as much. The two halves are the whole input. `joinInCode` reads `pair.first.html` and `pair.second.html`; `verifyJoin`, `rowFloor` and `checkJoin` read only what `read()` derives from those two strings. Nothing on the free path reads the assembled body. So two strings replay the guard AND its verification, and `pairFromHalves` is the second way to build a `ContinuationPair` — through the same `parse`, so a re-score cannot pass through a parser the stage does not use. - `table_join_code_declined` carries `html_first` / `html_second`, on every decline including the two whose header fields are absent because the pair threw: the bytes that threw are what a fix has to run against. - `table_joined` carries them too when `by === "code"` — a loosening that recovers declines and breaks free joins would otherwise look like an unmixed win. When `by === "editor"` it carries nothing, because that pair's decline line already holds the same bytes and `pairKey` is those two strings, so the pair of lines matches on content rather than on order. - The bound refuses rather than truncates. `capSignature` truncating a header at 1,200 characters still leaves a signature comparable as far as it goes; half a table's bytes parse to a *different* table — fewer rows, no closing markup — and a rule scored on them returns a verdict that is not the rule's. Past `MAX_REPLAY_CHARS` (64,000 per pair) the line carries the two sizes and `halves: "too_large"` and no bytes. - `halves` is on every one of those lines, `"logged"` or `"too_large"`, so a re-score can say "N of M are replayable" without knowing a constant in this file. `diagnostics.tables.code_declined_with_halves` publishes that for the declines and `joined_in_code_with_halves` for the free joins — both, because a payload that could report the population a loosening means to recover and not the population it must not break could only ever measure the upside. Measured, $0, read-only: the shipped stage run over the 75 submission arms of the reference corpus, with an editor that refuses to answer so every pair reaches a code verdict, produces 200 code verdicts (142 declines, 58 free joins) across the 37 arms that hold one. 200 of 200 replay from their own log line to the verdict the line recorded, 0 past the bound, 0 whose bytes would not rebuild a pair. Pair sizes 5,898–25,938 characters (median 11,026); one submission's log grows 9–111 KB (median 66 KB) against a 220–940 KB log. Those bodies are pages concatenated in page order, which forms more pairs than a live round's iterative assembly does, so the counts are the probe's and not a round's — the replay agreement is the claim, not 142. That range is a two-piece corpus's cost and not a ceiling. A table printed across three pages is joined one pass at a time, so pass 2's pair is (the pass-1 merge, the third piece) and the first two pieces' rows go on a second line — correctly, since that merge is what pass 2 judged. The ceiling is the loop's: 12 pairs reach a verdict, so 12 blocks at the bound is 750 KB. This corpus has no chains — 0 of the 200 lines took the previous line's merge as its first half, on 47 lines that had a free join immediately before them. The price is that the run log holds page markup verbatim where it used to hold captions and signatures. Both readers of it are owner-scoped (`ownedSession` on `GET /v1/sessions/:id/logs` and on `/diagnostics`, the only two callers of `paths.sessionLog` besides the two writers) and `/v1/quality` publishes aggregates, so this is not an exposure — but a log is now a copy of part of the document rather than a description of it, and `docs/API.md` and `docs/design-notes.md` say so where a reader of these fields meets them. Two things a decline line still cannot score, now named where the removed reason was: a change to which tables are PAIRED reads the whole body (`unmatched_source` and `not_adjacent` leave a caption and nothing else), and a change to the EXTRACTION is a different document, so it is a paid round. Nothing here loosens a guard, and nothing changes what joins, what declines, or what a reader receives. Two review rounds. Round 1 found the free joins' missing counter, the chain that re-logs an intermediate merge, and the log's change of kind from metadata to document content — all three fixed in `f75a531`, and round 2 withdrew its own 830 KB arithmetic for the 750 KB above, having counted a pass that breaks before it can emit. Gates: `tsc --noEmit` clean, `npm test` 1675 / 1675 / 0, `bash test/e2e.sh` ALL ENDPOINTS PASSED, no line over 121 characters. Closes #326 Co-Authored-By: bbertucc <46652+bbertucc@users.noreply.github.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Blake Bertuccelli-Booth Blake Bertuccelli-Booth committed on Sep 9, 2026, 07:13 PM
Showing 6 changed files +394 additions -13 deletions
Browse files at this commit →