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>