Commit graph

161 commits

Author SHA1 Message Date
5f524f3902 test(extract): reach extra-missing through the import probe
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>
2026-09-02 14:00:24 +02:00
11cde6d0b5 docs(plan): amendments etter PM-dom 02.09
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>
2026-09-02 13:58:21 +02:00
169dfdf550 docs(plan): record the three intake decisions and what measured them
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>
2026-09-01 22:32:24 +02:00
2a63223ff4 docs(intake): write down the boundaries that will not be closed
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>
2026-09-01 21:51:59 +02:00
7f568b3691 test(index): pin Door C's cross-run ordering bound
`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>
2026-09-01 19:58:19 +02:00
d2a8c43d77 feat(index): one ordering helper, called by both doors
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>
2026-09-01 19:54:45 +02:00
ac6dffe51e test(errors): reunite the segmentation codes with the conformance suite
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>
2026-09-01 19:31:51 +02:00
f65f5fc342 fix(inbox): a segment title is declared, never marked derived
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>
2026-09-01 19:29:46 +02:00
8f9b4c8cca fix(inbox): refuse a segmentation plan that matches no dropped file
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>
2026-09-01 19:27:27 +02:00
36af65c0c9 docs(plan): record the end-to-end gate run and its one failure
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>
2026-09-01 19:24:02 +02:00
63565bde98 feat(examples): SEGMENTED_V1 golden fixture and decision record 2026-09-01 00:31:15 +02:00
2cfb358b76 feat(tools): segmentation proposer emitting adjudicable PROPOSED entries 2026-09-01 00:28:41 +02:00
ba6e287585 test(inbox): rebuild equals incremental across a re-split with varying timestamps 2026-09-01 00:25:35 +02:00
894ccdf42f fix(inbox): retire the orphaned index when a directory's last segment goes
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.
2026-09-01 00:25:29 +02:00
91efd92612 feat(inbox): per-directory indexes with parent-to-child navigation 2026-09-01 00:21:05 +02:00
b9d776d1f0 feat(inbox): own concepts by source_file and retire stale segments 2026-09-01 00:17:04 +02:00
34a00b746f feat(inbox): collision gate over segment paths and additive concept reporting 2026-09-01 00:14:13 +02:00
bb79c55f43 feat(inbox): materialize one document into many concepts on hierarchical paths 2026-09-01 00:10:13 +02:00
f6e5ec9305 feat(inbox): mirror bundle_id and segment keys into concept frontmatter 2026-09-01 00:06:05 +02:00
224121f762 feat(inbox): root frontmatter emission at Door B with a caller-owned bundle_id 2026-09-01 00:04:09 +02:00
cd2c7517b0 feat(profiles): SEGMENTED_V1 profile and SegmentationPolicy capability 2026-09-01 00:02:14 +02:00
499253e53b feat(segmentation): slice canonical extracted text by declared spans 2026-08-31 23:59:05 +02:00
832e541fb9 feat(segmentation): key the adjudication cache on the extractor, not the hash alone 2026-08-31 23:57:49 +02:00
0a11c860ce feat(segmentation): segmentation plan data model and typed errors 2026-08-31 23:56:57 +02:00
770d8d4fbf docs(index): assess a consumer-controlled index ordering - accept, reshape
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>
2026-08-31 23:35:25 +02:00
2337a328d9 fix(frontmatter): a nested key must not substitute for a top-level one
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>
2026-08-31 23:31:53 +02:00
087be0bc87 chore(gitignore): keep .claude/projects/ local-only
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
2026-08-31 12:46:43 +02:00
c07fe89cef docs(measure): run the conversion arm on K2 - pandoc wins both office formats
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
2026-08-30 00:41:48 +02:00
2827be0ece 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
2026-08-29 09:26:57 +02:00
6572e9ec4d docs(measure): count the public project corpus - K2 measured, K1 blocked
Step 1 of the approved sequence (scenarioanalyse SS 6): produce numbers for a
heterogeneous project corpus after conversion. Measurement only - no code, no
parser, no bundle, no dependency; src/ untouched and the corpus lives outside
the repo.

Headline: 1 595 054 characters after conversion across 43 unique files,
844 PDF pages, 0 conversion failures of 40 attempted.

Two order premises moved under measurement:

- SS 9 marks K1 Skram "open, tested". It is not. K1 serves 79 filenames as
  plain text with no link and no file id for an anonymous visitor, on all
  three URL variants (known-positive: the same parser extracts 43/43 links
  from K2). The 142.8 KB PDF that "proved the mechanism" on 28.08 is a K2
  file - 146 242 bytes, Del I Vedlegg 5. The tested corpus was K2 all along.
  Per the order, K1 is reported blocked rather than substituted.
- SS 9 calls K2's two stages a near-duplicate. All 43 files are byte-identical
  by sha256, 0 differing. The corpus therefore contains no revision pair.

SS 9's file counts were exact for both corpora (79 and 43); the access and
duplication claims were not.

Also measured, closing an explicit "not verified" in SS 8: openpyxl
data_only=True returned a cached value for 52 of 52 formula cells, 0 None.
Bounded to the one workbook that has formulas.

Absences carry denominator, exit status and a known-positive throughout:
0 scanned PDFs (0 of 33 zero-font), 0 pptx (0 of 86, exit 0, xlsx control = 4),
0 login walls (0 of 86, control = 1).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3Ghu6sgMSsycFDrZDGzd6
2026-08-29 07:03:50 +02:00
c9f86702a9 docs(plan): record the K2 key inventory — 17 SPEC keys and 33 own, each classified with a command
Order 20260827T151230Z-5121410292-from-.claude, K2 of 4 mapping orders.
Written by a prior Fable 5 session that died before committing; this
session verified and commits it, does not rewrite it.

Self-check gate re-run independently: 138 pipe rows, 18 without a
backtick command = 9 table separators + 9 table headers -> 0 data rows
lack a command. Matches the sender's claimed numbers exactly.

KNOWN INCOMPLETENESS, not fixed here: the document's own trailing
"Selvsjekk-porten" section (last section, ending the file) is an
unfilled template — literal placeholders SELFCHECK_TOTAL,
SELFCHECK_NOCMD, SELFCHECK_SEP, SELFCHECK_HDR, SELFCHECK_SENTENCE were
never substituted with the computed values, because the writing
session died before running its own closing self-check command. The
body (Del A, B, C) is complete; only this trailing section is a stub.
Left as-is per order (do not rewrite the deliverable).

No code changes, no new measurement arm, no re-emission of bundles, no
publishing, no recommendation on closing any gap — mapping-phase order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 03:48:59 +02:00
d35bcb21ec docs(plan): record the Door C facet decisions, the mutation result, and the harness that lied
Four decisions: Door C projects and never derives (D-C1), the projection is
key-agnostic so it is not about numbering (D-C2), an unrenderable value drops
the facet and never the concept (D-C3), and a faceted entry is refreshed
because it is the first entry that can go stale (D-C4).

Also records a measurement about the measuring. The mutation harness first
reported 11 of 11 killed; the number was entirely false, because it invoked
pytest with an unrecognised `--timeout` flag and every run exited non-zero. The
negative control caught it by being reported killed when it provably could not
be. Honest result is 9 of 11, with both survivors analysed as equivalent
mutants and the one real gap they exposed (profile threading pinned only by
coincidence) closed with a direct test.

Closes open item 6 of the Door B record, which asked exactly the question this
answers: whose claim does the frontmatter make.
2026-08-27 11:03:37 +02:00
1f7d3502b8 feat(importer): Door C projects the sender's own facets into the index
vegnormal-okf measured the gap on 2026-08-27: the arm reading DEFAULT's
index.md scored 0 hits of 8, the arm reading a faceted index of the same
frontmatter scored 25 of 29. Same bundle, same concepts, same model. The
DEFAULT arm did not answer wrongly, it abstained -- the metadata is in the
bundle and the index throws it away (30 974 characters over 269
requirements, 0 occurrences of any of the eight facts).

FacetPolicy and STRUCTURED_V1 already did this. They did not reach Door C.

`import_bundle` now takes a keyword-only `profile` defaulting to DEFAULT, so
every existing call site emits the bytes it always did, and `link_in_index`
takes the facets to render.

Door C PROJECTS and never DERIVES, which is the answer to the objection this
work opened with: deriving structure for a document a third party wrote would
put our inference into an index entry ABOUT their bytes, where it reads as
their claim. The concept file was already verbatim; the entry describing it
now is too. Where the sender carries `derived`, THEIR list travels unchanged,
so a reader can still see which of the sender's facts the sender inferred.

The projection asks the policy which keys to carry and never what a key
means. That is what makes it work for a meeting note as well as a numbered
norm -- nothing in it can key off a numbering scheme -- and it is why a
consumer whose concepts are named by UUID can get `title` into the index by
naming the key, with no change here.

Two things measured during the work rather than assumed:

- A value carrying the policy's own joiner cannot be rendered. Door C's
  tolerance is structural and it refuses no sender on form, so the FACET is
  dropped and the concept still merges -- reported per concept and key in
  `ImportResult.unrendered_facets`, never dropped silently.
- A faceted entry can go stale where a flat one never could: the collision
  gate refuses an updated concept, so the operator's only route is to remove
  the merged file and re-import, after which the file said `gjeldende` while
  the index still said `utkast`. A faceted entry for a present target is now
  refreshed in place instead of skipped. Unfaceted callers keep the early
  return byte for byte.

Suite 695 -> 707; ruff and mypy --strict clean.

Order 20260826T224500Z-873805419-from-vegnormal-okf.
2026-08-27 10:58:33 +02:00
69001542b2 docs(plan): inherit the external negative finding on edge inference
A deep-research report arrived mid-session (.claude broadcast 20260826T223230Z)
on a near-identical problem shape -- numbered regulatory requirements with
cross-references. Two of its numbers bear on this work: index-selection
strategy contributed +38.0 points of accuracy, and edge inference gave NO
accuracy gain at 2.8x the cost.

The second is a negative finding worth inheriting rather than re-measuring. It
does not condemn what landed today: references and parent are EXTRACTED from
explicit tokens, and the one proposed relation is structural and costs a single
pass. It draws a line for later -- no semantic edge inference without measuring
that 2.8x against our own corpus first.

The report also states that no published source gives per-query token counts
for structured versus flat context, and none reports an indexed superseded-by
facet. That reframes today's 3.3x-6.4x cost dial: it is the tradeoff nobody has
published, which is a reason to measure it properly rather than to hide it.

Treated as a premise, not a result. An external number changes what is worth
trying next, not what this repo has proved.
2026-08-27 00:41:41 +02:00
cf65635648 docs(plan): record the structure-derivation decisions, mutations and cost
Six decisions, each with the failure it prevents: confidence carried in the
format, certainty stated per field, supersession never inferred from one
document, the index as a projection rather than an append, facets as a new
profile, and Door B's ownership stamp left deliberately open.

Six findings reported as findings rather than as gaps, per the order: the cost
of closing the metadata gap is 3.3x-6.4x the flat index and whether that keeps
the OKF arm's cost advantage cannot be answered from this repo -- it needs
their bake-off re-run on their corpus; a reference to a parent-level number
does not resolve to a child, and the fix needs a real corpus before 'unique'
can be defended; OKF_V0_2's verified/status/stale_after are still expressible
and unwritten for the reason already recorded.

Every number carries its denominator, and the supersedes 0/55 carries a
known-positive control (50/55 on a corpus that has shared numbers) proving the
query can find what it reported missing.
2026-08-27 00:39:48 +02:00
7ce548c09b feat(inbox): Door B derives structure and reprojects the index additively
Door B takes a profile (keyword-only, DEFAULT) and, under a profile carrying
facets, derives each dropped document's title, number, hierarchy and
cross-references, writes them into the concept's own frontmatter, and projects
them into the index entry.

The additive requirement is answered by one decision rather than by an
algorithm: the index is a PROJECTION of the concept files, recomputed from the
whole bundle each round. Nothing is diffed, so the three invariants hold by
construction -- rebuild-from-scratch equals incremental byte for byte,
re-dropping a document replaces its entry instead of doubling it, and a
relation formed in round 3 UPDATES the round-1 entry it is about, which an
append-only index could never do.

An unresolved pointer is marked '?' in the entry rather than omitted: during
build-up, pointing at something not dropped yet is normal, and the dangerous
version is the one that leaves no trace. Facet values are validated per file
BEFORE the write, so a producer value that breaks the grammar fails that file
and not the run.

DEFAULT is byte-identical with and without the new parameter, and is asserted
so. Door B keeps writing the literal 'generated: true' rather than the
profile's ownership stamp -- routing it through the profile would move
DEFAULT's bytes and orphan every bundle this door has already written; that is
a separate question and answering it here would have answered it silently.

18 new tests; suite 677 -> 695.
2026-08-27 00:37:12 +02:00
52c82bc3d1 feat(profiles): a faceted index policy and the additive STRUCTURED_V1 profile
The measured defect, as data: the 2026-08-26 bake-off had every arm retrieve
40/40, so quality could not separate them. The only axis that did was trap
exposure -- 18/20 for the OKF-index arm against 8/20 for a frontmatter
head-scan -- and both sides measured the reason independently: the flat index
carries title/date/status/supersedes 0 times while its own documents carry them
55/55/55/5. The metadata is in the bundle; the index throws it away.

FacetPolicy lets an index entry keep it. The grammar is thin on purpose (one
separator, then key: value joined by '; ') because index lines are read by
regex on both sides of this library, and a value carrying either delimiter is
REFUSED rather than escaped -- validation, not repair, as everywhere else here.

Additive by construction, not by caution. entry_pattern IS link_pattern when a
policy carries no facets, so DEFAULT and STRICT_V1 match the same lines and
emit the same bytes; the goldens are the proof. Facets arrive as STRUCTURED_V1,
a new profile, because DEFAULT states commons' ingest-spec index layer and
changing its bytes from here would be this repo editing a contract it does not
own.

17 new tests; suite 660 -> 677.
2026-08-27 00:30:42 +02:00
05cda5ded5 feat(structure): derive numbering, hierarchy and cross-references, marked declared vs derived
A bundle a consumer can only look things up in is a filing cabinet. This adds
the derivation half of what lets one REASON over it: per-document title,
number, parent and references, plus bundle-level resolution of every pointer.

Two rules carry the design. Every fact is marked DECLARED or DERIVED, because
an unmarked heuristic is worse than no heuristic -- a consumer cannot know when
to doubt it. And resolution is a PURE function of the whole document set rather
than a diff, which is what makes rebuild-from-scratch equal an incremental
update by construction: there is no diffing algorithm to prove correct.

An unresolved pointer is kept and reported, never dropped: while a bundle is
built up over several rounds, pointing at something not dropped yet is the
normal state, and the dangerous version of it is the one that leaves no trace.
Symmetrically, a parent our own grammar could never admit (4.2 -> 4, a bare
integer) is not emitted at all -- an unresolved list that never clears is one a
consumer learns to ignore.

45 new tests; suite 615 -> 660.
2026-08-27 00:25:44 +02:00
dc9ea599c5 docs(plan): answer the Door B/A capability-gap order from ms-ai-architect
ms-ai-architect's bake-off measured that no door carries both freetext
ingestion and a formable index. Verified against the code (not the
report) that neither offered alternative closes it: a BundleProfile
carries no title-derivation field, and a Door A connector was already
rejected by the consumer's own 2026-07-20 reasoning. The real gap is a
Door B title-derivation feature, independent of profile selection;
recorded as a decision only, no implementation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 23:54:35 +02:00
01e4170fe4 docs(plan): make retrieval cost a first-order element of the consumption contract
Second order 20260825T133057Z-193098605-from-.claude, carrying an operator
directive that how an agent finds information in a bundle must be
token-efficient, as a property the contract carries rather than a later
optimisation. The order prescribed the goal, left the form to us, and invited
the objection that "token-efficiency" is the wrong abstraction for a generic
layer. It is, so the element is not called that.

C6 — retrieval cost is declared: instrument, budget, denominator, breaking
point. It binds the READING side (the order's property 1): a conformant
consumption skill states the bound its cut targets, what a typical answer costs
against the whole-bundle denominator by a named instrument, which way cost
scales, and the corpus size at which its strategy stops fitting its budget.
Each is re-runnable as a command (property 2), and the instrument must
reproduce known-good figures before its own are believed — C3's second
corollary applied to a positive quantity.

Why not "token efficiency": a token is one encoder family's unit and fixing it
would adopt one vendor's arithmetic as everyone's (the move §3 already refuses);
"efficient" is a comparative with the denominator missing; and efficiency is a
property of a run while a contract binds a document. Reading a whole bundle
stays legal — doing it UNDECLARED does not.

Measured for this revision, all against stated refs: the gap was real (one
pre-revision hit, on an unrelated sense of "costs"); the cited 250 785 /
430 144 / 112 116 figures and their prior validation against three known-good
values hold; the reading model navigates rather than searches. A fifth
measurement the order did not know, prompted by vegnormal-okf's coord message:
IndexPolicy.per_directory is a JUDGING field (required_indexes/violations) —
no code path here emits a nested index, and materialize refuses STRICT_V1 for
exactly that reason. So a permitted shape is not an emitted shape, and §2 now
says a cost figure inherits the ref rather than the profile.

Bundle SHAPE stays with the repo that produces it (O2); no instrument is
blessed; no engine, ranker or cutter is designed. No src/, no bump, no tag.
2026-08-25 23:38:34 +02:00
d205b707da docs(plan): take ownership of the generic OKF bundle consumption contract
Plan work only, per operator decision 2026-08-25 relayed through .claude
(order 20260825T122454Z-4632317874). No code, no module, no version bump.

Fixes four things a bundle-specific consumption skill can be held to:

- The contract's five bundle-independent elements: source marking, pin
  anchoring by ref rather than version, denominator reporting, the
  script-cuts/agent-judges division of labour, and (added here, grounded
  in a measurement in a second corpus) conditionally-written fields,
  whose absence is a measurement rather than a fact.
- Architecture: a parallel document, not a field on BundleProfile. The
  profile is a construction-time gate in one runtime and deliberately
  excludes judgement vocabulary. Drift is prevented by shared profile
  identity plus a pin plus a requirement that structural claims be
  re-measured by command, never restated in prose. IndexPolicy's
  entries_match_directory binds the consumption side unchanged.
- The source-marking vocabulary: five required values plus a declared
  extension mechanism. [verified-in-target] is an extension, not a
  required value, because it presupposes a target outside the bundle
  and a measured corpus exists where that presupposition fails.
- A conformance checklist for a bundle-specific consumption skill.

The order's premise that the 2026-08-13 ratification was unfounded does
not survive re-measurement: the supporting grep was scoped to 16 of 639
files and missed the tracked state file that records the decision. The
real defect is narrower and belongs to another repo — the document its
own engine names as requirements owner has not been updated. Reported
by coord, not edited (O2).
2026-08-25 15:06:51 +02:00
6b2a4d3bc3 chore: bump classifier to Alpha, rename README scope heading
Reflects actual maturity (615 passing tests, real consumer): PyPI
classifier "2 - Pre-Alpha" -> "3 - Alpha". README heading "Planned
scope (v1)" -> "Implemented scope (v1)" since all three doors (A/B/C)
are implemented. Cosmetic only, no functional change. Operator
decision recorded in docs/2026-08-24-beslutninger-classifier-a5-f7.local.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGwbxHqib3QcaFa7hhMpB7
2026-08-25 06:33:39 +02:00
e60f7261a1 build(deps): move the guard pin from >=0.3,<0.4 to >=1.2,<2.0
The operator's condition for revisiting the pin was met (v1.2.0 contains
the flow-mapping frontmatter fix, commit 5870483) and the operator has now
approved the move itself, dispatched as its own order because it changes
consumer-visible Requires-Dist and shifts golden-fixture admission.

Floor 1.2: this library needs the flow-mapping support (`generated: { by:
x, at: y }`) that landed there — without it Door C fail-secures every
concept carrying that stamp. Ceiling <2.0, not narrower: the guard's own
1.0.0 release freezes its exported surface until a 2.0.0, and explicitly
keeps calibration (severities, dispositions) free to move within 1.x, so a
tighter ceiling here would claim a stability guarantee neither side needs.

Re-measured through Door C against the guard's own default
(allow_reserved=True, matching how the earlier recommendation measured
it), over the 9 concept documents across all four Door-A goldens:
4/9 admitted at 0.3.4 -> 8/9 admitted at 1.2.0, confirming the number
already reported. The ninth (a `sources` block-list carrying `resource`)
stays refused by design (G30) and is not expected to move.

Failing-test-first: test_guard_adapter.py::test_guard_version_is_inside_the_pin
and test_packaging.py::test_the_only_runtime_dependency_is_the_security_boundary
were updated to the new pin first and confirmed red against the
still-installed 0.3.4, then `uv sync --extra extract` installed 1.2.0 and
both went green. Full suite (615), ruff and mypy --strict clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RxcxzNwpX1kDP53n1rLhM5
2026-08-23 21:05:18 +02:00
3233b19b30 feat(watch): weekly OKF upstream watch that can prove it found nothing
The operator asked for a job that checks at least weekly whether Google OKF has
moved, and messages the right repo immediately when it has. It belongs here
rather than in `.claude` because knowing what a meaningful spec change IS
requires owning the pin, the runbook and the always-latest policy.

`tools/okf_watch.py`, stdlib only, driving git against the local read-only
mirror. It lives outside `src/` so it never enters a wheel; a new packaging test
holds that as a promise rather than an accident of the build config.

Three properties carry the design, and each closes a failure this repo has
actually met:

1. A failed call is never an empty result. Every git invocation raises on a
   non-zero exit and carries stderr, so a caller reading "" knows the query ran.
   The precedent is `grep ... | head; echo $?` reporting head's exit status - a
   broken query read as a quiet upstream.
2. It proves it can find, every run. Before believing any zero it re-runs the
   full detect-and-classify path over `ad30107^1..ad30107`, a range known to have
   changed SPEC.md. An empty known-positive aborts loudly rather than reporting a
   clean sweep. Network failure likewise raises; it never degrades to "no change".
3. It reports on change, not on state. A pin-keyed state file records what has
   been announced; moving the pin resets it, because a pin move means everything
   behind it was absorbed.

Quiet is the enumerated list, not signal. Enumerating what counts as normative
can only match what upstream has already invented, so anything new would fall
outside it and the watch would go silent - failing in the direction nobody
notices. A small measured quiet list, everything else reports. README.md is
deliberately not quiet: the repository move was announced in a README commit.

Sixteen tests build their own git repository in tmp_path rather than skipping
when the mirror is absent - a skipped test preserves nothing on the machine
where the dependency exists. All four load-bearing behaviours were mutation-
tested red before this landed.

Two more tests exist because building this fired a real false alarm: running
with `--pin` and without `--dry-run` delivered two live coord messages. The
override now implies dry-run, enforced in argument parsing rather than
remembered, and `.claude` has the correction.

The runbook gains a section stating what the watch CANNOT do, because that is
the part a future session will otherwise assume away: it sees commits, not
meaning. It would have fired on the 2026-08 tightening because SPEC.md changed,
but no commit list says a value that conformed last month no longer does, and
none says is_stale reversed. Its output is "run the runbook", never "here is
your exposure".
2026-08-23 20:38:37 +02:00
e286b5a173 docs(upstream): move the OKF pin to canonical ad30107, re-record V-A8
The operator authorized the move from `3fcbb9f` (frozen `knowledge-catalog/okf/`)
to `ad30107` in `GoogleCloudPlatform/open-knowledge-format`. Cost to our fixtures
was measured at zero bytes before the decision and is unchanged: `okf_spec_commit`
is a key `STRICT_V1` names, its value is the caller's under D5, and no shipped
fixture carries one.

The old hash stays in the alignment plan rather than being rewritten. Every fact
under that heading was *read* at `3fcbb9f`; replacing the hash would give those
facts a provenance they do not have. What moves is the tree we measure against.

V-A8 re-run at the new pin: 9/9 of our golden documents parse under upstream's
reader, 0 failures, 27/27 semantic-reader calls (`trust_tier`,
`normalize_verified`, `is_stale`) return without raising. The zeroes are
measurements — the harness was put to a known-positive in both directions first:
`type: [unclosed` raises `OKFDocumentError` while a well-formed control parses,
and `is_stale` returns True for a past `stale_after` and False for a future one.
Types recovered: `ingested_at` -> str (was datetime), `generated` -> dict,
`sources` -> list, `okf_version` -> float (A-E6, still upstream's).

The `38c713f` divergence is carried forward as a written known divergence rather
than left to evaporate with the pin, and it is now recorded at pin-level
precision. `38c713f` is not an ancestor of `3fcbb9f` (`merge-base
--is-ancestor` exits 1) — it landed three weeks forward of the pin we left, so
both the old and the new pin carry the bare-scalar `tags:` form and the move
loses nothing. The accurate statement is that canonical ships a form its own
frozen predecessor has already repaired.

The open question is closed rather than deferred, because it was cheap: across
129 tracked text files present in both trees, 9 carry frozen-only lines - 8 are
`38c713f` and the 9th is the freeze notice in `README.md`. Zero files are
frozen-only. The divergence is fully enumerated.

Runbook Step 3a gains the harness trap that cost this round a re-run: the
semantic readers take the frontmatter mapping, not the document, and passing the
document raises an AttributeError that reads like an upstream incompatibility.
2026-08-23 20:30:22 +02:00
32b4b76dc4 docs(guard): re-measure the OKF grammar across four guard points, pin holds
Order G34 asked whether the corpus-wide rejection of a conformant `sources`
block reproduces against guard 1.1.0, on the premise that 1.1.0 introduced a
mapping-key allowlist on 08-21. It does reproduce, and the premise is off by
one release: v1.1.0 is dated 08-13 and carries no allowlist. The allowlist is
`5870483`, nine commits past that tag and carrying no tag of its own -- its
`__version__` still reads 1.1.0.

Measured, each point extracted with `git archive` and put on PYTHONPATH ahead
of the venv, resolved version confirmed per run. The conformant variant is 0
merged and the control 34 of 34 at 0.3.4, 1.0.0, 1.1.0 AND the allowlist
commit; the control merging is what shows the harness can pass at all. Seven
candidate `sources` carriers, and none that names a `resource` merges at any
of the four points, because `resource` is deliberately off the allowlist while
SPEC 5.1 makes it REQUIRED inside an entry.

Two things the plan did not know. v1.1.0 closed the one-key block sequence,
which the 2026-08-02 measurement recorded as the only shape carrying a section
10 pointer through Door C, so `unverified_references` is empty by construction
at >=1.1. And the allowlist commit moves this library's own goldens from 4 of
9 to 8 of 9 concepts through Door C, the `generated` stamp being exactly the
shape it admits.

The pin therefore holds at `>=0.3,<0.4` / `v0.3.4`. Lifting to v1.1.0 is
behaviour-free here (595 pass, the one red being the pin assertion itself) and
buys nothing on this axis; the gain sits on an untagged commit, and pinning
one trades a release channel for a moving target. The condition to revisit is
a fact, not a date: a guard tag containing `5870483`.

The 2026-08-02 table is left standing and dated rather than rewritten -- it is
true of the version it names.
2026-08-23 19:26:42 +02:00
b1c2f5ecee docs(upstream): run the runbook against OKF at its new canonical repo
Operator GO 2026-08-23. Two findings outrank the change that triggered
the round.

OKF moved to GoogleCloudPlatform/open-knowledge-format (6265173). The
tree this repo pinned, knowledge-catalog path okf/, is now a frozen
snapshot by upstream's own notice, and the runbook's Trigger step named
it. The two trees have already diverged: the frozen copy carries a fix
(38c713f, eight tags: values as sequences rather than one plain scalar)
that the canonical repo does not.

The spec tightened without a version bump. SPEC.md still declares
Version 0.2, but every timestamp-valued key is now an ISO 8601 datetime
with an explicit offset, so a value that conformed in July does not
conform now under the same version number. Step 4 gained a fifth diff
class for it.

Our exposure is zero, measured: 10 of 10 timestamp values across the
four goldens already carry a Z offset, gated by _INGESTED_AT_RE. The
three tightened keys (stale_after, last_modified, usage_window) are
named but never written by any door. V-A8 at the new pin: 9/9 of our
bundle documents parse, 78/78 of upstream's do.

Two reference-reader behaviour changes recorded. A date-only
stale_after now reads as never stale rather than as stale -- silent,
fails open, and the class the black box cannot absorb. Timestamps now
survive a parse as written, so our ingested_at arrives as str where it
used to arrive as datetime.

No new profile: there is no new version to express. The pin move and
consumer notification are named as deliverables and deliberately not
taken here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013j8ZEcjiZQpz22fecNsyLZ
2026-08-23 18:54:15 +02:00
fb9812fbe7 docs(install): measure the uv install channel and correct the per-tree wheel range
The comment on [tool.uv.sources] claimed the built wheel carries
`Requires-Dist: llm-ingestion-guard<0.3,>=0.2`. That is the `v0.4.0` tag's
range, not this tree's, and it had been stale since the pin moved. A wheel
built from this tree carries `<0.4,>=0.3`, measured against the built wheel.
The old value is kept and attributed to the tag it belongs to rather than
substituted, because it is still true there.

Five measurements were run before editing, on uv 0.9.8 with an empty cache,
because the plan of record was to REMOVE this entry and the README claim it
supports had never been measured in more than one form:

- uv, direct: the README one-command install resolves the guard from the
  tag's [tool.uv.sources]. Third independent confirmation (07-25, 08-20,
  08-21).
- uv, transitive: a separate consumer project naming only this package still
  resolves the guard from the entry, because this package reaches it as a git
  source. Not previously measured.
- pip, negative: installing this package alone fails with exactly the error
  the README names, and the message prints the tag's own range.
- pip, positive: the README's two commands in order install clean and import.
- core install: brings the guard and no binary parser packages.

The entry is therefore load-bearing, not scaffolding: a wheel carries
Requires-Dist and nothing else, so it cannot survive an index install, and
while the guard is off-index removing it would break the documented uv path.
No package index carries the guard today, which was the premise removal
depended on.

The README install block measured correct as published and is unchanged. Its
test count had drifted: 596 with the [extract] extra, 589 passed and 7 skipped
without, both measured today.

Wheel metadata is byte-identical before and after, so the change is inert.
2026-08-21 21:10:28 +02:00
658b7aafe0 feat(extract): implement pdf behind the [extract] extra with pdfplumber
Order G2a. Populates the optional `[extract]` extra for the first time with
one parser, `pdfplumber>=0.11.10,<0.12` (MIT), and wires `pdf` through it.
The default install is untouched: exactly one runtime dependency, stdlib
otherwise, enforced by test_packaging.py.

The gate for `pdf` becomes an import probe rather than a frozenset membership
test, exactly as extract.py's docstring had promised. The rejection does not
change: without the extra, `pdf` still raises `extractor_extra_missing` with
the same message. That behaviour is asserted UNCONDITIONALLY via a sys.modules
monkeypatch, so it holds on machines where the parser is installed too — a
skip would have preserved nothing there. Verified in a clean venv without the
extra: 589 passed, 7 skipped; with it, 596 passed.

`docx`/`xlsx` are unchanged and still fail fast — the extra names exactly what
it ships.

The parser choice was forced by measurement, not preference (b73dd9d,
docs/2026-08-21-g2-pdf-extraction-measurement.md): on a real requirement table
pdfplumber keeps 4 of 4 rows with label and value on one line, where pypdf,
pdfminer.six and pymupdf each keep 0 of 4. pymupdf is additionally out on
licence (AGPL-3.0), which an MIT package must not push onto a consumer.

Three facts from that measurement are now carried in code rather than in a
report:

- Extracted text is pinned to an exact transitive parser version
  (pdfplumber pins pdfminer.six==20260107; date-stamped, no stability
  contract). tests/test_extract.py freezes the expected text of a committed
  hand-written fixture so a parser upgrade breaks something visible instead of
  drifting silently. Reasoning at the declaration site and in
  tests/fixtures/README.md.
- Determinism within a version is now held by a test, not only measured once.
- Drawn content does not survive extraction. Every pdf extraction emits the
  new `ExtractionWarning`: figures have no text to recover, so a bundle built
  from drawn documents is incomplete by construction. Stated categorically
  rather than detected — deciding "is there a figure here" is the layout
  heuristic G2b declined.

Two new error codes, both mirroring existing patterns: `extractor_empty_pdf`
(a scanned/image-only PDF, refused rather than persisted as an empty concept)
and `extractor_pdf_error` (parser failure wrapped, never leaked).

Structured table recovery (G2b) is NOT implemented and is documented as out of
scope: two independent parsers return the same wrong shape, so the breakage is
document geometry, not a library choice. PDFs enter as prose.

Also corrects an install promise this change would otherwise have published:
the README no longer presents a bare `pip install 'llm-ingestion-okf[extract]'`
as working, because the package is not on an index.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HtNhsdHnMGtMi7U2mvMU8z
2026-08-21 20:22:39 +02:00
b73dd9d6a4 docs(extract): measure one Vegnormalene PDF page against the extraction registry
Order 20260821T170054Z-486638087-from-.claude (gap G2). Measurement only: no
parser implemented, no version bump, no pin move.

Measured on Handbok N200 Vegbygging (juli 2018), 308 pages, page index 150:
the registry rejects .pdf with extractor_extra_missing while .md/.csv controls
pass in the same call, and process_inbox reports the file as failed without
aborting the run. pdfplumber recovers Tabell 524.1 as 4/4 correctly paired text
lines where pypdf, pdfminer.six and pymupdf all score 0/4; both structural
extractors return the same wrong 2x6 grid, so table structure is the document's
geometry rather than a library defect. Whole book: 308/308 pages yield text,
45 of 196 detected tables are clean enough for render_table.

Verdict: text extraction is a small, bounded job; structured table recovery is a
separate project that nothing currently waits on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xTq1nbpz9x34udpDDExWM
2026-08-21 19:16:15 +02:00