The plan document decided what the contract is and who owns it, and said of
itself that nothing in it was implemented. This states the same six elements as
numbered paragraphs with MUST and SHOULD, in the form a checker can read, and
adds the three things the plan layer had no way to carry: concept identity as
the (bundle_id, concept_id) tuple with a content digest per excerpt, the closed
adjudication state set with unknown written explicitly rather than collapsed,
and the prohibition on reaching the verdict layer with a query-time retrieval
tool.
The plan document is not rewritten to match. It records why; this binds. Two
copies of the same rules drift, so the plan carries a pointer instead.
A sixth golden directory, never an edit to the five that exist. Its only
difference from the SEGMENTED_V1 golden is the added `okf_version` root key --
measured by diffing the two -- which is exactly what the profile is for and
nothing else moved.
Modelled on tests/test_segmented_golden.py rather than test_golden.py's
`materialize_case`, which is Door A only: it drives materialize_bundle from a
manifest and this bundle comes through Door B's inbox. The plan's pointer to an
"existing goldens list" in test_golden.py is stale -- that file holds only the
Door A parametrize, and `ingest-golden-segmented` is not in it either. Door B
goldens carrying their own test file is the established pattern, and this one
asserts the five priors are untouched from its own side.
CROSS-BUNDLE IDENTITY (PM decision B1) is settled BEFORE this pin, not after.
Byte-pinning a bundle carrying `okf_version` is where its concept IDs stop
being adjustable: a concept ID is bundle-local and stable, identity across
bundles is the tuple (bundle_id, concept_id), and there is no cross-bundle link
form in v0.2. Asserted -- every concept carries its bundle id, so the tuple is
readable from one document, and no foreign bundle id appears anywhere.
Two defects in these tests, both found by running them:
- `parse_frontmatter` takes a Path and returns the dict; three call sites had
it wrong.
- The cross-bundle check used `b-golden-segmented` as the foreign id, which is
a PREFIX of this bundle's own id. It failed on every file for the wrong
reason and would have read as "a cross-bundle reference was emitted". Now a
foreign id that is not a prefix, with a known-positive half asserting the
bundle does name its own id -- so the check is looking at content that could
have carried one.
Negative control: changing one byte of the golden's `okf_version` turns the pin
red.
`okf_version` and `bundle_id` are fixture DATA read from the case, never
constants in the test -- the value belongs to catalog (E1).
Suite 931 -> 941.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Neither shipped profile could do both: SEGMENTED_V1 names `bundle_id` and not
`okf_version`, OKF_V0_2 names `okf_version` and has no segmentation at all. The
sixth profile is where the two intersect. Additive, as upstream support always
is here -- a new profile, never a migration.
THE INDEX POLICY IS DECIDED, NOT INHERITED, and that is the part that was easy
to get wrong. Measured: OKF_V0_2.index has facets=None and per_directory=False,
while SEGMENTED_V1.index has both. Building on OKF_V0_2's index would have
produced a segmented bundle with no faceted index -- structurally valid, SPEC
conformant, and missing the surface a consumer reads. So the index comes from
SEGMENTED_V1 with both root keys named, and the spec declaration from OKF_V0_2.
The premise is asserted in the suite rather than trusted to stay true.
`SegmentationPolicy.adjudication_key` is the discriminator BETWEEN segmented
profiles. It has to be a field with a value: every 1-to-N branch keys on
`profile.segmentation is not None`, which both segmented profiles satisfy, so a
later step surfacing the adjudication marker on that check would write it into
SEGMENTED_V1 too and move a byte-pinned golden. `None` means the profile does
not surface adjudication state at all.
`okf_version`'s VALUE is not in this module and must never be: a profile names
a key, the caller owns its value (decision E1). A constant here would claim a
decision this library does not own and would be the one thing to chase on every
upstream release. Asserted.
Five existing profiles untouched, each still equal to a freshly constructed
copy, root keys pinned literally, and OKF_LATEST still DEFAULT -- adding a
profile is not a GA event.
Suite 926 -> 931.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Arm E, capability only. A profile MAY name a renderer per suffix; no
domain-aware renderer is written here, that stays a Non-Goal, and `_RENDERERS`
is empty on purpose so the emptiness reads as a decision rather than an
omission.
THE LAYERING IS THE DESIGN, not an implementation detail. `extract.py` is the
extraction registry and must not import the contract layer, or the dependency
runs backwards and the registry stops standing on its own. So `extract_text`
gains a keyword-only `renderer: Callable[[str], str] | None`, knowing nothing
about profiles, and `inbox.py` -- which already holds the profile at that call
site -- resolves a NAME to a function. A test asserts extract.py still contains
no reference to the profile layer, because that constraint is the whole reason
the parameter is shaped this way.
The renderer runs AFTER extraction, never instead of it, so it never has to
re-implement a reader and the two cannot drift. The default is identity, which
is what keeps the five byte-pinned goldens byte-pinned -- asserted per suffix
rather than once.
An unknown renderer NAME is refused rather than falling back to identity: a
silent fallback would produce a bundle that looks rendered and is not, which is
the failure mode this arm exists to make visible. That needed a registered code
(`unknown_renderer`) and its test -- slightly beyond the step's named files,
but the capability cannot ship without defining what an unknown name does.
`tests/test_profile.py`'s exact-field-set assertion went red, as the plan's risk
table predicted. Updated deliberately with the reason recorded: that assertion
exists so a field cannot arrive without someone deciding it should, and its red
run is the mechanism working.
Suite 917 -> 926. All five goldens byte-identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The README told consumers that `docx` and `xlsx` ship no parser and always fail
fast. True when written; false the moment the converter seam landed -- and
false SILENTLY, because prose has no test. This repository has been bitten by
that exact shape before: a published guarantee is a test obligation.
So the correction comes with `tests/test_docs_promises.py`, which compares the
README's declared format list against the registries it describes and fails on
a format added without touching the README, on the old claim reappearing in any
wording, on an unmeasured row going unnamed, and on the exclusions being
dropped. Negative control: removing one format from the README's marker turns
it red.
The README now states which rows are measured and which are not. Three of the
five office rows have denominator ZERO in the corpus -- they work by
construction and have never met a document anyone wrote. They are not known to
be broken and not known to be right, and a reader should not have to open the
source to learn which.
The CHANGELOG's shipped entry is left as written, because a changelog records
what a release did; the correction is stated at that line instead so a reader
arriving there is not misled.
Suite 913 -> 917.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three hand-laid OOXML containers, every part written out by hand and zipped
with a fixed date_time so they are byte-reproducible. No converter output
anywhere in them: a .docx written by the converter and read by the converter
proves only that the converter agrees with itself, and would stay green through
any conversion defect that is symmetric -- which is most of them.
two-line-krav.docx heading + label/value on one line (the docx mirror of
the PDF fixture)
no-styles-krav.docx the SAME document without word/styles.xml
two-line-krav.xlsx sheet name as heading + label/value on one row
THE FIXTURES FOUND A REAL DEFECT IN THE SEAM THEY WERE MEANT TO PIN. The
converter call used pypandoc's TEXT entry point, which takes an `encoding`
because it treats its source as text -- and that corrupts a zip. The xlsx
fixture failed with `Failed to unpack XLSX archive: not enough bytes` while
reading correctly from disk with the same binary. The docx of the same shape
happened to survive, which is the part worth writing down: the defect is silent
for some inputs and fatal for others, so "it worked on the file I tried" was
never evidence. Input now goes through a temporary file.
Two measurements while building, both the same shape -- structurally valid
input, silently reduced output, exit code 0, no warning:
- Without word/styles.xml the docx extracts as flat prose with no heading. A
fixture lacking that part would pin the body and pin nothing about structure.
Committed as a negative control that RUNS rather than a sentence in a README.
- With inline strings rather than a shared string table, the xlsx extracts with
the sheet name intact and every cell value gone. The fixture uses a dimension
element and a shared string table instead.
The frozen literals are pinned to a NAMED converter version, asserted beside
them: a frozen literal without one says "these bytes" without saying what
produced them.
Suite 908 -> 913. Fixtures regenerate byte-identically.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_PANDOC_FORMATS` names the rows and no others: docx, xlsx, pptx, odt, rtf.
`.html` stays on its stdlib extractor -- routing it through the converter would
buy nothing and would add CVE-2025-51591 (SSRF via an iframe in HTML input),
unpatched in every converter version. `.epub` is out on the "no gain" half of
that.
`_EVIDENCE` records what each row rests on, asserted in the suite rather than
written in a comment: docx and xlsx are `measured`, and pptx, odt and rtf are
`unmeasured` because the corpus contains ZERO files of those types. Three of
five rows therefore leave this step working by construction and never checked
against a document anyone wrote, and the assertion is what keeps that visible.
Three converter arguments, all measured and none of them hygiene:
`--eol=lf --wrap=none` because the defaults produce different bytes (max line
length 75 against 447), and `-t markdown` never `-t plain` because plain
destroys the headings the segment proposer reads -- 15 entries with two real
headings become 13 with none.
`_UNPARSED_OPTIONAL_EXTENSIONS` is now empty and kept rather than deleted: the
branch still raises, and a future type arriving before its reader belongs there
rather than in a new mechanism. This is what the first step was for -- both
tests for `extractor_extra_missing` were repointed at the import probe before
the set emptied under them.
The converter call is isolated behind `_convert_bytes` so the seam's own logic
is testable without the binary; the conversion itself is pinned by frozen-text
fixtures in the next step. Checked live against a hand-laid docx through the
real vendored binary: heading and body both survive.
Suite 895 -> 908.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_pandoc.py` hands back a converter whose identity is known, or refuses.
The wheel is not enough on its own. pypandoc searches PATH before its own
bundled binary and keeps the highest version found, so on this host the
vendored 3.9 was silently bypassed for the system 3.10.2 -- measured a third
time before writing this. The resolver reads the installed package's own
`files/pandoc` path and asserts the reported version against a frozen
PANDOC_VERSION literal, raising `extractor_binary_version` naming both,
`extractor_binary_missing` when the wheel carries no binary, and
`extractor_extra_missing` when the extra is absent.
A mismatch is refused rather than used with a warning: extraction is
deterministic within a converter version and not across one, and a byte-pinned
fixture cannot tell "a different converter ran" from "we introduced a defect".
Two defects found by measuring rather than by the suite:
1. The first implementation asked `pypandoc.get_pandoc_version()`, which
answers from a module global that `clean_pandocpath_cache()` does not reset.
It therefore reported whichever binary was probed FIRST in the process --
3.10.2 for the bundled 3.9 binary. The suite was green because nothing in it
probed the host binary first. Now `_get_pandoc_version(path)` probes the
argument, with no cache and no search in the way, and a regression test
poisons the cache before resolving. Negative control: that test fails on the
old mechanism.
2. The module docstring named the process-spawning API in prose, which is
enough to fail the model-free gate -- the gate is a grep. Reworded. The gate
now proves the narrower "no model vendor is reachable from src/", stated in
the module rather than glossed.
os.environ is restored on both the success and the failure path, and a
pre-existing override is put back rather than deleted.
Suite 887 -> 895. mypy --strict clean (pypandoc joins the guard's
ignore_missing_imports override; every value it returns is coerced here).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`pypandoc-binary==1.17` joins the `[extract]` extra, and the extra's contents
are now pinned by a test -- `test_packaging.py` asserted `project.dependencies`
only, so a second package could have arrived in the extra unnoticed, which is
precisely where an unexamined transitive tree shows up.
WHY VENDORED RATHER THAN FOUND ON PATH: the xlsx and pptx readers exist only
from pandoc 3.8.3. Debian 12 ships 2.17.1.1 and Ubuntu 24.04 ships 3.1.3, so a
PATH binary cannot deliver two of the five office formats on current stable
distributions. The pin is exact rather than a range for the same reason
pdfminer.six's is: extraction is deterministic within a converter version and
not across one.
The single-runtime-dependency rule is untouched -- it governs
`project.dependencies`, which still names the guard alone.
Measured after installing, on this host:
bundled binary pandoc 3.9 (inside the wheel, as intended)
pypandoc picks 3.10.2 (the host's PATH binary)
That is the third independent measurement of the trap: pypandoc searches PATH
before its own bundled binary and takes the highest version it finds, so
"vendored" buys nothing until something resolves the path explicitly. The
resolver is the next step; until it lands, the vendoring is a pin without an
effect and should not be described as more than that.
Suite 886 -> 887 (the extra is installed, so the packaging pin runs).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Registered before any step raises them, so no later step invents a spelling:
- `extractor_binary_missing` -- the converter binary is absent (distinct
from the extra not being installed)
- `extractor_binary_version` -- present, but not the pinned version
- `extractor_convert_error` -- the converter failed on this file
- `extractor_empty_conversion` -- the converter returned no text
Also widened the `extractor_extra_missing` type list, which still read
"pdf/docx/xlsx".
Denominators recounted after the change rather than carried from a note --
the stale 49/48 figure is what made the recount a step requirement:
code bullets in errors.py 50 -> 54
distinct codes 49 -> 53 (one code documented twice)
test definitions in this suite 56 -> 57 (one parametrized definition,
four cases -- one per code)
The four tests assert only what is true at this step: the code is documented
and an ExtractionError carries it. The resolver and the seam replace each with
a behavioural raise-site test; a code that never gains one stays visible here
as a test that still only reads a docstring.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both tests reached `extractor_extra_missing` through a `.docx`/`.xlsx`
filename, which works only while `_UNPARSED_OPTIONAL_EXTENSIONS` is non-empty.
Those types are about to gain a converter, which empties the set and makes the
membership branch unreachable -- the tests would have gone red for the right
reason at the worst moment, mid-series.
Repointed both at the import probe, the mechanism the pdf gate already uses and
the one path that stays reachable however many types gain parsers.
Measured negative control: without the probe the same call raises
`extractor_pdf_error`, so the probe is load-bearing and the test can still fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Record the two cross-repo decisions the programme ratified on 2026-09-02, so
they exist on this side of the boundary rather than only in a consumer's plan.
- Concept IDs are bundle-local; identity across bundles is the tuple
(bundle_id, concept_id); no cross-bundle link form in v0.2. Written down
before a golden freezes the IDs, because retrofitting would move a pinned
golden.
- The adjudication state has a fixed wire form: frontmatter key `adjudication`
with a closed value set, three companion keys when adjudicated, and an
`adjudication` index facet -- new profile only, SEGMENTED_V1 byte-identical.
Absence of the key means `unknown`, never `absent`.
The ten amendments from the same judgement are applied to the voyage plan
itself, which is LOCAL-ONLY under this repo's public-remote model and so is
not part of this commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The converter binary is vendored as a pinned wheel in the [extract] extra
rather than resolved from PATH. Three measurements retired the alternative:
the xlsx and pptx readers exist only from pandoc 3.8.3 while Debian 12 ships
2.17.1.1 and Ubuntu 24.04 ships 3.1.3; the docx reader changed its output in
at least 22 of the 3.x releases; and Quarto and pypandoc-binary already made
this call. Two consequences are recorded because they are easy to get wrong:
the single-runtime-dependency rule is untouched, since the extra sits outside
it by construction, and the model-free gate keeps subprocess in its
alternation, since src/ imports a package instead of spawning a process.
One trap is recorded because it is invisible when it fires: pypandoc searches
PATH before its own bundled binary and takes the highest version it finds, so
on a host with a newer system pandoc the vendored wheel is bypassed without a
word and the determinism guarantee goes with it.
Judgement lives in tools/, outside the run path, as versioned data. The
verdict carries its dwell time, because a ratified flag with no per-item time
cannot be falsified. Rejecting is one keystroke and correcting optional,
because requiring a typed correction was measured to reduce flagging. The
model leg is off by default so its value can be measured.
A sixth profile carries both okf_version and bundle_id rather than extending
SEGMENTED_V1, which would have rested on an unverifiable claim about whether
another repository had adopted it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three facts about Door B's intake were decided but never stated in a tracked
file, which left them looking like gaps someone would eventually close.
OCR is permanently out of scope (operator, 2026-09-01). The consequence is
stated rather than implied: the vegnormal-okf PDFs are rastered 11 of 11 and
will never enter through the inbox, so "Vegnormalene in => bundle out" is not
a goal this library holds. That premise never described anything that happened
either -- that bundle is built from seven JSON files through Door C.
The id grammar keeps lowercasing every path component. It is stricter than
OKF v0.2 section 2 requires, but it is shared with Door A and pinned by
byte-exact goldens, so softening it on the segmentation path alone would put
two grammars in one codebase. Measured consequence: in vegnormal-okf's B3 run,
267 of 270 concept identities matched case-folded and 0 matched verbatim.
Structured sources are recorded as open work rather than a closed boundary:
a span cuts the canonical extracted text, and for .json that text is raw data
in a fenced block, so 0 of 267 bodies came back byte-identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`d2a8c43` states in a code comment that Door C's ordering holds within a
run and never re-orders entries an earlier run wrote. That was true and
untested: every test in the new file ran each door exactly once, so the
sentence was prose rather than a pin.
Two imports into one bundle, the second adding the concept whose key
sorts FIRST. It lands last, because `link_in_index` appends what is
absent and leaves what is present. The same three concepts merged in one
run do come out ordered, asserted alongside, so the two assertions cannot
both be trivially true -- the difference is the append bound, not an
ordering that failed.
883 tests.
Co-Authored-By: Claude <claude-opus-5>
An index ordering a profile names must be honoured wherever this library
writes an index. Door B and Door C have separate index writers, so an
ordering built on Door B's `_index_sort_key` seam alone would have been a
profile field Door B obeys and Door C ignores -- silently, because
nothing raises and both files still parse. That is
`IndexPolicy.per_directory` again: a field that reads as global and acts
on one path.
`IndexPolicy` gains `sort_key`, `sort_order` and `sort_missing`. Both
order fields draw from CLOSED sets, and `sort_order` is deliberately not
a caller-supplied callable: a callable cannot be serialised into the
bundle, reproduced from it, or audited by a reader, which is the whole of
what a deterministic bundle claims. A `sort_key` the facet policy does
not name is refused too -- every entry would be missing the key and the
ordering would silently do nothing, which is this row's own defect class.
`IndexPolicy.sort_entries` is the one helper. Four stable passes, so each
is the tie-break of the next: concept path, then the named key, then the
missing group partitioned to whichever end the policy says, then
navigation last. Passes 2 and 3 are separate on purpose -- folding them
into one reversible key tuple would flip the missing group along with the
order, so `sort_missing="last"` would mean "first" under `descending`.
The tie-break is the CONCEPT PATH, not the link target, and that is
measured rather than assumed: `notes-beta.md` precedes `notes/alpha.md`
by concept path and follows it by generated filename, so ordering Door C
on the target would have re-ordered every existing Door C bundle.
`IndexEntry` carries the path for that reason; `parse_entry` leaves it
`None` and the ordering falls back to the target, which costs nothing
because no caller sorts entries it read back off disk.
Door B's two reprojection writers and Door C's index emission all route
through the helper. Door B's unfaceted path is not routed and does not
need to be: `sort_key` requires a facet policy, and a faceted profile
never reaches that writer. Door C's guarantee is bounded and stated in
the code -- `link_in_index` appends what is absent and leaves what is
present, so the order holds within a run and never re-orders entries an
earlier run wrote.
Default ordering, unchanged and now stated: with no `sort_key`, concepts
before navigation, each group ascending by concept path.
TDD, and the red was watched twice. First behaviourally with the fields
inert (both doors emitted the exact reverse of the named order), then
again with Door B routed and Door C not -- the broken world reproduced,
where a Door-B-only test would have passed.
882 tests (868 before). The five byte-pinned goldens are untouched and
green; no shipped profile moved.
Co-Authored-By: Claude <claude-opus-5>
The registry file says one test per code and that this file IS the
conformance suite. During the 1-to-N voyage the byte-stability pin over
tests/ was the verification mechanism, so editing this file was
forbidden and six new codes were covered in the new segmentation
modules instead. That was right then. The end-to-end gate run released
the pin, so the convention applies again -- a conformance suite split
across two files stops being one quietly, which is why this was carried
as an obligation rather than a preference.
All seven segmentation codes now have a test here, including
segmentation_plan_unmatched from this session. The behavioural tests in
test_segmentation.py and test_segmented_inbox.py stay where they are:
they exercise the parser and the door, a different question from
whether every documented code has a raise site.
Measured after: 49 documented codes, 48 covered in this file. The one
gap, source_reference_unquotable, predates this work and is tested in
tests/test_okf_v0_2_profile.py:351 -- reported, not silently closed.
Also records both defects and this migration as closed in the decision
record's known-gaps list.
Co-Authored-By: Claude <claude-opus-5>
A segment's title comes from the plan, so a human adjudicated it. But
structure derivation runs over the segment body, finds no title key and
no usable heading, falls back to a stem, and adds "title" to derived.
The concept then emitted a stated fact under an inferred marker, and a
consumer that distrusts derived fields would distrust exactly the thing
a human decided. An over-marked field is the same defect class as an
unmarked heuristic: the marker is only worth something if it is
accurate in both directions.
Scoped to title alone, and pinned that way by test: number stays in
derived on a segment, because nothing about segmentation makes an
inferred document number declared. Without a segment a derived title is
still marked, so only a plan makes a title declared.
The SEGMENTED_V1 golden moves, which is the intended consequence and
the only golden that may. The four existing goldens are byte-identical
to baseline 770d8d4, measured against the sha rather than inspected.
Co-Authored-By: Claude <claude-opus-5>
A plan is selected by content hash, so a mistyped source_sha256 matched
nothing, every dropped file fell through to the one-concept rule, and
process_inbox returned an ordinary success over a flat bundle. The
operator asked for segmentation, got none, and had no error to read --
the silent skip this library refuses everywhere else. vegnormal-okf is
about to run an N500 corpus through this path, where a silent zero
would read as "the corpus has no concepts".
The refusal asks whether a covering plan was FOUND, not whether every
file was examined, so an unreadable drop cannot mask it; and coverage
is recorded at selection, not after path validation, so a matched plan
with a refused entry path still reports its own per-file code. The
first cut got that second question wrong and an existing collision test
caught it; the case is now pinned by its own test, verified red against
the earlier form.
New code segmentation_plan_unmatched, registered in the SegmentationError
docstring register in the same commit. Fail-fast before any disk
mutation. The four existing goldens are byte-identical to baseline.
Co-Authored-By: Claude <claude-opus-5>
The plan's Verification section held twelve gates that cross step
boundaries; /trekexecute checks per-step manifests and never ran them.
Run against the tree at 63565bd with the baseline at 770d8d4, before
any new commit, so the gates measure the delivered work and not
themselves.
Eleven passed. Gate 7 failed: one hunk in tests/test_profile.py, whose
closed field set on BundleProfile gained "segmentation" after
assumption A4 was falsified during execution and the edit ratified.
The gate's mechanism is byte-equality; the property it stands for was
measured directly instead — removing the member makes the test red, so
the assertion is a closed set with one more legitimate term, not a
loosened one. Recorded as failed rather than rewritten to pass.
Two mandatory known-positive controls fired: the zsh :e modifier ate a
path in git cat-file, and an unquoted for-loop did not word-split under
zsh. Both had produced a false absence that read as an answer.
Co-Authored-By: Claude <claude-opus-5>
Retiring a directory's last concept left its index.md standing, so the
directory survived a rebuild that never creates it -- diff -r reports it as
'Only in ...'. Found by S7 once its fixture was made to actually retire a
segment: with round 2 a superset of round 1, S7 stayed green with retirement
disabled entirely.
Order 20260827T123301Z-3790625395 asked for a judgement on a profile-named
`index.sort_key`, explicitly not for code. The finding is accepted and so is
the mechanism; the shape needs one correction.
Verified what the order asserts about us: Door C sorts its index on the
concept path and nothing else (`importer.py:421`, a plain `sorted(documents)`),
and no policy object anywhere in the chain can redirect it. `FacetPolicy`
governs what an entry CARRIES; nothing governs how entries are ARRANGED. A
consumer cannot reach this today, exactly as claimed.
The correction: naming a key is necessary and not sufficient, and the
insufficiency is where the requester's own measured gain lives. A sort needs a
key, a value and a COMPARATOR, and D5's formula seats only the first two.
Measured here: sorted() over N100.9/N100.10/N100.2/N100.20/N100.1/N99.1 puts
N100.10 before N100.2 and N99.1 after the whole N100 family. A `sort_key:
req_number` resolved through default ordering would therefore have produced a
different wrong order, not the 100.0% ascending over 24338 pairs that was
measured. The library already knows this one module over -- `_version_key`
(`structure.py:375`) exists for it and its comment says why.
So: three fields on `IndexPolicy`, not one on `FacetPolicy` -- `sort_key`,
`sort_order` from a closed set (`lexicographic` | `natural`, never a
caller-supplied callable, which would make emitted bytes depend on caller code
and break rebuild-equals-incremental), and `sort_missing`. Concept path stays
the final tie-break so the new ordering REFINES the existing total order rather
than replacing it; trading S8b determinism for retrieval quality would be the
worse failure.
Timing: build after the segmentation voyage lands, as a parameter on the
`_index_sort_key` seam Step 10 leaves behind, unifying both doors' index
writers in the same change. That seam is real and manifest-enforced, but it is
Door B's while this order is Door C's -- a profile naming an ordering that one
door honours and the other ignores would repeat the `IndexPolicy.per_directory`
trap.
Not claimed: that ordering explains the whole gap. T8-4 remains unexplained and
we attach no hypothesis to it either.
Order: 20260827T123301Z-3790625395-from-.claude
Co-Authored-By: Claude <claude-opus-5>
The line-oriented frontmatter grammar exists in three copies, each with the
duplication documented at its site: `materialize` reads a path, `structure`
needs a character offset, `profiles` returns body lines. All three keyed on
`key.strip()`, which discards the indentation that is the only thing telling
a nested key from a top-level one. An indented `title:` under a `sources:`
block therefore landed in the same flat namespace as the document's own
`title:` and, arriving later, won.
The failure is substitution, not omission. A dropped value is visible to
whoever reads the concept; a substituted one is not -- the document carries a
title that looks entirely right and belongs to something else. Because
`number` derives from `title` and `parent` derives from `number`, one
substitution walks the hierarchy. Measured, not inferred: a document titled
`N100.2` with a nested source titled `N200.7` came back as N200.7 with parent
N200 instead of N100.2 with parent N100.
Measured incidence across the two corpora, denominators stated:
`_okf-canonical` @ ad30107, 54 documents with parsable frontmatter, 49 carry
a nested key colliding with a top-level name (90.7%); `_okf-upstream` @
9a15b13, 66 documents, 58 collide (87.9%). The colliding key is `title`, and
often `resource` with it -- in `acme_retail/tables/orders.md` the concept's
own BigQuery resource pointer was replaced by a nested one. This is a fix
that clears observed damage, not a hardening without a witness.
The fix refuses indented lines; it does not read them. Block form stays
unreadable -- `sources` and `verified` still come back empty -- so D4's
flow-form emission rule is untouched and the structured reader is still D1b.
Two characterization tests that pinned the old behaviour now pin the new: the
block-list family still DROPS its value, and only the key-space pollution is
gone. That family is not otherwise addressed here.
Test first, red before the code was touched, with known-positive controls for
all three parsers so that a parser returning nothing could not pass.
Order: 20260830T000740Z-4733930312-from-.claude
Co-Authored-By: Claude <claude-opus-5>
Session briefs and voyage baselines live under .claude/projects/; the
remote is public, so they follow the same local-only rule as STATE.md.
The brief-review round 1 flagged that this protection was sitting
uncommitted while the brief already references corpus filenames.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JhzrFYGvFMXBz6mF6D7DEn
Leg 2 of the approved sequence, order 20260829T071322Z-683659895-from-.claude.
Arm A of the acid test, never run before because the vegnormal PDFs were
rastered 11/11. Measurement only: no parser, no src/ change, no runtime
dependency. docling was installed as a measuring tool in an isolated venv.
pandoc 3.10.2 ships an xlsx reader. The 08-29 count reached for markitdown on
xlsx because that reader was assumed absent; it is not.
G2 holds on the Excel half: the 207 merged ranges in Bilag 7 Prisskjema pair
61/61 for all three converters. Merged geometry inflates volume without
breaking label-value pairing.
pandoc takes docx (193/196 source-string coverage, 51/53 paired, 1/1 footnote)
and xlsx (193/193, 98/98, at 9 979 non-whitespace chars against docling's
58 875 for identical coverage - docling replicates a merged cell into every
spanned column, 8x on the two measured). markitdown corrupts embedded cell
newlines into the literal two-character \n; correcting for that moves its xlsx
coverage from 184/193 to 191/193.
No converter beats the incumbent pdftotext on pdf: pandoc has no reader
(rc 21, 33/33), markitdown pairs 534/9 027 (5.9 %, same pdfminer.six failure
this repo measured independently on 2026-08-21), and docling's PDF path is
blocked - transformers 5.8.1 needs torch >= 2.4 and no x86-64 macOS wheel
exists past 2.2.2. Reported as 3 attempted, 3 failed, 30 not attempted,
because the failure precedes any file read.
docling is the only converter returning content from the Word 97 .doc.
markitdown returns exit 0 with a wrong document class on it, and a stub on
.smc/.zip; docling returns exit 0 with no file at all on those two.
Denominators come from the source's own OOXML and from raw page geometry, never
from a converter under test. Notes have no denominator here - the corpus holds
0 real footnotes or comments - so capability is characterised against an
injected known-positive instead, where docling drops the footnote 0/1 while the
other two capture it.
Stated as a measured gap, not a footnote: K1 carried the revision pairs, K2's
two stages are byte-identical, so kravspek B1 4.7 (a) utgavepar is uncovered by
this corpus.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ge8kWitAAx5bJh7FW9r3u
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