The consumer's question about `vann- og frostsikring` in a subsea tunnel
delivered 0 of the 16 concepts covering it, best of them at fused rank 14.
Reproduced with the denominator, then decomposed per signal before anything
was built.
It is not a matcher miss. `normalise("vann- og frostsikring")` already returns
`('vann', 'frostsikring')` on HEAD, the prefix rule already bridges the
inflections, and the best covering concept already answers 7 of 7 question
tokens -- more than any delivered one. A tokeniser rule had nothing to widen.
It is the fusion, but not a weight. RRF ranks every concept in every signal,
including a signal that scored them all the same, and the declared
`(-score, concept_id)` tie-break then orders that group by id. On N500 the
document prior has TWO distinct values over 270 concepts, so the third signal
contributed alphabetical UUID order spread from 1/61 to 1/329 -- enough to put
a concept leading the body signal behind concepts sharing only `tunnel` and
`vann`.
`--tie-shared-rank` lets concepts a signal scores equally share that group's
first rank. The miss closes: best covering 14 -> 3, 2 of 16 delivered. OFF BY
DEFAULT, by the order's own rule: the three requirement lookups hold at rank 1
and the K2 digest holds, but hit@8 over the six published questions falls 5 of
6 to 4 of 6. Decomposed rather than guessed -- K2's prior is coarse (6 values
over 39 documents) rather than degenerate, and one gold sat early in its tie
group. That benefit was never a measurement, but it is a published row.
`--withheld-titles` gives each withheld entry the concept's title, so a reader
can see WHAT was withheld without reading the bundle. 11 lines of code; the
bytes are why it is off. It grows an N500 payload 37.9 % and takes the
629-concept K2 bundle's BOOKKEEPING to 122 704 B -- past the 120 000-byte limit
itself -- which would falsify the breaking point published in the tracked
`skills/okf-consume/SKILL.md` on the day it shipped.
Defaults measured, not asserted: six payload digests built from a frozen
`ff79cfa` (`git archive`, `__file__` checked) and from this tree with both
flags omitted are 6 of 6 identical, and `okf_skill.py` output is identical
apart from the paths each copy writes about itself. Contract checker exit 0 on
eight payloads, both values.
One known-positive did not reproduce and is reported rather than matched: the
order's S7 literal `2ae46f68`/169 573 B is stale by three excerpt-form commits;
HEAD measures `c759a657`/171 614 B.
Suite 1388 -> 1397. Report: docs/2026-09-08-rangeringsbom-sammensatte-ord.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
K3 round 2, per file type. Order 20260908T143513Z-6327528123-from-.claude, carrying two operator decisions taken beforehand: D1 the orphan-gate variant goes behind a flag, D2 the arms become reachable from `okf build`. No default moved. Report: docs/2026-09-08-k3-runde2-per-filtype.md.
THE REPRODUCTION HELD, all three numbers, before any edit: `okf build` on the five-document tender folder gives 31 markdown files with both PDFs flat and 5/5 merged; the tender PDF gives no boundary without a flag and 9 with `--outline-run 3` (reference 9); the price sheet gives 1 on HEAD. Both proposer runs had to go through `bash -c` -- zsh does not word-split an unquoted `$flags`, so a sweep hands `--outline-run 3` to argparse as one token and every row comes back exit 2.
D2 -- `cli.py:_propose_plans` called the proposer with no arm argument, so the build path ran Arm B while `tools/okf_propose_segments.py` could run D, E and F. It now passes `--outline-run`, `--table-grid`, `--unit-fold` and `--keep-table-heading` through unchanged. THE DEFAULT DOES NOT MOVE and that is measured, not asserted: same folder, no flags, before and after the change, digest 3af10770...8fbbe2 both times and `diff -rq` clean. The "before" bundle was built before the first edit, because the editable install reads src/ live. Red test on the PLANS and on titles rather than a count, with the same fixture and no flags as its control. Per-document table for B/D/E/F/F2 is in the README and the report; the tender PDF is 1 under the default and 9 under every arm above it, and the reference is 9.
D1 -- a sheet heading with a table opening under it has an empty body, so the orphan check drops it: the NAME survives (carried onto the table block), the LINE does not. `--keep-table-heading` lets the heading survive and absorb the table instead. Price sheet 1 -> 1 concepts, `source_offset` [34, 11048] -> [0, 11048], body now starting at the heading. ELEVEN IS NOT REACHABLE THIS WAY and the number says why: the sheet is one heading and one continuous pipe-table block, and the eleven cost groups are eleven ROWS inside it (lines 10-20 of 103). What is missing is a section-row rule inside a sheet -- the opposite of `--table-grid`. Corpus: the flag changes 2 of 39 documents, both `.xlsx`, under arms B, E and F alike; known-negative 0 of 32 `pdf` and 0 of 5 `docx`. With it off, Arm E over all 43 is byte-identical to session 109's tree (33 plans, 43 `.err`, 4 FAILED, diff exit 0, counts asserted first).
THE PDF REMAINDER, one at a time. Position 9: clause 1 read the list AFTER the orphan check, and a contents list without dot leaders is a run of bodiless headings, so all but the last entry were already gone and the run was one. The run is now measured on the pre-orphan list, predicate written once and read in both places. 11 -> 10. Position 7: the same clause required siblings, and a numbered report's contents list interleaves 1.1/1.1.1/2.1 -- its 34 entries are one block that the level condition cut into runs of 9, 1, 1, 1, 5, 2, 10, 2 and 3, so the short runs survived. The level condition is dropped; the run LENGTH, which is what the CONTENTS_RUN sweep bought, is unchanged. Measured outward: the relaxation changes 1 document of 39 and removes exactly the leftover line. 34 -> 33.
TWO REMAINDERS ARE DECLINED WITH NUMBERS RATHER THAN FIXED. Position 1: the three level-1 candidates are 3 of 3 `rule:outline`, same level, same grammar, and the operator keeps one of them by prose alone -- there is no property to read. Position 4: a title-length rule was measured on paper and falsified -- a real chapter is 56 characters and a real heading in a document the arms already score correct is 88, sitting between position 4's 86 and 91, so no threshold separates the classes. Position 0 stays an extraction failure.
ONE SHIPPED EXPECTATION MOVED and is stated rather than quietly updated: `Innhold 1` is now discarded with the contents list it heads. Its body is in no segment afterwards, which is a real cost on a fixture where that heading has one.
Nine new tests: five red before the implementation, four green by construction and named as such. Three mutations, three red, unmutated control green each time -- restoring the level condition, computing the run post-orphan, absorbing a table unconditionally. 1379 -> 1388 tests. ruff clean, mypy --strict clean on 17 files. K2 bundle untouched (1108 files, 9cd74519...). The K2 ranking control is NOT measured: no bundle was rebuilt with the flag, so the rank is a prediction.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Order 20260908T133512Z-139864689-from-.claude. First iteration of the
per-file-type directive (operator 2026-09-08 13:05Z), not the last. No
threshold is set: ratifying a bar is the operator's, and setting one inside the
work that produces the measurement would be fitting the bar to the number.
[skip-docs] covers README.md only, and it follows a precedent re-measured this
round rather than quoted: `grep -c` for outline-run, table-grid, Arm C, Arm D
and Arm E returns 0 in README.md and CHANGELOG.md, while --path-prefix, a real
interface change, has a CHANGELOG entry. The rule is "interface and behaviour
changes yes, arm flags no", and --unit-fold is an arm flag that defaults off.
CLAUDE.md IS updated, because its `okf build` bullet enumerates which arms are
off there and would otherwise become false.
FUNN 1, and step 1 asked for it: the reproduction broke. Arm E on HEAD is
byte-identical to the archive on 31 of 33 plans; the two that differ are 2 of 2
spreadsheets in the corpus. The cause is EXTRACTION, not segmentation --
56ae274 writes a workbook as pipe tables, and the sample's price sheet extracts
to 11 048 characters where the worksheet records 100 694, which is the figure
that commit's own message predicts. The consequence is a segmentation
regression against the reference: K3 position 3 went 3 concepts -> 1 under both
Arm D and Arm E, where the operator wants eleven. The mechanism is the orphan
check dropping the sheet heading once a table opens below it (propose.py:461),
already reported there as a ranking regression. Doors unchanged: 43 .err, 4
FAILED, extractable 39/43.
THE MATCH CRITERION WAS WRITTEN DOWN BEFORE ANY CELL WAS SCORED, and it stalls
at 7/12 on the literal calibration gate after three rounds, each revision
recorded. The five failures are not the criterion's: at every one it agrees
with the operator's own (a), (b) or free text and disagrees only with (c).
Column (c) is a RELATIVE judgement ("closest today"); the four K3 categories
are absolute. The only way to reach 12/12 is to define "correct" as "the
closest arm", which reads (c) back out of itself. The dominance gate, declared
in advance as the second reading, holds at 11/12.
ARM F is one rule with three clauses derived from the operator's three, not
twelve special cases, and it only MERGES or DISCARDS: a run of at least
CONTENTS_RUN same-level page-numbered headings is a contents list and goes; a
heading deeper than the unit level folds into its parent, extending the
parent's span; a table folds back into the shorter heading that introduces it,
keeping the HEADING's name. K3 first rater, n=12: 2 coarse / 5 fine / 0
duplicate / 5 correct -- best of four arms, ceiling was 4, two moved, nothing
regressed anywhere.
THE PAPER MEASUREMENT CAME FIRST AND FALSIFIED THE FIRST VERSION. Clause 2 was
letting rule:outline -- Arm D's RECOVERY of an integer numbering run -- vote on
the unit level, which took K3 positions 1, 7 and 9 to 3, 4 and 7 concepts
instead of 17, 34 and 11. A recovered numbering is a heuristic, not a level a
document declares, and the unit worksheet showed the operator ATX and dotted
headings only. Fixed with its own red test; 11 of 12 predictions correct after.
PER FILE TYPE, which is the directive: docx 3 of 3 (solved on this sample), pdf
2 of 8 (lags, unchanged by Arm F, and the remainder is decomposed per position
rather than left as one number), xlsx 0 of 1 (regressed, see FUNN 1). Outside
the corpus, n=1 each: pptx and odt byte-identical, rtf proposes nothing either
way, txt differs and exposes clause 2's fallback.
CONTENTS_RUN swept 1..5 and off. Distance prefers 1; three ships anyway,
because at 1 the body chapter "... i henhold til TEK 17" is deleted for ending
in a number, and no K3 cell differs between 1 and 4 -- the metric prefers a
value that provably deletes a chapter and cannot see the cost.
Whole corpus, all 43 through arm_run in ascending foreground chunks: 32 plans,
491 entries against Arm E's 679, 14 documents changed, 1 plan disappeared
entirely (three drawing-schedule numbers that clause 1 correctly reads as a
contents run) and that is reported rather than special-cased.
THE okf build MECHANISM IS REPRODUCED AND IT IS NOT DOOR B: cli.py calls the
proposer with no arm flag at all, so the shipped build path is Arm B. On a
tender PDF that means no boundary where Arm D finds nine and the reference says
nine. Largest per-file-type gap this round found; it is a default change and
therefore the operator's.
5 tests red first, 1373 -> 1379. ruff clean, mypy --strict clean on 17 files.
K2 consumer bundle unchanged: 1108 files, digest 9cd74519... with the flag off.
No bundle built, no version bump, no tag, no push.
Report: docs/2026-09-08-k3-arm-f-mot-enhetsarket.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four questions, two bundles, one run each, in a scratch project outside this
repository with a generated skill per bundle. All four passed, and zero numbers
or identifiers appeared in any answer that were not in the delivered set or in
the payload's own identities (62, 45 and 35 unique numeric tokens checked).
The skill triggered WITHOUT being named in the prompt and selected the right one
of two installed skills from the question alone, so no special invocation syntax
is needed: the generated `description`, which carries the bundle id, the concept
count and the ref, is enough to route on.
One defect the runs found, and it was in the prose rather than the payload. The
citation guidance listed the four locator keys this library writes, so on the
270-concept third-party bundle the model reported "no page locator, the address
is at document level" while the excerpt in front of it carried
`source_element_id` - that bundle's own locator, correctly delivered by the
prefix rule. The guidance now tells the reader to cite whichever `source_*` keys
are present. On the re-run the same question returned the element id. Two runs
of one question, the second measuring a changed artefact and not retrying the
first.
One finding that is not a defect in this chain: the first attempt at a
known-negative was not one. The bundle covers water and frost protection on 17
of its 270 concepts and the ranker put none of them in the cut. The consumer
behaved exactly as the contract asks - refused, named its denominator, reported
its own zero as unmeasured because `withheld` entries carry no titles, and did
not go around the cut. Recorded as a retrieval miss rather than replaced, and
it is the same shape as the open fusion finding.
A correction to this session's own measurement is in the record too: a first
sweep used `grep -rhoE "^source_[a-z_]+:"`, whose character class excludes
digits, and so missed `source_sha256` on 270 of 270 concepts. A pattern that
cannot match what it is looking for returns a zero that reads like a fact.
README gains "Consume in Claude Code": folder to answer in three commands, every
one of them run in this session. A test holds that the recipe invokes only
scripts this repository ships, at the paths it names.
Suite 1373 (1339 at the session baseline), ruff clean, mypy src clean. No
version bump, no tag, no push.
Co-Authored-By: Claude <claude-opus-5>
Two changes, one theme: what a reader needs in order to cite is a property of
the PRODUCER, so neither the excerpt nor the skill may hard-code a list of the
producers someone thought of.
The pass-through rule is now the `source_` PREFIX, not the five keys this
library writes. Measured on the N500 bundle currently on disk: 269 of 274
concepts carry `source_element_id`, a locator that repository chose under this
chain's own rule ("the key says what it indexes") and that this library never
writes. The allowlist dropped it, and an excerpt that names a document without
naming the place in it is the defect this work exists to close. A prefix and
never a substring - `resource_owner` contains the literal and is not a locator,
and promoting it would be fabricated provenance produced by a matching bug. The
known-negative is tested: `bundle_id`, `type` and `ingested_at` do not travel.
Contract 8.5 states the rule as a prefix rather than a list.
K2 control, re-measured against the frozen tool at b6a8c8b, same question and
same k: the RANKING is untouched - same 8 ids in the same order, identical
`text_sha256`, identical `withheld`, denominators 629 = 621 + 8. The FIELD moved:
payload 108 877 -> 113 143 B (+3.92 %), spent 18 606 -> 22 210 (+450.5 B per
excerpt), excerpt members 9 -> 17, 99 changed lines. Known-positive follows the
contract document's bytes again: 12 049 -> 12 563 measured, 11 719 -> 12 227
raw, delta 330 -> 336.
`tools/okf_skill.py` instantiates the template for one bundle: id, ref, concept
count, the conditional-field table with a denominator per field (the `source_*`
rows DISCOVERED from the bundle, not listed), the whole-bundle cost by the gate's
own instrument, the share one measured answer spent, the concept count at which
the withheld bookkeeping alone reaches the limit, and the index-walk-against-
directory control - run once at generation time, never on the question path.
The form was chosen on a measurement that came out against the obvious gate:
the contract checker passes the UNFILLED template against a real payload, and
passes a skill built for a different bundle against this one's. It cannot tell
the two forms apart, so conformance could not decide it. What decides it is that
5's denominators, 6.4's conditional fields and 7.6's breaking point are
per-bundle numbers - a generic skill either leaves them as holes (the template's
own definition of unfinished) or states another corpus's numbers, which is worse
than a gap. Every gate the checker lacks is therefore a test here: no placeholder
survives, the skill names its own bundle's id and ref and not another's, its
commands are absolute and point at files that exist, and it refuses a directory
with no index (exit 1, `bundle_unreadable`), an index with no `bundle_id`
(`bundle_id_missing`), an empty bundle, and an occupied target without --force.
It lives in `tools/` for the reason `okf_consume.py` and `okf_contract_check.py`
state for themselves - outside `src/`, so no consumer's install surface changes -
and because a wheel-installed `okf skill` would emit a command pointing at
`tools/okf_consume.py`, which the wheel does not contain.
Suite 1372 (1347 before), ruff clean, mypy src clean.
Co-Authored-By: Claude <claude-opus-5>
The pre-pass delivered the right concept and the answer could not name it.
Measured by portfolio-optimiser 2026-09-08 over three paid arms: the gold
concept came back at rank 1 of 8 on 3 of 3 bundles, and the model answered
correctly on 1 of 3, because a delivered excerpt carried `concept_id`, body
text and nothing the document is known by. The previous session measured the
same gap from the other side: the provenance it had just written into every
concept did not reach the payload at all.
`excerpt_for` now carries `title` unconditionally, and `req_number`, the SPEC
5.1 address `sources` and each locator key (`source_pages`, `source_sheet`,
`source_rows`, `source_lines`, `source_offset`) when the concept has them. A key
the producer did not write stays absent: an empty value would assert that they
wrote an empty one, which is the contract's 6.4 failure.
`sources` is read in BOTH YAML forms, on a measurement rather than a taste. K2
writes the flow form on 629 of 629 concepts; the largest N-bundle writes the
block form on 270 of 270 and carries no locator key at all, so a flow-only
reader delivers that bundle with no address whatsoever. Reading the block form
is not a licence to write it - the emission rule is untouched, because the
line-oriented parser still cannot round-trip a block list. A `sources` value
this reader cannot decode is named (`sources_unreadable`), never dropped into
the same silence as an absent one.
Contract 8 gains the requirement and the checker gains its code
(`excerpt_unnamed`, 15 rules now, was 14): an excerpt a reader cannot name is
one an answer cannot cite, whatever its rank. `req_number`, `sources` and the
locators are SHOULD, not MUST - they are conditional on the producer, and a
bundle whose concepts carry no identifier cannot deliver one.
K2 controls, same question and same k, before against a frozen copy of the tool
at b6a8c8b: the RANKING does not move - the same 8 concept ids in the same
order, identical `text_sha256`, identical `withheld`, identical denominators
(629 = 621 + 8). The FIELD is what moved: payload 108 877 -> 111 744 B
(+2.63 %), budget spent 18 606 -> 20 907 (+287.6 B per excerpt), excerpt
members 9 -> 15, 83 changed lines. The contract document's own bytes moved with
8, so the budget instrument's known-positive moves with it: 10 349 -> 12 049
measured, 10 060 -> 11 719 raw, delta 289 -> 330.
New fixture `tests/fixtures/consume-provenance`: the two address forms and a
concept carrying neither address nor identifier. Purpose-built, because the two
real bundles are complementary and neither exercises both forms.
Suite 1347 (1339 before), ruff clean, mypy src clean.
Co-Authored-By: Claude <claude-opus-5>
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>
A question naming a requirement number now delivers that requirement at rank 1
on all three vegnormal bundles (was 96, 9, 35 of 446, 1 133, 270). Two
mechanisms, both measured, both default because no published figure moves.
The matcher: `tokens_match` compared four leading characters, so the unique
identifier `3.3.1-13` read as 135 of 446 common and the rarity weight ranked a
common adjective above the number naming the document. An identifier now
matches by equality alone; df falls to 1/1/1. Words keep the prefix rule, which
was measured for Norwegian compounds. Equality has no floor either, so a
three-character identifier stops matching nothing at all -- measured, `9.2`
reached 0 concepts while sitting verbatim in one title.
The lookup: a question carrying an identifier that sits verbatim in a concept's
title or id is answered by a partition over the fusion's output, not by a
fourth signal. The form was chosen by measurement -- a fourth RRF signal was
simulated first and put the gold at rank 26 / 15 / 19, none of them delivered,
because RRF consumes ranks only and one signal contributes at most 1/(RRF_K+1).
No frontmatter key list is declared: of 1 846 concepts carrying `req_number`,
1 846 also carry that identifier in the title.
The matcher alone is NOT a monotone win (N200 9 -> 26, because that gold's body
cross-references a neighbouring number that the prefix rule counted as a hit on
the question's). Only the partition delivers; the table is in the record.
Consumer corpus: every named control byte-identical against a frozen
`git archive` copy of 116d3e1 -- four payload digests, eight candidate ranks,
six hit@8 rows, both known-negatives. One document that was withheld at
position 621 of 621 is now delivered at rank 1, on a corpus with no
requirement-number grammar at all.
13 tests (12 red before the rules existed), 7 mutations, 7 red. 1 320 passed.
Consumption-side only; no bundle ref moves.
Record: docs/2026-09-08-eksakt-oppslag.md
Co-Authored-By: Claude <claude-opus-5>
O2b asked whether a requirement number can be made worth more than a common
word by weighting each hit with the token's rarity in the bundle, with no
hand-set constant and no declared token class. It can, on one of the three
bundles, and the two it cannot are decomposed rather than guessed.
The rule is log(N/df) over the concepts' own tokens, counted with the same
four-character prefix rule a hit is scored with. It enters the RANKING and
never the GATE: `lexical` stays a count, because `krav` weighs exactly 0 on
all three bundles and a weighted gate would drop every concept matching only
that word -- which is the gate 54a0bc2 falsified for other reasons. One df
table per bundle reaches every stage that scores the question against text,
including the document prior. One pass, 0.241 s over 1 133 concepts.
Measured on four corpora, before and after, with every published figure
reproduced first: gold fused rank 96 -> 103, 9 -> 8 (withheld -> DELIVERED at
rank 8) and 35 -> 35; K2's priced sheet candidate rank 10 -> 2 with the cost
vocabulary and 251 -> 78 without; Q-good unmoved at rank 1; hit@8 5 of 6 with
every rank identical; the S7 control payload byte-identical on the default
command.
DEFAULT OFF, decided by the number and not by taste: it does not win on all
four, because N100's gold loses seven rank positions. Off means the bytes that
were already published, and that is measured -- 8 of 8 payload digests
identical against a frozen copy of 56c1205 built with git archive.
Two limits, both someone else's mechanism and both named: MIN_SHARED_PREFIX=4
makes a unique identifier read as 135-of-446 common on N100, so the weight
correctly ranks a common adjective above the exact requirement number; and RRF
consumes RANKS only, so on N500 -- where the gold already leads the one signal
that can see the identifier, and the other two cannot see it at all -- no
weighting inside a signal can move anything.
Consumption-side only, so no rebuild: the K2 bundle ref 2f82fcfe... stands.
Report: docs/2026-09-08-sjeldenhetsvekt.md. 13 new tests, red first; 8
mutations, 8 red, two of them only after the survivors were read as code -- one
exposed a fixture that put the identifier where the real corpus does not, and
the corrected fixture is what found the RRF limit. Suite 1295 -> 1308.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two consumer-reported defects, one rebuild.
The pre-pass could not see a requirement number: `_TOKEN_SPLIT_RE` split
`10.2-2` into digit runs and `MIN_TOKEN_LENGTH` removed them, so a question
naming a requirement reached the ranker carrying only the word every concept
in a standards bundle carries. Measured on three real bundles (446, 1133 and
270 concepts), the named requirement was withheld `below_k` in three of three.
Numeric groups joined by `.` or `-` are now held together, dash variants fold
to the ASCII hyphen, and the noise floor is unchanged. The gold moves from
160 to 96, 143 to 9 and 100 to 35 -- a large move, and NOT a delivery: it is
still `below_k`, because `_overlap` is a count and an exact requirement number
is worth no more than a common verb. That weighting is a separate decision.
The rule was narrowed by a measurement: a version that joined alphanumeric
groups swallowed a document slug whole and cost a hit@8 row. An equality-only
variant was measured on all three bundles and falsified -- better on one,
worse on two.
The orphan gate destroyed a heading's name: a table opening directly below a
heading left that heading with an empty body, the orphan check dropped it, and
the surviving table block kept the mechanical `Tabell linje <n>`. A table that
orphans its heading now takes that heading's title and section number.
Conditioned on the drop, on adjacency, and carrying both members -- each of
the three measured or mutation-tested.
One K2 rebuild for both, from a frozen source tree: 629 concepts, `39 + 4 = 43
= N`, 2 of 629 ids moved and both moved BACK to the names the 2026-09-03
bundle carried, 1106 of 1108 files identical to it. New ref
sha256-tree:2f82fcfea91c3bd3f8ef7147f80cd613227d3ca7975c41d88810233f3f79ab4b
-- c26eed6a... is superseded. The regression the previous session measured is
closed: candidate rank 19 -> 10, and the delivering command is now
`--cost-vocabulary --k 12` inside the default budget at 58 907 o200k against
65 912 before. The specific question is unmoved at rank 1.
The tokeniser alone leaves the K2 control question byte-identical, measured
with the bundle held fixed and both published byte counts reproduced.
Report: docs/2026-09-08-kravnummer-tokenisering.md. 8 new tests, red first;
6 mutations, 6 red, one of them only after the survivor was read as code and
a missing fixture was added. Suite 1287 -> 1295.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 prior measurement (docs/2026-09-08-blindsone-below-k-k2.md SS 3) found that
the budget, not the ranking, is the second lock on a mandate-shaped cost
question -- and that the same mechanism was a REGRESSION on the question that
works: raising `--k` to 16 evicted the gold concept, because the exact knapsack
maximises a SUM of fused scores and has no opinion about rank, so twenty small
excerpts out-value one that costs 56.5 % of the budget.
Measured here on the same 629-concept bundle, with the three known-positive
figures from `4c699fd` reproduced first:
- Corpus distribution, denominator 629: median excerpt 857 B, max 223 391 B,
3 concepts over the limit alone.
- Candidate rule (b), a corpus-derived budget, is FALSIFIED by two numbers: two
defensible derivations are 49x apart on the same corpus, the small one turns
the gold concept into `over_budget_alone` (13 refusals against 2), the large
one changes nothing at the default k. A budget is the consumer's constraint,
not a property of the corpus; `--limit` already belongs to the caller.
- Built instead, behind `--reserve-top-rank` (default OFF): the top-ranked
candidate gets its bytes before the pack runs, AFTER the `over_budget_alone`
pre-exclusion and never before, and the payload declares `budget.reserved`.
- It fixes the eviction: k=16 and k=24 deliver the gold concept at rank 1,
costing one and two excerpts, and 20.4 % / 27.3 % FEWER o200k tokens.
- It changes the delivered list in 2 of 24 measured combinations -- both of them
that eviction. In the other 22 the list, its order and `spent` are identical.
- It does NOT close the mandate-shaped blind spot: that concept ranks 10, not 1.
The one delivering command is `--cost-vocabulary --k 12 --limit 160000`
(62 149 tokens against 58 401), and that is a consumer's decision.
11 new tests (RED first), 7 mutations 7 red with an unmutated negative control
green before and after; two of the seven survived the first test set and the
tests were strengthened. Default payload byte-identical, both goldens unchanged.
Report: docs/2026-09-08-blindsone-laas2-budsjett-k2.md
Suite 1279 green, mypy --strict clean over 28 files, ruff clean.
Co-Authored-By: Claude <claude-opus-5>
The consumer report (portfolio-optimiser, S7 SS 2) found that a mandate-shaped
cost question withheld the corpus's one priced table under `below_k`. Measured
here, on a bundle proven byte-identical to a fresh HEAD rebuild:
- The mechanism is a VOCABULARY gap, not a `k` defect: two of three ranking
signals are exactly 0.0 and the concept is candidate 249 of 269.
- The k-sweep buys nothing: k in {8,12,16,24,32,64,128} all withhold it, at
+9.5 % tokens. It also found a regression -- for the question that WORKS,
k >= 16 EVICTS the gold concept, because one 67 838 B excerpt is 56.5 % of
the budget and the knapsack maximises a sum.
- Two proposed rules were falsified BEFORE any code: number/table density ranks
the priced table 178/165/46 of 269 (the form is unfilled, so it is
number-poor), and per-document spread puts its document 30th of 35.
Built instead, behind `--cost-vocabulary` (default OFF, DEFAULT byte-identical):
one declared cost/price/quantity vocabulary family that bridges a question and a
document naming money with different words. It moves the concept from candidate
rank 249 to 10 -- and does NOT close the blind spot: the budget still refuses
it, which is now a separately measured second lock.
Seven RED tests first; six mutations of the rule, six red (two survived the
first version of the tests and the tests were strengthened). Control: a question
with no cost term produces a byte-identical payload with the flag on, at every
k, on the real corpus. Known-positive: 164 987 B / 40 425 o200k tokens, equal to
the published pair.
Report: docs/2026-09-08-blindsone-below-k-k2.md
Suite 1268 green, mypy --strict clean over 28 files, both goldens unchanged.
Co-Authored-By: Claude <claude-opus-5>
Two producer-side defects from the S7 acid test (ordre 20260907T234741Z-9578626297-from-.claude), both reproduced on K2 before and after.
F1: `okf build --ingested-at` alone stamped only 11/629 concepts -- the unsegmented ones, which read the call's value directly. The 618 segmented concepts read `segment.ingested_at`, the plan's `proposed_at`, independently defaulted to `DEFAULT_STAMP`. `proposed_at` now falls back to `ingested_at` when omitted; neither flag passed still yields `DEFAULT_STAMP` for both.
F2: the consumption pre-pass's index walk counted a root-linked `log.md` (`corpus.link_log_in_root_index`, `95eb271`) as a concept, inflating a 629-concept K2 rebuild to 630 and letting the log rank and cut like real content. The link stays -- the contract is silent on `log.md` and `95eb271` already named it a LOCAL choice -- but the walk now treats `LOG_NAME` like the index itself: reachable, never a concept.
K2 rebuilt twice from the same corpus and diffed against the delivered `K2-bundle-20260903`: FOR (stashed fix, matching fbaac6d) reproduces po's numbers exactly -- 619/1108 files differ, 618 ingested_at-only, ref `sha256-tree:4ffd750c...`. ETTER (fix applied) leaves exactly 1 line differing (the deliberate log link, predating this fix) -- 0 files stamped 1970, 629/629 stamped 2026-09-03, ref `sha256-tree:f14872a0...`. The delivered bundle's ref is unchanged before and after (`sha256-tree:9a4e5561...a968b5`), since it carries no log link and the new branch never fires. Conservation identity holds both times: merged + coded rejections = 43 = N, 39/0/4.
1258 -> 1260 tests. mypy --strict clean on 28 files. ruff clean. Both goldens byte-unchanged.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`tools/okf_table_measure.py` answers how far Arm E's join reaches and what it
costs, and it imports `find_candidates`, `_GRID_RULE`, `_TABLE_ROW` and both
rule names from the shipped module rather than carrying a copy.
`tests/test_table_measure.py` pins that with `is`, not `==`: `re.compile`
returns a distinct object for an equal pattern, so equality would be satisfied
by a pasted literal and only identity catches it. `draw_sample` is imported
from `okf_outline_measure` for the same reason -- one K3 draw in the
repository, not two that can disagree.
The column this round actually needs is the `|`-row count, for EVERY file
including the ones the door refuses. It is the ceiling: a document with no
table row cannot be moved by this arm. Asserting the ceiling from entry counts
instead would assume the orphan check kept every table candidate, which nobody
measured -- and one of the K3 sample documents produces no plan at all, so its
zero would be an absence with no denominator.
Constants split the way `okf_outline_measure.py` splits them. 38 grid-rule
lines across 3 documents is a DECLARED expectation measured before the rule was
written; a different value means the shipped grammar is not the measured one
and no figure below it may be read. The Arm D entry totals are REFERENCE
values, printed beside the measured ones and gating nothing.
A file the door refuses becomes a `Row` with its reason named rather than a
missing row, because `extractable / len(rows)` is the door count this round
reports as 39/43 and a silent drop would make it unmeasurable.
Tests first: 7 red (the module did not exist), then green. 1251 -> 1258.
[skip-docs] on the same measured precedent as the flag commit: `grep -c` for
"okf_outline_measure" and "okf_cid_measure" in README.md returns 0. A
measurement instrument is documented by its own docstring and by the round's
report, and it never enters the wheel.
ruff check: exit 0. ruff format --check: exit 0. mypy --strict src/ tools/:
28 files, Success. pytest -q: exit 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The flag threads through `run` and `main` and takes no argument. Arm D's gate
is a run LENGTH where 0 means off; Arm E has no numeric parameter, so a boolean
is the honest shape and an integer would only manufacture a sweepable knob that
means nothing. `run` therefore adds no numeric validation, and the help says
why.
Both prose sites that enumerate the arms `okf build` does not expose are
updated: `src/llm_ingestion_okf/cli.py` and `CLAUDE.md`. The second was found
by review, not by grep of the first -- the same claim lives in two files and
only one of them is code.
The generalised attribution test earned itself in this commit. The first draft
of the Arm E help contained "byte-identical to Arm D -- Arm D rather than Arm
B", and argparse's rendering plus the test's ` --` chunk split meant the
attribution fell OUTSIDE the `table-grid` chunk. The test went red with the
truncated chunk printed, which is exactly the failure it exists to catch: a
whole-output grep would have been satisfied and the attribution would have been
unfindable in the option it belongs to. The clause is now parenthesised.
Arm C's marker check in `tests/test_cli_build.py` gains `rule:table-grid` and
is renamed to speak of all three arms, measured on the artifact rather than on
the flag: a flag `okf build` never passes is not evidence about what it emits.
[skip-docs] is the MEASURED precedent, not a convenience. `grep -c` for
"outline-run", "max-segment-chars", "Arm C" and "Arm D" returns 0 in both
README.md and CHANGELOG.md: an arm flag is documented in its constant's `#:`
comment, in `--help`, and in the round's measurement report, and it is off by
default so it makes no promise to a consumer. `--path-prefix`, which is a real
interface change, does have a CHANGELOG entry. The rule this follows is stated
at docs/2026-09-07-k3-arm-d.md: "interface and behaviour changes yes, arm flags
no." Arm E's report is docs/2026-09-07-k3-arm-e.md, later in this round.
Tests first: 3 red, then green (a fourth, the no-argument test, is honest in
its docstring that it is green before the flag exists too, because argparse
rejects an unknown option with the same code; it becomes evidence only once
the flag is real). 1248 -> 1251.
ruff check: exit 0. ruff format --check: exit 0. mypy --strict src/ tools/:
27 files, Success. pytest -q: exit 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`build_plan` gains the keyword-only `table_grid` and threads it into
`find_candidates`, and the `derived` composition becomes an ordered build in
`_derived_names` rather than a two-branch conditional expression. Four
combinations exist now, and the order is itself a claim: the marker, the rule
that OPENED the span, Arm E's join, then Arm C's cut.
The test that earns its keep here is
`test_a_split_part_of_a_joined_block_names_all_three_rules_in_order`. Arm C
rebuilds every part as a fresh frozen `Candidate` from an explicit keyword
list, so a field not copied there is silently defaulted back -- the spans stay
joined, the plan looks right, every diff-based control passes, and only the
rule name is missing. That failure has no other detector.
The module docstring's claim that "each entry names exactly one" rule is
corrected rather than deleted: it is the ORIGIN that is single, not the list.
`tests/test_propose_segments.py`'s `len(rules) == 1` is deliberately left
alone -- it runs on the default fixture, where the original claim is still
true, and widening it would weaken a true assertion about Arm B.
Tests first: 5 red, then green. 1243 -> 1248.
ruff check: exit 0. ruff format --check: exit 0. mypy --strict src/ tools/:
27 files, Success. pytest -q: exit 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Arm E, off by default. `find_candidates` gains a keyword-only `table_grid`
whose branch is not even evaluated when it is False, so the flag-off path is
byte-identical by construction rather than by test.
The defect it addresses is measured. The converter emits pandoc GRID tables,
whose rows are separated by `+---+---+` rule lines that `_TABLE_ROW` cannot
match, so `in_table` resets between every pair of rows and ONE table becomes
one concept per row group. On the K2 corpus that is 33 of Arm D's 709 entries,
on exactly 3 of the 33 documents that produce a plan -- and those three are the
K3 sample positions 5, 10 and 11, all three rated `too fine`.
Three points where this could have gone silently wrong, and what each cost:
- `Candidate` is frozen and `dataclasses.replace` is not imported, so the join
is recorded as a `set[int]` over `marked` and applied at the orphan-check
pass that already rebuilds every candidate. `subdivide` rebuilds them again
from an explicit keyword list, so `grid` is copied there too -- exactly the
trap `split` already has.
- `rule_pending` is cleared on the fall-through together with `in_table` and
`open_block`. A grid table ends with a bottom rule, which sets it; without
the clear, the NEXT table's first row would be recorded as a join although
nothing was joined. `test_two_tables_separated_by_a_blank_line_stay_two_concepts`
is built to catch precisely that: its second table has ONE row group, so it
cannot be joined, and the test asserts `grid is False` on it. A test that
checked only candidate counts would stay green through the defect.
- A rule line can never OPEN a block: it is reached only with `in_table` true.
So no surviving candidate's `start` moves, bodies only grow, and the orphan
check -- which is monotone in the line set -- cannot drop a candidate it
previously kept. Asserted as an offset, not a length.
Measured on the real corpus with this code, reproducing a prediction written
down before it was built: 21 -> 6, 15 -> 3, 2 -> 1 entries, and identical at
--outline-run 0 and 3, so Arm D and Arm E do not interact here.
Tests first: 8 red, then green. 1235 -> 1243.
ruff check: exit 0. ruff format --check: exit 0. mypy --strict src/ tools/:
27 files, Success. pytest -q: exit 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two constants, no behaviour. `RULE_TABLE_GRID = "rule:table-grid"` joins
`RULE_NAMES`, and `_GRID_RULE` sits beside `_TABLE_ROW` as the grammar of a
pandoc grid-table rule line.
The rule is the author's, not upstream's, and the constant says so: `grep -c -i
"arm" docs/2026-09-02-k3-k4-k5-metode.md` is 0, so the definition was written
for order 20260907T075834Z-18584396-from-.claude. Its axis is a third one --
Arm C names SIZE, Arm D names what the DOCUMENT declared, and this names what
the CONVERTER emitted.
The character class `[-=:+]` is measured, not guessed. Across the three
grid-bearing documents of the K2 corpus, 38 of 38 lines whose stripped form
starts with `+` match this pattern, and those four characters are the complete
set occurring on them. The `:` is pandoc's column-alignment marker and is load
bearing: a first pass with `[-=+]` matched 37 of 38, and through that single
miss read one document as having two tables where it has one. The `\s*` on
both ends mirrors `_TABLE_ROW` because the loop iterates
`splitlines(keepends=True)` -- every line carries its `\n`, and an indented
rule line is a real shape.
Tests first: 2 red, then green. 1233 -> 1235.
ruff check: exit 0. ruff format --check: exit 0. mypy --strict src/: 17 files,
Success. pytest -q: exit 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`test_the_help_attributes_arm_d_inside_its_own_option_chunk` ended with
`assert squeezed.count("not defined upstream") == 1`, with the comment "the
literal belongs to Arm D alone". That was true when it was written and it is a
guard worth keeping -- but it goes red the moment a SECOND arm is attributed
the same way, on an axis that has nothing to do with whether the new arm is
correct. Left as it was, the cheapest way past it during the Arm E round would
have been to weaken Arm E's attribution, which is the opposite of what the
guard is for.
The assertion is now derived from a declaration:
ARM_ATTRIBUTION = {"outline-run": True, "max-segment-chars": False}
Per chunk, an arm either carries the literal or must not; the whole-output
count is `sum(attributed)`. Arm C stays False on purpose -- its help says "not
defined in the K3 method file" instead, and the point of the check is that
each arm's attribution sits in its OWN chunk rather than anywhere in the file.
Semantically identical this commit (1 == 1). It is committed on its own, before
the flag it unblocks exists, so the generalisation is visible as a deliberate
change rather than as collateral inside a feature diff.
Tests first: not applicable -- this commit's product IS the test, and its
correctness is that it stays green against unmodified production code.
1233 -> 1233. ruff check: exit 0. ruff format --check: exit 0. pytest -q: exit 0.
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>
Step 12's README section is brought forward to here because the docs gate is
right: a feat commit that ships a new command needs the command documented.
CLAUDE.md's Commands section gains the pre-pass beside `okf build`. Nothing
else moves.
Contract check against a real payload from the 629-concept bundle:
$ .venv/bin/python tools/okf_consume.py <K2-bundle> \
--question 'Hvordan skal prisene fylles ut?' --out /tmp/k2.json
$ .venv/bin/python tools/okf_contract_check.py \
--skill skills/okf-consume/SKILL.md --payload /tmp/k2.json
conformant: 14 rules over 8 excerpts and 621 withheld entries, 0 findings
exit=0
And the two negative controls, because a green checker proves little on its
own -- measured, it returns 0 findings on an empty payload paired with the
unfilled template:
broken denominator identity -> NOT conformant, 2 findings, exit=1
missing payload file -> exit=2
Placeholder scan, known-positive first: the DOTALL scan reports 20 occurrences
on the template and 0 on this copy. The shipped example payload is generated
from the in-repo golden bundle, not from the corpus, and a test regenerates it
byte for byte. No K2 concept path or document title reaches any tracked file
here, checked with a pattern shown able to find against the bundle's own index.
Suite run after git add: 1224 passed, mypy --strict clean on 26 files,
ruff clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two corrections the plan did not carry, both found by running the instrument:
- A question matching nothing still returned the top eight by tie-break -- a
confident guess wearing a denominator. The closed rule set gains
no_lexical_match, so the known-negative returns a measured empty set with
every considered concept named in withheld.
- The stage-one document prior summed overlap across a document's units, so it
measured document SIZE. Measured on K2 for the price question: the
competition document sums to 6.0 over 79 concepts (0.076 each), the price
document to 2.0 over 1. The prior is now a density; the price-form gold moves
from outside the top eight to rank 1. Found with that answer visible, which
the measurement document states beside the number.
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 rows at zero, so K2 for them was unmeasured rather than
passing. This measures them on the synthetic set committed in `a7b050b`,
whose hand count landed BEFORE any of these numbers existed.
Coverage 59/59 and pairing 20/20 for all three, standalone and product
alike, where "product" is scored off the persisted concept in the bundle
rather than an intermediate. The instrument's own denominator, derived
from each file's XML or stream, agrees with the hand count on all three:
59 strings, 20 pairable. Door B over the fixture directory persists 3/3
with nothing quarantined, rejected, failed or skipped, so K1b holds at
3 = N. There is no sub-100 % cell to explain, and that is a result about
three documents we wrote, not about the formats.
THE NEGATIVE CONTROL IS WHAT MAKES THOSE FIGURES MEAN ANYTHING. Removing
one label and its value from each converted text moves every row to
57/59 and 19/20 -- exactly the two strings and the one pair taken away.
`tests/test_fidelity.py` carries that control per reader, so it cannot
rot into a claim.
The order names `okf_fidelity` as the instrument and the instrument
refused all three types (`no source-string reader for '.pptx'`), so step
2 could not run without readers for them. Added here, stated in the
report as sitting outside the order's enumerated fence: this is the
measuring instrument, not extraction code. `extract.py` is untouched and
every `_EVIDENCE` row still reads `unmeasured` -- three synthetic
documents in one house style are exercise, not coverage, and the suite
asserts the rows are not promoted.
Two converter findings, reported and not fixed, both exit code 0 with no
warning. RTF cell paragraphs without `\pard\intbl` are read as each row
nested inside the previous one, 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`. Any Word-produced
RTF carrying Norwegian letters loses one character after each of them,
silently. That is now a named limit of the `rtf` row rather than an
unknown.
Pandoc 3.9 and the host's 3.10.2 produce byte-identical output for all
three fixtures, so the numbers are not sitting on a version-fragile edge.
`~/corpora/` was neither read nor written; N there is still 43.
Suite 1141 passed, `ruff` clean, `mypy --strict src/ tools/` clean over
25 files, all after `git add`. Regenerating the fixtures leaves
`git diff --quiet` 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>
Until now "run the door over a folder" was a shell loop over two scripts
under `tools/`, with nine flags between them and a `--path-prefix` rule
that lived in a code block in a measurement report. Neither script was
packaged (`pyproject.toml` ships `src/llm_ingestion_okf` only), so the
path the published K1/K2 numbers were measured on was reachable from a
clone and nowhere else.
`okf build <folder> --bundle <dir>` is that path, packaged, declared as a
console script and installed with the wheel. It is orchestration only:
the proposer and the corpus harness MOVED into the package
(`llm_ingestion_okf.propose`, `llm_ingestion_okf.corpus`) and the two
`tools/` scripts became thin entry points to them, so the published
reproduction blocks still run and there is exactly one implementation of
each rule. Neither move adds a dependency or a model call.
Two decisions belong to this layer and are stated where they are made.
A document's proposed paths are scoped by its RELATIVE PATH minus the
extension, not its basename: the door walks recursively now, and two
documents named alike in different folders would otherwise collide on a
path Door B is supposed to make impossible rather than merely detect.
And omitted timestamps do not come from the clock -- `--ingested-at` and
`--proposed-at` default to one shared epoch constant, because a
wall-clock default would put a changing byte in the artifact and take
rebuild-equals-incremental away from every caller who did not pass them.
Arm C and Arm D stay off and are not exposed here.
Measured on the 43-file K2 corpus, one invocation against the two-script
bundle of 2026-09-03: N = 43 computed, merged 39/43, coded rejections
4/43 (`extractor_unknown` 3, `extractor_empty_pdf` 1), K1b 39 + 4 = 43,
exit 0, 779.43 s. 1107 of 1108 files byte-identical. The one that
differs is the root `index.md`, by exactly the `log.md` link a commit
fifteen hours younger than the stored artifact adds -- appending that
line to the stored file reproduces the new one byte for byte. Against
the two scripts at THIS commit the trees agree in full, which is what
the byte-identity test holds.
Suite 1127 passed after `git add` (1113 before), mypy --strict clean,
ruff clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Door B listed `inbox.iterdir()` and kept only top-level files. A file in a
subdirectory was neither ingested nor refused: it appeared in none of the
result's buckets, so a nested drop produced a bundle that was silently short
of what was dropped and no count said so. That broke the K1b identity for any
inbox with folders in it. Operator decision 2026-09-06.
- `walk_inbox` is the ONE walk rule, shared with `tools/okf_corpus_run.py`:
the denominator N is now counted over exactly the set of files the door
ingests, rather than over a second listing that happened to agree.
- Sorted on the whole relative path, not the basename, so the order is a
function of the tree; that is what keeps rebuild-from-scratch byte-equal to
an incremental update.
- A concept's `source_file` is the path relative to the inbox root,
`/`-separated. The concept NAME still comes from the basename, so two
folders holding one basename hit the existing §3 collision refusal instead
of one silently claiming the other's concept.
- Dot-directories and a bundle directory inside the inbox are skipped with a
CODE, in a new `InboxResult.skipped`. Recursion makes the door's own output
reachable as its own input; a silent skip would be the same
absence-without-a-denominator defect one level down.
- `--path-prefix` reduces per component and rejoins with `/`, so the caller
driving a nested corpus can carry the relative directory. Reducing the whole
string folded the separator into a `-` and flattened `sub/sub2`.
`tests/test_inbox_flow.py::test_subdirectories_are_not_walked` asserted the
opposite and is superseded in place, with the reason written down.
Measured on the K2 corpus (flat, N=43): 39/43 merged, 4 coded, K1b holds. The
bundle digest is
`1472e98aec8643c5beee540f4c42b5e437bd26e7c61d69a91bcff799f06a6d13` over 1108
files -- byte-identical to a run of the same corpus at 190086f WITHOUT this
change (`diff -r` exit 0), so recursion costs a flat inbox nothing. It differs
from the stored 2026-09-03 artifact by one line in `index.md`
(`- [Corpus run history](log.md)`), which 95eb271 added 15 hours after that
bundle was built.
Suite 1113 passed, `ruff` clean, `mypy --strict src/ tools/` clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
run() parses the plan before building anything and parses its own
verdict after. Only the first failure is the operator's file.
The second is reachable: an empty --adjudicator produces a verdict the
grammar refuses ("adjudication field 'adjudicated_by' must be a
non-empty string"). The plan parsed fine; the fault is in what this
command stamped onto it.
Catching SegmentationError at the top of main() -- the previous commit
-- caught both raise sites and printed "malformed plan" for each. On
this path that is a clean, confident, WRONG diagnosis: it sends the
operator to fix the one artifact that was fine. Worse than the traceback
it replaced, because a traceback at least does not claim to know.
The verdict parse now raises AdjudicationError, which is what "this
command failed" already means in this file and already returns 2. Exit
code unchanged either way, nothing written either way; only the message
changes.
Suite 1073 -> 1074 passed (pytest exit 0, measured without a pipe);
ruff and mypy --strict clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A SegmentationError raised by the plan grammar escaped main() as a
traceback and exit 1, while every other malformed-plan case in the same
file already returned 2. Exit codes are the interface a caller scripts
against, and exit 1 with a traceback is the code an unhandled bug
produces -- it says "this command broke" where the truth is "this file
is not a plan".
The refusal itself is unchanged: nothing was written before and nothing
is written now, and the grammar in src/ is untouched. What changes is
one line on stderr naming the error code, and the exit code.
Both branches that can raise are covered: the pre-write parse of a
non-empty plan, and the required-field check reached through the empty
branch.
The old behaviour was pinned by
test_an_entries_value_that_is_not_a_list_is_still_refused, which
asserted that a wrongly-typed `entries` reaches the caller as a raised
SegmentationError and recorded that as a finding rather than fixing it.
That test is rewritten here, in the same commit as the code, to assert
exit 2 plus the code on stderr. A second test pins the one-line stderr
shape on the non-empty branch.
Suite 1072 -> 1073 passed; ruff and mypy --strict clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Order 20260904T172353Z-6290714297-from-.claude. Bilag 9.1's 95.1 % CID
share (docs/2026-09-04-k3-arm-c.md) was found ad hoc, with no committed
script -- the same gap this repo's own fidelity instrument criticizes in
Arm A's uncommitted docx/xlsx figures. okf_cid_measure.py runs the exact
extract_text call the door makes and reports per-document CID share and
4+-letter word count, denominator stated for files it cannot measure.
Red-first: tests/test_cid_measure.py pins measure() against fixture text
of known composition before the implementation existed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Arm C is NOT defined in docs/2026-09-02-k3-k4-k5-metode.md -- that file
contains no occurrence of the word, and neither Arm A nor Arm B is defined
there either. The definition implemented here was written for order
20260904T145630Z and is reported as the author's, never as a ratified one.
Arm C = Arm B's mechanical rules, plus one deterministic rule that cuts
any proposed span longer than a declared cap at the nearest paragraph
boundary at or before it, the whole document counting as one span when
the rules find no boundary at all.
One rule and not two, on purpose. The two failure modes the K2 rebuild
measured -- a PDF with no outline (Bilag 9.1, 217 472 characters) and a PDF
whose headings are its table of contents, so the trailing segment absorbs
the body (Bilag 3.1, Bilag 1) -- are the same failure of SIZE, and a second
rule aimed at each would confound which one moved the number.
`--max-segment-chars` defaults to 0, which is OFF: the artifact is then
byte-identical to Arm B's, pinned by a test that writes both and compares
bytes. The standard profile does not move, and the K2 bundle a consumer is
running against right now is not rebuilt.
What Arm C deliberately does NOT change: the region before the first
candidate is still covered by no segment. That is a real coverage defect --
the K3 baseline's blind rater named it -- and fixing it here would put two
changes behind one measurement.
A part carries TWO rule names in `derived`: the heading rule that opened the
span, and `rule:size-split` for the cut. Dropping the first would make a
part traceable to arithmetic and nothing else.
Tests first: 13 red, then green. 1055 -> 1068.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The guard could not read back what this library WRITES. At 1.2.0,
`okf.parse_frontmatter` refused the OKF v0.2 golden outright --
`OKFFrontmatterError: value begins with a disallowed YAML indicator '['`
against `sources: [{ id: golden-v0-2-sales, resource: fixture }]`. Flow is
the only form this library can emit, because its own line-oriented parser
cannot round-trip the block form at all, so a gate that refuses flow
refuses everything Door A produces under `OKF_V0_2`.
The control was run BEFORE the bump, which is the only moment it exists:
the probe raised on 1.2.0, so the new test discriminates rather than
merely passes. `[project.dependencies]` already said `>=1.2,<2.0` and is
unchanged; only `[tool.uv.sources]` and `uv.lock` move.
TWO gate rows moved, not the one the work was scoped around, which is why
the whole documented probe was re-run instead of just the `sources` case:
the BLOCK form of `sources` now passes too, retiring G30. That changes
nothing about what we emit -- our own parser is still the binding
constraint on writing flow -- and `docs/okf-nokkelinventar.md` now carries
a `guard 1.3.0` column beside the 1.2.0 measurement rather than
overwriting it. A third row kept its verdict but changed its reason, so
the quoted message was corrected too.
The Door C boundary is unmoved, verified with a known-positive:
`resource` is allowlisted only inside a `sources` entry, so section
10.2's `executor.resource` and `attester.resource` are still rejected
("not on the OKF mapping allowlist under 'executor'") while top-level
`resource` passes.
`uv.lock` also gains `pypandoc-binary==1.17`. That is a stale lockfile
being corrected, not a new dependency: it was already declared in the
`[extract]` extra, and `uv lock --check` reports the lockfile out of date
on the untouched tree. Core keeps exactly one runtime dependency.
Not addressed, and recorded rather than built: the guard reports that
`sources[].resource` is scanned as text but never URL-validated, because
SPEC 5.1 permits bundle-relative paths and scope descriptions. No
consumer has asked for a gate there.
Guard 1.3.0 installed from 44e2b31, verified anonymously over https
against the remote tag. 1054 -> 1055 tests. `mypy --strict` clean, `ruff`
clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured on the K2 artifact by a consumer: `log.md` was on disk and no
index named it, so a reader entering the bundle at `index.md` -- the walk
section 8 exists to support -- never reached the one file carrying `N`.
Stated as a LOCAL choice rather than conformance, because it is one.
Upstream's own bundles do not link their log: measured at `9a15b13`, 0 of
the 24 shipped `index.md` files name the single `log.md` in the set, with
the same grep form finding `tables/index.md` in 4 of them as the
known-positive control. That shows the link is not REQUIRED -- not that
it is disallowed. `docs/plan/okf-v0.2-alignment.md` P1-F6 already
recorded the upstream shape; a line there now separates the two claims,
since reserved names still stay out of an `entries_match_directory`
listing and this profile has that off.
It lives in the harness because the library cannot make it. The log's
content IS the run's outcome, so it cannot exist when the indexes are
projected, and an index that enumerated it off the directory would gain
the link only from the second run onward -- breaking
rebuild-equals-incremental, the property the segmented bundle is built
on.
The membership test is load-bearing and was measured, not assumed. The
two reprojections disagree about this line: the per-directory one drops
every managed entry before re-emitting its block, while the flat one
keeps a managed line whose target is not an owned concept, deliberately,
so that a regex cannot delete curated content. Appending unconditionally
therefore doubled the entry on the second unsegmented run, which is why
both run modes are pinned separately.
1052 -> 1054 tests. `mypy --strict` clean, `ruff` clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`log.md` is written INTO a directory Door B enumerates on the next round: it
matches the concept glob and is excluded only by `index.md`'s name, so a
rebuild could have seen it as pre-existing curated content or pruned it.
Rebuild-equals-incremental is the property the segmented bundle rests on.
Measured on the real artifact, not only the synthetic: the K2 corpus was run
a second time into the same bundle and compared against a snapshot with
`diff -r`, exit 0 over all 1108 files. The test pins the same property in
seconds instead of 13 minutes.
Also corrects the report's reproduction command -- it documented plan
filenames the run did not use, and re-running it into the existing plans
directory would leave two files claiming one `source_sha256`, which
`_resolve_plans` refuses.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured on the K2 corpus 2026-09-03: 11 of 39 documents proposed zero
segments -- overwhelmingly PDFs with no declared structure, which Topic 1b
had already measured at 23 of 33. The proposer wrote an artifact for each
of them and exited 0.
An empty plan cannot be replayed. `process_inbox` refuses one by design,
because a plan naming no entry would persist nothing for a document that
was dropped, so the only thing a zero-entry file can do is fail a run
later -- and it did: the first segmented corpus run stopped on
`segmentation_plan_invalid` before writing a single concept.
Exit 1 with no artifact, distinct from 2, so a driver can tell "this
document lands as one flat concept" from "stop". The orphan check's test
now observes the same property through the status.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured on the K2 corpus 2026-09-03: 39 documents proposed 618 entries
under 601 distinct paths -- 17 paths were claimed by two documents each.
Section numbering is document-local (`1 Innledning` is in most procurement
documents), so this is structural, not unlucky. Every collision reaches
Door B's gate, which refuses per DOCUMENT, so those documents would land as
coded rejections rather than concepts and a corpus run could not be built
at all.
`--path-prefix` is an argument and not something the tool derives: the
proposer sees ONE document and cannot know what else is in the bundle. It
is reduced to the id grammar before anything is read, and a prefix that
reduces to nothing is refused rather than silently producing the unscoped
paths the caller asked to avoid. Without the flag every artifact already
produced is byte-identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>