fix(structure): a derived reference needs a cue, not just a number shape

STRUCTURED_V1 derived `references` from every number-shaped token in a body.
A consumer measured 12 false references out of 12 on their corpus, ten of them
version numbers in titles read as document numbers. Measured again here before
changing anything, on two corpora, through `derive_document_structure` itself:

  corpus A  a consumer's normative bundles   2 561 docs -> 2 838 subjects
  corpus B  this repository's own docs/         28 docs ->   559 subjects

The reported class reproduces, and two larger ones they did not report turn up:
702 of corpus A's subjects (24.7 %) are hexadecimal fragments of UUIDs read out
of `id="..."` attributes in embedded MathML, and corpus B lifts tokens out of
escape sequences in quoted source (`\n60` -> `N60`), licence identifiers
(`AGPL-3.0` -> `3.0`) and package pins.

A derived subject is now a bundle-local link target, or a number immediately
preceded by a cue from a closed set. Positive rather than a blacklist because
the data forces it: `V221` is a genuine document number in corpus A and
`V0.3.0` is a software version in corpus B, and they are the same token shape,
so only the words in front of them can tell them apart. The cue matches at a
word boundary (a Norwegian compound ending in `-klasse` otherwise satisfies the
cue `se`, which admitted 86 class designations) and the window is NFC-normalised
so a cue survives a decomposed filesystem. Fragment-only and brace-carrying link
targets go too: neither can name a concept, so neither is a pending pointer.

After: 2 838 -> 1 279 (A) and 559 -> 72 (B). Hand-classified against the
occurrence that actually passed the gate: 30 of 30 sampled genuine on A, 60 of
60 on B. Residual known falses: 9 of B's 72, all illustrative link targets in
prose about link syntax.

The prefix-resolution rule stays open, per the order's condition: of 2 589
documents, 2 562 carry a number and 0 of those are dotted, so unique-prefix
match has no data here to be defended against.

Cost stated rather than hidden: a corpus phrasing cross-references outside this
vocabulary derives nothing and must declare `references`. A missing reference is
visible to the reader; a false one is not.

Record: docs/plan/references-cue-rule.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATyA5Lx53N1rKzwMnuMoem
This commit is contained in:
Kjell Tore Guttormsen 2026-08-29 09:26:57 +02:00
commit 2827be0ece
4 changed files with 277 additions and 6 deletions

View file

@ -168,7 +168,9 @@ def test_a_document_never_references_itself() -> None:
def test_references_are_deduplicated_and_ordered_by_first_appearance() -> None:
doc = derive("N300 then N200 then N300 again.\n", "n500-x.md")
# Each mention carries a cue, because a number without one is no longer a
# reference at all. What this test pins is the ordering and the dedup.
doc = derive("se N300 then se N200 then se N300 again.\n", "n500-x.md")
assert doc.references == ("N300", "N200")
@ -370,3 +372,86 @@ def test_edges_are_deterministically_ordered() -> None:
doc("inbox-a", "See N300.\n", "n100-a.md"),
)
assert list(bundle.edges) == sorted(bundle.edges, key=lambda e: (e.source, e.kind, e.subject))
# --- what a derived reference IS ------------------------------------------
#
# Measured 2026-08-29 against two real corpora, because the previous rule --
# "every number-shaped token in the body" -- was reported producing 12 false
# references out of 12 on a third. Denominators, before the rule below:
#
# corpus A 2 561 unique normative concept documents -> 2 838 subjects, of
# which 702 (24.7 %) were hexadecimal fragments of UUIDs lifted out
# of HTML/MathML attribute values, plus unit symbols (`kN/m2` ->
# `M2`), table row labels and formula numbers;
# corpus B 28 of this repository's own documents -> 559 subjects, dominated
# by software versions (`v0.3.0`, `3.14.0`, `0.11.10`), a licence
# id (`AGPL-3.0` -> `3.0`), a container version (`%PDF-1.6` ->
# `1.6`), a hash algorithm name (`SHA256`), and tokens lifted out
# of escape sequences in quoted source (`\n60` -> `N60`).
#
# The version class cannot be excluded structurally: corpus A proves `V221` and
# `V240` are genuine document numbers and corpus B proves `V0.3.0` is a
# software version, and they are the same token shape. So no blacklist can
# work, and a positive cue is the only rule that can be defended against data.
# A missing reference is visible to the reader; a false one is not.
def test_a_package_version_is_not_a_reference() -> None:
doc = derive("Built on pdfplumber 0.11.10 and Python 3.14.0, licence AGPL-3.0.\n", "n500-x.md")
assert doc.references == ()
def test_a_v_prefixed_version_is_not_a_reference() -> None:
# The reported failure class: a version string carried in a title, read as
# a document number. `V221` in corpus A is a real document; `v0.5.0` here
# is not, and only the cue in front of it can tell them apart.
doc = derive("The classifier lags v0.3.0 and the gap is wider now (v0.5.0a2).\n", "n500-x.md")
assert doc.references == ()
def test_a_number_inside_markup_is_not_a_reference() -> None:
doc = derive('<math id="id-647a093b-a70f-4e38-d277-b139040226a4">x</math>\n', "n500-x.md")
assert doc.references == ()
def test_a_measurement_is_not_a_reference() -> None:
doc = derive("Compressive strength at least 500 kN/m2, used only below 2.5 m.\n", "n500-x.md")
assert doc.references == ()
def test_a_cue_word_makes_a_number_a_reference() -> None:
# KNOWN-POSITIVE. Without it the two tests above pass for the wrong reason:
# a rule that finds nothing at all also finds nothing false.
doc = derive("Laid to depth as se kapittel 2.6.2.2 and tabell 4.8.4-3 give.\n", "n500-x.md")
assert doc.references == ("2.6.2.2", "4.8.4")
assert "references" in doc.derived
def test_a_section_sign_is_a_cue() -> None:
doc = derive("Emitted as one block, in §10.2's own listing order.\n", "n500-x.md")
assert doc.references == ("10.2",)
def test_a_cue_only_counts_at_a_word_boundary() -> None:
# A Norwegian compound ending in `-klasse` ends in the cue `se`. Matching
# an unanchored cue admitted 86 such class designations on corpus A, none
# of which name a document.
doc = derive("Se også R210; lysklasse C5 gjelder.\n", "n500-x.md")
assert doc.references == ("R210",)
def test_a_fragment_only_link_target_is_not_a_reference() -> None:
# An anchor points inside THIS document. It can never resolve to another
# concept, so emitting it states a relation that cannot exist.
doc = derive("See [table pairing](#table-pairing).\n", "n500-x.md")
assert doc.references == ()
def test_a_template_placeholder_link_target_is_not_a_reference() -> None:
# Documentation about link SYNTAX carries example targets. A brace cannot
# occur in a concept name (`reduce_to_id_grammar` emits lowercase
# alphanumerics and hyphens), so such a target can never resolve — and an
# edge that can never resolve is a claim, not a pending pointer.
doc = derive("Managed lines read `- [title](ingest-{id}.md)`.\n", "n500-x.md")
assert doc.references == ()