Skip to content

B1b-3c-1a: correctness gaps — Number() range downgrade, and coverage the review proved missing #112

Closed

Description

@gvonnessi

Found by the B1b-3c-1a review panel, all proven by surviving mutation or by construction.

Correctness

DONE. anchorPosition uses Number() (reference-resolver.ts). Two distinct decimal integers above 2^53 round to the same double, so start < end is false and the code takes the invalid arm instead of the bounds arm. #p1/9007199254740992-9007199254740993 on a 5-code-point block reports CDX-E-ANCHOR-POSITION-INVALID (disposition null → IGNORE) instead of OUT-OF-RANGE (WARNING, B3-escalating). Schema-valid under contentAnchorUri's pattern. The Python oracle uses arbitrary-precision int() and already disagrees. Fix: compare digit strings (length then lexicographic) before converting. Fixed by compareDigits, which normalizes leading zeros first — [0-9]+ admits 007, so a bare length-first compare would have called #p1/007-10 inverted and reported a conformant anchor. Pinned by fixture warn-anchor-position-out-of-range-unsafe-integer (RED before the fix: the document reads IGNORE) plus three unit tests covering the inverted-above-2^53 mirror case and the leading-zero regression.

Coverage (each verified non-equivalent)

  • contentAnchorPosition's entire structural rule set is untested DONE (with one correction). — 5 independent surviving mutations (offset-alongside-start/end, half range, offset integer guard, start/end guard, !== undefined vs in). Seven unit tests plus fixture warn-annotation-anchor-position-malformed (the oracle's _object_position structural arm had no corpus case either). FOUR of the five mutants die; the half-range guard does not, and it is EQUIVALENT rather than uncovered — a half range falls through to the numeric guard, where the absent half is not a number and the same code is reported. It survives as a diagnostic only, and the line now says so, since no test in this repo can catch its removal.
  • No anchor target anywhere has >1 text node, so §3's concatenation model is untested. join('')→join(' ') survives in both implementations — a shared misreading passes every gate. DONE. Fixture warn-anchor-position-multi-text-node-target (abc+def, range 0-7) kills the separator mutant in the reference AND in the oracle, verified separately in each. A unit test pins the length from both sides (0-6 clean, 0-7 out of range), ruling out any off-by-one as well as any separator.
  • All four new oracle confirm_clean arms are deletable with every gate green. archive_oracle.py solved this with selftest() driving clean-case guards from malformed fixtures; document_oracle.py has no selftest().
  • A wrong content hash is undetected on any non-clean fixture — the extra FILE-HASH-MISMATCH is tolerated by the subset check and cannot raise the verdict. (All 148 fixtures verified correct today.)
  • Annotation URI-form positions untested DONE (both the out-of-range and the no-position control); 4 of 8 reference fields untested (incl. the only many:true path) CLOSED by B1b-3c-1a: split CDX-E-ANCHOR-POSITION-OUT-OF-RANGE by §5.4.2 row (one-way door before B3) #111 — the row split needed all eight fields asserted, so test-document-verdict.ts now covers each one's position routing, academic:theorem uses[] included, each asserting its own code and the absence of the other two. The DANGLING arm's per-field coverage is untouched by that and still thin. The Dublin Core arm has no fixture and array-valued terms no test; run-flush has no negative control.
  • Pin the derived vocabulary DONE. assert.deepEqual([...textBearingTypes].sort(), ['definitionTerm','figcaption','heading','paragraph']). Throw-on-empty is implemented but is not sufficient — a partial schema refactor yields a non-empty wrong set. Measured, and worse than stated: dropping figcaption leaves the entire corpus green — 360/360 and all 107 oracle confirmations — not merely the clean fixtures, because no fixture anchors into a figcaption. Only the new assertion fails.

Three absence tests a reviewer specified precisely DONE

All three land in test-document-verdict.ts, each asserting the scan AND canonicalContent so the scope claim is checked against what canon actually does. Note the figure.caption case: canon reaches the same reject by a DIFFERENT arm — it merges the two adjacent text nodes and the per-string check fires on the merged value — so the two agree on the verdict without agreeing on the route. Original text: a crdt payload with a split run is NOT reported and canonicalContent accepts it (assert both directions); a crdt inside a text node's marks with a split run IS reported (kills the naive key-name mutant); a figure.caption rich array with a split run IS reported.

