A concept named its source file by basename and, when segmented, carried a
`source_offset` into the text THIS LIBRARY extracted. Following that pointer
needed the corpus directory, the extractor and its exact transitive version --
none of which the bundle carries. Hand-walked on a real K2 concept: six steps,
four of them requiring knowledge from outside the bundle, to learn that a
requirement sits on pages 12-13 of a 20-page document.
The address is spec's: `sources: [{ resource, title }]`, where `resource` is
the dropped file's inbox-relative path (SPEC v0.2 5.1:303-306 -- "an absolute
URL, a bundle-relative path, or a path into a `references/` subdirectory").
The locator is ours, and it has to be: 5.1 has no field for a place within a
resource, and the pinned guard (1.3.0) rejects every route to putting one
inside a `sources` entry -- a non-allowlisted key by name, a nested flow list
as "scalar leaves only", and quoting as an unsupported form. So the locator is
top-level keys shaped like `source_offset`, and a path carrying a flow
terminator is refused fail-fast rather than mangled.
The unit table is built AT EXTRACTION, where the extracted text and the
original's structure are known to agree: pdf -> `source_pages` from
pdfplumber's own page numbers (a page that yielded no text does not renumber
the ones after it), xlsx -> `source_sheet` + `source_rows`, everything else ->
`source_lines`. `source_offset` stays.
Two measurements changed the design before it shipped. A `paragraphs` key for
docx would name a number the document does not have: `<w:p>` counts of
108/27/65/176/57 against converted-markdown lines of 75/33/67/144/63, not one
pair agreeing -- so the key is `source_lines` and says what it indexes. And an
empty spreadsheet row renders exactly like a table separator: the content-based
rule ate 8 empty rows on the K2 price sheet and reported its last row as 92
against a workbook that says 100. The separator is now found by position, and
`tomrad.xlsx` keeps that red.
One profile moves. `provenance` is a policy object, `None` everywhere but
`SEGMENTED_OKF_V0_2`; the other five shipped profiles are byte-identical.
K2 rebuilt from a frozen src copy: 629 concepts, 1108 files, name set identical,
0 ids moved, 479 files byte-identical, 629 changed and 0 lines removed anywhere.
629/629 now carry an address and a locator. New ref
`sha256-tree:665563a2f74423fcbcc8e4f0b0954ee73b73985ac0418de4f6987bd162a1f7c8`;
`2f82fcfe...` is stale. The pre-pass payload does not grow by one byte
(209 092 B before and after, 18 changed lines: the ref and eight per-concept
digests) -- because an excerpt carries the body, not the frontmatter, which is
also why the consumer still cannot cite "file X page 12" from a payload alone.
Report: docs/2026-09-08-proveniens-k2.md. 1339 tests, ruff and mypy clean.
Co-Authored-By: Claude <claude-opus-5>
Two producer-side findings from the consumer's S7c acid test (ordre 20260908T063454Z-3648220855-from-.claude), both measured on K2 before and after, both with the corpus rebuilt from scratch.
FUNN 3 -- THE FORM. The converter's default markdown writer emits simple tables, which pad every cell out to the width of the widest cell in its column. Measured on the tender's price sheet: one 594-character prose cell produced a 67 244-character whitespace carpet with runs of up to 887 characters between a label and its amount, 19 integral amounts carrying a converter `.0`, and a header row naming one column. The bytes reached a live model in 2 of 11 prompts and 0 of 11 answers. The spreadsheet row now writes pipe tables with `--columns=1` (load-bearing: the pipe writer pads to a width computed from it, so at the default 72 a narrow table gains runs of up to 45). Same sheet after: 11 048 characters, longest run 2, one row per line, 0 artificial `.0`. Spreadsheet-only, and the scoping is pinned by three digests -- the same change moves the odt fixture 1366 -> 1105, so it can fail.
The `.0` rewrite is bounded twice: to a cell whose whole content is such a number, anchored between unescaped pipes, and skipped when the literal is in the workbook's shared string table -- the converter renders the number 92 and the TEXT "92.0" identically, so the output alone cannot tell them apart. Read with zipfile and xml.etree; no new dependency.
FUNN 2 -- THE LOG LINK. `link_log_in_root_index` (95eb271) is removed. Consumption contract SS 9.2 forbids a consumer from enumerating the bundle directory unless the profile says the index is derived, so the index tree is the entire map a consumer may use and everything it links is a document: their navigator returned 630 where our pre-pass counts 629, and a corpus run's own log was citable as content. The log is still written to the bundle root (SPEC section 9); `tools/okf_consume.py` keeps its exclusion for the bundles already built with the link.
K2 rebuilt twice. BEFORE reproduces the consumer's ref exactly (`sha256-tree:f14872a0...c8a92a`, 629 concepts) and their three consume figures to the token (57 289 / 62 149 / 58 401). AFTER: 629 concepts, `merged + coded rejections = 43 = N`, new ref `sha256-tree:c26eed6a...e3261f`, 627 of 629 concepts byte-identical, 1104 of 1108 files identical to the delivered bundle.
ONE REGRESSION, MEASURED AND NOT FIXED: on the mandate-shaped question with the vocabulary bridge the priced concept moves from candidate rank 10 to 19, so `--k 12` withholds it `below_k`; `--cost-vocabulary --k 20` delivers it at 65 912 o200k. The cause is measured rather than argued -- restoring only the concept's title on the new short body ranks it 10 again. The chain ends at the orphan check (`propose.py:461`), which drops the sheet heading once a table block opens two lines below it. That is the already-reported orphan gate, and changing it is a default-ON segmentation rule affecting every document type. The specific question is unaffected: rank 1 before and after. The priced excerpt's budget share falls from 56.5 % to 9.7 %.
11 new tests (RED first), 8 mutations, 8 red, with an unmutated control green each time. One mutation survived twice before the fixture could make it fire, and both survivals are written down. 1279 -> 1287 tests. mypy --strict clean on 28 files. ruff clean. Both proposer goldens byte-unchanged. One frozen literal moved with the fix and is reported rather than hidden.
Report: docs/2026-09-08-prisform-og-loggen-k2.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The K3 Arm E round (order 20260907T075834Z-18584396-from-.claude) measures a
rule that has not been written yet. This commit pins what the code does TODAY,
so the later claim "the default did not move" rests on a committed artifact
rather than on reading a diff.
Three characterization tests and one new golden fixture. Every count they
assert was measured against unmodified code before the test was written:
GRID_TABLE yields 3 candidates, TWO_TABLES_BLANK_SEPARATED yields 3,
TWO_TABLES_RULE_SEPARATED 2, PIPE_TABLE 1, PROSE_WITH_A_STRAY_RULE 1.
The second golden is not duplication. `propose-golden-default.json` is taken
over `OUTLINE_DOCUMENT`, which contains no `|` row and no `+` rule line, so no
table rule -- present or future -- can move its bytes. It is a guard that
cannot fire. `propose-golden-grid-default.json` is taken over a document that
has a pandoc grid table, and it is generated here from PRE-Arm-E code: a
fixture recorded after the change would pin post-change bytes and prove
nothing about the default.
Two fixture shapes are load bearing and say so in the band's comment. The
second table in TWO_TABLES_BLANK_SEPARATED has exactly ONE row group, so it
cannot be joined -- that is the only shape that catches a join whose pending
state is never cleared. And `propose()` is excluded from this band: it pops
`source` and discards every other kwarg, so an Arm E test written through it
would silently measure the default and pass.
Tests first: 1 red (the golden did not exist), then green. 1230 -> 1233.
ruff check: exit 0. ruff format --check: exit 0. pytest -q: exit 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
hit@8 = 5 of 6, every hit at rank 1, against a chance baseline of 1.35 of 6
over a denominator of 629 concepts per question. Wall time 0.51-0.56 s per
question; spent 17 970 - 74 838 bytes against a 120 000 limit.
Two things this measurement did NOT establish, both in the report:
- BOTH known-negative controls FAILED. A question the bundle has no answer to
still returns eight excerpts, because no natural Norwegian question is
lexically disjoint from a 629-concept corpus under a four-character
shared-prefix rule -- measured per token, the interrogative `hvor` reaches 40
concepts, `brukes` 83. So `no_lexical_match` works per concept and not as a
whole-question gate: an empty excerpt list is evidence of absence, a full one
is not evidence of presence. The fix is named (rarity weighting) and NOT
built, because this step's fence freezes the instrument before it is measured.
- The question texts were written during execution, after the ranker existed.
The plan recorded the gold documents' SIZE profile -- its per-row baselines
sum to 1.35 and the sizes used here reproduce that exactly, which is an
independent check that this is the set the plan profiled -- but it recorded no
question texts, and three of six gold documents could not be pinned uniquely
from the sizes. Not a blind evaluation, and the report says so.
The scorer is a tool, not a script in a document: `tools/okf_consume_measure.py`
takes the gold set as an INPUT because it is tracked in a public repository and
an answer key names a consumer's documents. hit_rank, both chance baselines and
the document-size census are unit-tested; the corpus run is a measurement.
Public-file rule, checked with a pattern DERIVED from the corpus's own 39
document names rather than hand-picked, and shown able to find first (67 hits on
the bundle's own index): zero corpus document names in any tracked file in this
repository. One leak was found and removed on the way -- a corpus concept name
in a code comment and a hardcoded corpus path in a test.
Suite run after git add: 1230 passed, mypy --strict clean on 27 files,
ruff clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`docs/2026-09-04-k2-pptx-odt-rtf.md` measured the corpus denominator for
these three office rows and found it ZERO: `K2/trinn1` holds 43 files and
not one of them is a `pptx`, an `odt` or an `rtf`. So `extract._EVIDENCE`
calls those rows `unmeasured` in the strongest sense available -- they
work by construction and had never met a document at all.
This is the smallest thing that changes that without inventing a corpus.
One authored document -- a title, an intro, a 20-row label/value table, a
caption and a 4x4 grid -- laid out three times in three containers, so
the container and its reader are the only variable between the three
measurements. `k2-office-fasit.json` carries the hand count taken from
the AUTHORED content rather than from any converter's output: 56 cells,
20 pairs, 59 distinct strings, shared by all three. It is committed here,
before the measurement runs, because a fasit written afterwards is a
description of a result rather than a denominator for it.
No converter wrote any of these files. `make_k2_office.py` lays every
part by hand, for the reason `make_fixtures.py` already states and this
set inherits: a file written by the converter and then read by the
converter proves only that the converter agrees with itself, and stays
green through any conversion defect that is symmetric. The commissioning
order offered pandoc as one generator option; the committed fixture
policy forbids it and the policy wins.
Two converter behaviours were measured while laying the RTF out, both of
them structurally plausible input read silently wrong, exit code 0 and no
warning. Without `\pard\intbl` on cell paragraphs, consecutive
`\trowd...\row` rows come back as each row NESTED inside the previous
one: five label/value rows read as five levels of nested table, 2076
characters where 117 were expected. And the `\uN?` unicode escape -- the
form Word emits -- loses the character after it: `A\u248?BC` reads back
as `AoC` with the `B` gone, `A\u248?xBC` reads back as `AoBC`. The
fixture writes `\uN ?` with an explicit space, which round-trips. Neither
is worked around anywhere in `src/`.
The generator and the fasit live one level above `k2-office/` and that is
not tidiness: Door B walks its drop directory recursively, so anything
parked beside the three documents would enter the run and N would stop
being 3.
Three synthetic documents in one house style are not a corpus. The rows
stay `unmeasured` and the suite asserts that they do.
Suite 1132 passed (1127 + 5), `ruff check` and `ruff format --check`
clean.
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>
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