Activity

  1. gvonnessi commented on Aug 13, 2026

    @gvonnessi
    CollaboratorAuthor

    Two items done on conformance/b1b-3c-1a-content-validity; body updated.

    Number() range downgrade — fixed. compareDigits orders the bounds on the digit strings, before any conversion. One wrinkle worth recording: [0-9]+ admits leading zeros, so a bare length-then-lexicographic comparison calls #p1/007-10 inverted and reports a conformant anchor — Number() happened to get that case right, so the fix normalizes leading zeros first or it trades one defect for another. Only the ordering comparison needs the exact form; the bounds check against textLength does not, since a target's length is a real array length and any value a double rounds at already exceeds it.

    Ratchet: reverting to the double comparison makes warn-anchor-position-out-of-range-unsafe-integer fail with documentDisposition IGNORE outside [WARNING, WARNING] — the downgrade is visible at the document level, not just in the code. Three unit tests cover what the fixture cannot: the INVALID code is absent, a genuinely inverted range above 2^53 is still INVALID (so the fix is not "never report inverted up there"), and the leading-zero case stays clean.

    §3's concatenation model — fixed. warn-anchor-position-multi-text-node-target is the first target in the corpus whose text spans more than one text node: abc + def, range 0-7. Mutating join('') to join(' ') was verified to fail the suite in the reference and in the oracle separately — this was the one gap where both implementations shared the same misreading, so a probe against either alone would have proved nothing. A unit test pins the length from both directions (0-6 clean, 0-7 out of range), which rules out an off-by-one as well as any separator.

    Neither needed an oracle change: int() and "".join were already correct, so both fixtures are genuine reference-versus-oracle agreements rather than a shared model.

    Counts now 152 fixtures, 359/359 conformance, oracle 106 injected / 22 clean, test:document-verdict 56. Every gate green.

    Still open here: contentAnchorPosition's structural rule set (5 mutants), the absent document_oracle.py selftest(), the undetected wrong content hash on non-clean fixtures, annotation URI-form positions, the Dublin Core arm, run-flush's negative control, the derived-vocabulary pin, and the three absence tests.

  2. gvonnessi commented on Aug 13, 2026

    @gvonnessi
    CollaboratorAuthor

    Second pass: the cheap pins are in. Body updated.

    contentAnchorPosition's structural rule set — seven unit tests through the annotation object form (the only form that can express these shapes), plus warn-annotation-anchor-position-malformed, since the oracle's _object_position structural arm had no corpus case either. Four of the five mutants die on deletion. The half-range guard does not, and it turns out to be equivalent rather than uncovered: a half range falls through to the numeric guard below it, where the absent half is not a number and the same code is reported. It survives purely as a diagnostic — "start without end" names the rule §2.2 states, where "start=0, end=undefined" reads like an internal error — so I kept it and annotated the line to say no test can catch its removal. Better than leaving a comment implying coverage that does not exist.

    Derived-vocabulary pin — in, and the original note understated the problem. Dropping figcaption from the derived set leaves the entire corpus green: 360/360 conformance and all 107 oracle confirmations, not just the clean fixtures. No fixture anchors into a figcaption, and a wrong-but-non-empty set only produces the null-disposition structural code, which is invisible in both of the comparator's dimensions. Only the new assertion fails. The oracle hand-transcribes the same four names under its own SYNC comment, so this is what keeps the two transcriptions honest.

    Annotation URI-form positions — covered both ways, out-of-range and a no-position control.

    The three absence tests — all three in, each asserting the scan and canonicalContent, because the scan's scope claim is a claim about what canon deletes and asserting only the scan would pass under a wrong model of it. Confirmed empirically before writing them: crdt on a block is silent and canon accepts; crdt under a text node's marks is reported and canon throws; figure.caption is reported and canon throws. Worth noting for whoever reads the caption case later — canon gets there by a different arm, merging the two adjacent text nodes so the per-string check fires on the merged value, so the two implementations agree on the verdict without agreeing on the route.

    153 fixtures, 360/360, oracle 107 injected / 22 clean, test:document-verdict 71. Every gate green.

    Left here: document_oracle.py has no selftest(), the wrong-content-hash hole, the Dublin Core arm's fixture and array-valued terms, and run-flush's negative control.

  3. gvonnessi commented on Aug 14, 2026

    @gvonnessi
    CollaboratorAuthor

    Committed as 2c16d7d. Three /review-fix cycles ran on this branch; cycle 3 returned no defect in shipped runtime behaviour, which is the stopping signal. Recording what the panels closed here, and four deferrals that need to outlive the session.

    Closed by the review cycles (beyond the items already struck above)

    • codeBlock was missing from the derived text-bearing set — its schema reaches textNode through an allOf that narrows it (§4: exactly one marks-free text node), and the derivation followed only direct references. §3 names codeBlock text-bearing outright, so every anchor into a code block reported "not a text-bearing block" on conformant content, and an out-of-bounds one was downgraded to the null-disposition code. Fixed on both implementations, with a fixture and unit tests; reverting the derivation fails two gates.
    • The published anchor-range rule had the same >2^53 defect the document layer's position check had — on the exact URI a new fixture pins. checkAnchors ordered bounds via Number(), so a well-ordered range above 2^53 was reported inverted under a message naming the same number twice. The comparison is now shared (scripts/lib/decimal-digits.ts); two published vectors pin it.
    • The derived-vocabulary pin was a trap in its first two forms. It first asserted the derivation's own output (locking in the codeBlock bug it had just been written to catch), then asserted deepEqual against §3's list — which reads "such as", so the spec supports only a superset bound. An equality assertion fails on a correct widening, and the cheapest route back to green is to narrow the derivation again. Now two one-sided bounds: every named type derived, no container derived.
    • Both oracle grammar fixes were unratcheted until cycle 3 — each reverted fully green. Two annotation fixtures now pin them (a non-ASCII-digit URI; an integral-float offset), each verified to fail on revert. This mattered more than ordinary coverage because the oracle is the independence property.

    Deferred — please keep these on this issue

    1. A live false positive remains: semantic:ref. Its schema declares children.items.$ref → content.schema.json's textNode, so §3 makes it text-bearing, but deriveContentVocabulary reads only content.schema.json's $defs. An in-bounds anchor into one reports CDX-E-ANCHOR-POSITION-INVALID today; out-of-bounds is downgraded. Same class as codeBlock, and the oracle shares the blind spot, so no fixture disagreement can surface it. Blast radius is exactly one type. Recorded as OQ-012 rather than fixed, because it is a spec call first: refBlock's children are "Display content" while it also carries format ("Section {number}"), so if the rendered text is generated rather than taken from children, what an offset addresses is ambiguous. Belongs with 3c-1b (extension-block interiors), and whichever way it goes the fix must land on both implementations at once.

    2. check:enumeration-coverage's "emitted" claim is satisfiable by a comment. The scan is lexical and its regex accepts backticks, so a code named only in a backticked JSDoc counts as emitted. Demonstrated by rerouting a code and leaving a comment behind: the gate stayed green while check:conformance failed. Latent — no code is comment-only today — so it needs a gate change, not a fix.

    3. No compile-time link in the rewriteIds → resolver direction. The resolver's field tables are now guarded by the per-row test (dropping a field fails it), but a field added to rewriteIds alone is silently unresolved. The cost rose with this change: those tables now decide which §5.4.2 row a position defect takes, so a field known to canon and unknown to the resolver has no row at all. Mitigated for now by a note in rewriteIds's JSDoc, where the drift is caused.

    4. anchorTargets defaults to an empty map in document-verdict.ts, reintroducing one level out the defaulting its own JSDoc warns against. Safe today — it is assigned beside contentIds and the call site gates on contentIds !== null — but that invariant spans two variables and is stated nowhere. One nullable pair removes it structurally.

    Still open from the original list: document_oracle.py has no selftest(), the wrong-content-hash hole, the Dublin Core arm's fixture and array-valued terms, and run-flush's negative control.

  4. added a commit that references this issue on Aug 28, 2026
  5. gvonnessi commented on Aug 28, 2026

    @gvonnessi
    CollaboratorAuthor

    The four original items are closed by #118. Leaving this issue open — the four deferrals recorded above are untouched and still belong here.

    document_oracle.py has no selftest() — it does now, and it covers every arm rather than the four B1b-3c-1a added. Measured first: deleting any one of those four left every gate green, and each deletion made confirm_clean return "clean" rather than being caught by a later arm, so nothing was masking them. The same held for every other arm, so the table drives all 37 refusal arms from the malformed corpus — 36 from fixtures, and the archive-unreadable guard from synthetic bytes, no document fixture being an unreadable zip.

    The rows assert the specific refusal message rather than "was refused at all", because confirm_clean returns on its first failing arm and a bare is-not-None assertion is satisfied by any later one. Six rows were confirmed to fail as WRONG-ARM rather than BLESSED under mutation, which is the proof that distinction is load-bearing.

    The part worth recording for whoever adds the next arm: a table restoring today's coverage adds nothing that keeps tomorrow's arm honest, and inserting a new arm with no row was measured to leave every gate green — the same defect class, one level up. So the arms are enumerated from the function's own AST and the drivers traced, and an unreached arm fails. It also distinguishes "no row" from "row shadowed by an earlier arm", because the remedies are opposite and the naive message tells you to add a row that already exists.

    A wrong content hash is undetected on any non-clean fixture — closed, and wider than stated. The claim needed narrowing: it holds for reject- and warn- cases but not for the five expecting exactly [IGNORE, IGNORE], where the extra WARNING does move the disposition and check:conformance catches it. And the hole was not limited to content.hash: corrupting one non-clean fixture's asset-index hash was invisible to check:conformance, check:fixtures, check:document-oracle and check:document-id together. The gate now covers every {path, hash} pair a manifest declares — 148 hashes across 125 document fixtures.

    The Dublin Core arm has no fixture and array-valued terms no test — both closed, and the fixture description needed correcting before it shipped: 4.3.1 projects creator/subject/language as arrays with a scalar coerced, so "either a string or an array" describes the authored value, not the projection. That text ships in case.json, and an implementer skipping the coercion computes a different document ID. Pinning only creator was also measured to leave subject and language unscanned in both implementations with the corpus green, so the verdict test runs over all three.

    Run-flush has no negative control — closed. The control is a pair carrying the same four text-node values, differing only by a paragraph between the second and third; each run is NFC-stable alone and only the concatenation across the boundary is not. Deleting the reset leaves all 368 checks and both oracles green and fails only that test. It sits in a blockquote rather than the root blocks array: blockquote, listItem, tableCell, admonition and definitionDescription all admit a text node beside a paragraph (validated with the repo's own factory against negative controls), so the control does not depend on the root-admissibility question open in #114.

    Two coverage residues the panel measured and #118 did not close are now #119.

  6. added 2 commits that reference this issue on Aug 28, 2026
  7. gvonnessi commented on Aug 30, 2026

    @gvonnessi
    CollaboratorAuthor

    Both remaining un-struck items were closed by PR #118. Measured on main at 18f293f:

    "All four new oracle confirm_clean arms are deletable with every gate green." document_oracle.py now has the selftest() the issue asks for:

    $ npm run check:document-oracle
    selftest: 52 drivers reach all 37 refusal arms in confirm_clean
    

    The table is enumerated from the function's own AST and the drivers traced, so an arm added later with no driver fails the selftest rather than going silent — it ratchets rather than merely restoring coverage.

    "A wrong content hash is undetected on any non-clean fixture." check:fixtures gained a hash-coherence pass over every {path, hash} pair a manifest declares:

    $ npm run check:fixtures
    Fixture hash coherence:
      ✓ 149 declared hash(es) across 129 document fixtures agree with their stored bytes
        (1 deliberately malformed, 6 declined and named, 6 fixtures carry no comparable reference)
    

    The residue that pass deliberately does not cover — asset hashes listed inside an index file, which are pinned only by the oracle's clean-fixture arms — is tracked separately as #119 item 1, with the reasoning for where the boundary sits.

    Everything else in this issue was already struck through. Closing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions