Commit graph

244 commits

Author SHA1 Message Date
a37d5ced38 fix(consume): match an identifier by equality, deliver the concept a question names
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>
2026-09-08 13:27:59 +02:00
116d3e1007 feat(consume): weight a lexical hit by its rarity, off by default and measured
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>
2026-09-08 12:36:57 +02:00
56c1205ec4 fix(consume,propose): hold an identifier number as one token, give an orphaned heading's name to its table
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>
2026-09-08 11:43:39 +02:00
56ae274246 fix(extract,build): write a spreadsheet as pipe tables, stop linking the run log from the index
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>
2026-09-08 10:06:58 +02:00
6776c37d23 feat(consume): measure the budget lock, add one flag-gated top-rank reservation
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>
2026-09-08 05:18:30 +02:00
4c699fdbb1 feat(consume): measure the below-k blind spot, add one flag-gated vocabulary bridge
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>
2026-09-08 04:55:45 +02:00
5a0c8794af fix(build,consume): stamp every segmented concept, exclude a linked log.md from concept navigation
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>
2026-09-08 03:21:36 +02:00
35b8c9e805 docs(measure): K3 unit worksheet delivered for operator, no code changed
Order 20260907T141419Z-2723316647-from-.claude: a 12-document, three-question
worksheet so the operator can answer the unit-of-knowledge question
docs/2026-09-07-k3-arm-e.md left open before any new arm is attempted. The
worksheet itself (concept splits per arm, extracted document structure,
blank checkboxes) is local-only and gitignored because it quotes corpus
content; this commit adds only the method record, which names no corpus
path or document title.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:36:16 +02:00
fbaac6d2a1 chore(measure): Arm E round closed, suite green after git add
STATE.md rewritten (local-only, gitignored) with the NESTE block, the board
line and the route scoring for the next session. Nothing else to stage: the
working tree is clean and every artifact this round produced is either already
committed or deliberately local.

Gate suite, run AFTER git add so nothing untracked was measured as green:

  uv run pytest -q                    1258 passed        exit 0
  uv run mypy --strict src/ tools/    28 files, Success  exit 0
  uv run ruff check .                 All checks passed  exit 0
  uv run ruff format --check .        88 files formatted exit 0

Baseline at the start of the round was 1230 tests and 27 mypy files.

The next step is NOT another arm. It is the operator answering the unit
question, and the report says why: the verdict is a conditional in two clauses,
and the blind row carries one document's worth of information about this arm --
only 1 of Arm E's 3 movable documents falls in the fixed blind positions. A
threshold set on that measures the protocol, not the arm.

No push, as the order requires. Measured rather than assumed: 35 commits are
unpushed on origin/main..HEAD, 13 of them from this round, and 0 carry a
Claude-Session trailer (checked with git log --format=%B, not --oneline, which
prints subject lines only and could never have found one).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 11:51:16 +02:00
a7ee942d5f docs(measure): K3 Arm E beside Arm B and Arm D -- three rows, no threshold
Three rows rated in one round by one first-rater identity, then two blind
raters per arm as the order asked -- 36 first-rater verdicts, 36 blind ratings,
six k/6 figures. No threshold is set and none is implied.

First rater: Arm B 8/4/0/0, Arm D 4/5/0/3, Arm E 4/3/0/5. Against the direction
declared before the row was seen -- fewer `too fine` WITHOUT more `too coarse`
-- `too fine` goes 5 -> 3 and `too coarse` stays 4.

Blind raters: `too fine` is 1 of 6 in EVERY arm, Arm E included. The report
publishes both rows and states the verdict as a conditional, because they are
measurements of the same twelve documents and only one of them is favourable.

The disagreement is one position and it is legible. At position 10 both Arm E
blind raters kept `too fine` where the first rater moved to `correct`, and
their REASON changed rather than persisting: under Arm B and D they object that
thirteen table rows are severed from their header; under Arm E, where the table
is one concept with its header, they object that the table is severed from the
sentence that introduces it. Arm E fixed the first complaint and does not touch
the second.

And the denominator that governs how much that can say: the blind positions are
fixed at 0, 2, 4, 6, 8, 10, and only ONE of Arm E's three movable documents
falls in that set. The blind row carries one document's worth of information
about this arm. No threshold should be set on that, and the report says so.

The ceiling is measured this round rather than inferred. A committed instrument
extracted all 43 files and counted table ROWS in the text -- 3 of 39 documents,
57 rows -- because zero ENTRIES is not the same claim: the orphan check can
delete a table candidate before it becomes an entry, and one sample document
produces no plan at all so its zero would be an absence with no denominator.
The 57 reproduces the Arm D round's independent pre-work screen exactly, with
neither measurement derived from the other.

Controls are split by what they do on failure, which is this round's correction
to its own first plan: gating controls halt, predictions are reported whatever
they say. Only one prediction was allowed to halt -- a zero-diff run, which
would be a wiring bug wearing a null result's clothes, with the ceiling standing
ready as a plausible wrong explanation.

Nine limits are stated rather than left implicit, including three that weaken
the arm's own case: the character class was fitted to the same three documents
it is measured on; position 5 has the round's largest reduction (21 concepts to
6) and its label does not change; and pandoc's simple and multiline table forms
carry no pipes at all, so the ceiling is bounded by which form the converter
chose rather than by how many tables the corpus holds.

Pure ASCII (0 non-ASCII bytes), short document labels only (0 corpus filenames).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 11:48:58 +02:00
fb46424a05 chore(measure): six blind raters, two per arm, n_blind 6 each
36 blind ratings, six k/6 figures, as the order asked -- twice the Arm D
round's protocol, which used one rater per arm. Blindness is structural: six
separate subagent spawns, each given one arm-neutral file and told to read no
other. Rule names were stripped, because a rule:table-grid in the list would
have identified the arm.

The rows:

  Arm B  blind-1a 4/1/0/1   blind-1b 4/1/0/1     agreement 5/6, 5/6
  Arm D  blind-2a 2/1/0/3   blind-2b 3/1/0/2     agreement 6/6, 5/6
  Arm E  blind-3a 2/1/0/3   blind-3b 2/1/0/3     agreement 5/6, 5/6

Within-arm agreement exists for the first time, because there are two raters
per arm: B 6/6, D 5/6, E 6/6. It bounds self-consistency and nothing more --
every rater is an instance of the same model family.

The finding the first-rater row does not contain: `too fine` is 1 of 6 in
EVERY arm, Arm E included. Both Arm E raters kept `too fine` at position 10,
which the first rater moved to `correct`. Their reason changed rather than
disappearing. Under Arm B and D they object that thirteen table rows are
severed from their header; under Arm E, where the table is one concept with its
header, they object that the table is severed from the sentence that introduces
it. Arm E fixed the first complaint and does not touch the second.

And the denominator that governs how much any of this can say: the blind
positions are fixed at 0, 2, 4, 6, 8, 10, and only ONE of Arm E's three movable
documents falls in that set. Positions 5 and 11 were seen by no blind rater. So
"the blind row did not move" is partly a fact about where the protocol looks,
not only about the arm, and the report says so rather than letting the number
stand alone.

One disagreement is a limitation of the material rather than of a rater: at
position 6 the first rater calls Arm B `too coarse` because a chapter is
ABSORBED into a neighbour and does not exist as a concept, which a rater seeing
only the emitted concepts cannot detect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 11:45:59 +02:00
d0f2d67a5f chore(measure): first-rater verdicts for Arm B, D and E
36 verdicts, one rater identity, one round, all three arms re-rated rather than
carried over. The rows:

  Arm B  8 too coarse / 4 too fine / 0 duplicate / 0 correct
  Arm D  4 / 5 / 0 / 3
  Arm E  4 / 3 / 0 / 5

Against the direction criterion pre-registered in the brief before the row was
seen -- fewer `too fine` WITHOUT more `too coarse` -- `too fine` goes 5 -> 3
and `too coarse` stays 4. Both halves are published. No threshold is set and
none is implied.

Against the ceiling measured in step 9 (3 of 39 documents carry any table row,
so at most 3 of the 12 can move), the row moved 2 of the 3 it could. The third
is position 5, and it is the finding worth more than the two that moved: Arm E
takes it from 21 concepts to 6, which is the biggest single reduction in the
round, and the LABEL DOES NOT CHANGE. Each of its three references is still cut
into a 114-char title concept carrying a heading and no body, plus its table.
Joining the table rows removed most of the fragmentation and left the rest.

Two things this rating is not. It is not independent: this rater had read the
Arm D report's published rows, so Arm B reproducing 8/4/0/0 and Arm D
reproducing 4/5/0/3 is consistency, not confirmation. And positions 2, 6 and 8
are `correct` only under the reading that a top-level chapter is one unit of
knowledge -- the question Arm D surfaced, which is the operator's and is not
decided here.

Sources were verified before rating, not assumed: both archives reproduced
byte-for-byte in step 8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 11:43:11 +02:00
ac540710f6 chore(measure): Arm E corpus run and the reach instrument, figures recorded
No code. The figures this round publishes, and the controls that let them be
read, are now in the local measurements file.

The ceiling stopped being an assumption. The committed instrument extracted all
43 files and counted table ROWS in the text itself, not entries in a plan:
3 of 39 extractable documents carry any table row, 57 rows in total. That 57
reproduces EXACTLY the independent pre-work screen the Arm D round published
(57 of 35 050 lines) without either measurement knowing about the other. Both
declared expectations -- 38 grid-rule lines across 3 documents -- hit their
value, so the shipped grammar is the grammar that was measured.

The Arm E run differs from the Arm D archive on exactly 3 of 33 plans, and
those three are the ones named before the rule was built: 04.json (position 5)
21 -> 6 entries, 02.json (position 10) 15 -> 3, 43.json (position 11) 2 -> 1.
The NN.json to position mapping is derived from each plan's own path prefix
against the committed draw, not from the glob index the file numbers come from.
The one outcome that would have been a wiring bug rather than a finding -- no
file differing at all -- did not occur.

Corpus totals: 709 -> 681 entries, 33 table-block candidates -> 5, and 5 of 681
entries carry rule:table-grid.

Path churn is 0, and the denominator is computed rather than declared: 676
spans are present in both arms and none of them received a different concept
path. The plan had declared 686, which was wrong twice over -- 28 entries are
removed, not 33, and a joined block's end moves so its pair matches nothing in
Arm D. Publishing a control with a wrong denominator is the failure this round
is supposed to be about, so the computed number is the one of record.

Nothing here gated. Step 8's controls halt; these are reported whatever they
say, because the predictions came from an exploratory replica and a replica may
not sit in judgement over the shipped rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 11:40:42 +02:00
5184a59162 chore(measure): gating controls passed, both identity runs reproduce their archives
No code. This commit records that the Arm E round is allowed to be read.

Seven gating controls, all green, each one asking whether the SHIPPED rule is
the rule being measured:

- `src/` diff touches only `propose.py` and `cli.py`; 136 changed lines in
  `propose.py`, most of them `#:` comments.
- The consumer bundle `K2-bundle-20260903` is unchanged against the literals
  already published in the Arm D report: 1108 files and
  9cd745194346cda0c70eab9c7136fa44506203bbe85bc17d7eff2766c6e9b4d1 under
  LC_ALL=C. Pinned to a published value rather than compared before-and-after,
  because a pair both taken this round cannot see a mutation that predates it.
- 0 network imports in the proposer and in both instruments.
- Arm B identity: the whole corpus re-run with no flags at
  --proposed-at 2026-09-03T00:00:00Z is byte-identical to
  K2-plans-baseline-20260907. diff -r exit 0.
- Arm D identity: the whole corpus re-run with --outline-run 3 at
  --proposed-at 2026-09-07T00:00:00Z is byte-identical to
  K2-plans-armd-20260907. diff -r exit 0.
- Door counts: 4 of 43 .err files record FAILED, so extractable is 39/43,
  unchanged, and measured from the run artifacts rather than by building a
  bundle nobody needs.

Three things about the identity controls are deliberate. They compare against
the ARCHIVES, not against a second run of the same binary, which is the only
way to detect a flag accidentally defaulted on. The json and .err counts are
asserted BEFORE the diff, so `diff -r` cannot exit 0 by comparing nothing. And
`_index.txt` is INCLUDED in the comparison rather than excluded: the Arm D
round's published block both omits its generation and excludes it, which would
let NN.json name a different document across two runs without anything saying
so.

The Arm B run is not bookkeeping. It is the corpus-level half of the promise
that --outline-run 0 with the flag absent is still Arm B, and it is what makes
the Arm B row rated later in this round rest on verified bytes rather than on
an archive nobody re-checked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 11:25:33 +02:00
b1977c27ac feat(tools): a re-measurable grid-table reach instrument for K3 [skip-docs]
`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>
2026-09-07 10:56:12 +02:00
c1d0ba237d feat(propose): --table-grid selects Arm E, absent is off [skip-docs]
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>
2026-09-07 10:53:59 +02:00
b293977ebf feat(propose): a joined block names the grid rule beside the table rule
`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>
2026-09-07 10:51:16 +02:00
7dae0d1a3a feat(propose): a grid rule line does not close an open table block
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>
2026-09-07 10:49:52 +02:00
dc67f86511 feat(propose): name the grid-rule grammar Arm E measures
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>
2026-09-07 10:47:36 +02:00
45f1013308 test(propose): count the arm attribution per option chunk, not per output
`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>
2026-09-07 10:46:10 +02:00
3fa772e917 test(propose): pin today's grid-table split with a golden that can fire
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>
2026-09-07 10:45:05 +02:00
54a0bc26ef docs(consume): refutation sweep — smoothed-IDF rarity weighting cannot zero both known-negatives
Order 20260907T074541Z-939882634-from-.claude asked for smoothed-IDF rarity
weighting so no_lexical_match gates on informative tokens, with both
known-negative controls (sveising/titan/vakuum; pingviner/Bouvetoeya) required
to reach delivered == 0 on the real K2 bundle (629 concepts, 39 documents).

Swept the threshold across the full observed IDF range (1.5-7.9) before
building anything, per Verifiseringsloven face 3: verify an input premise
against ground truth before acting on it. The frontier is empty. neg2 first
reaches 0 at T=4.6, and gold-hit reachability has ALREADY fallen to 0 of 6 at
that same T. neg1 does not reach 0 until T=6.4, by which point every positive
question has been unreachable for eighteen threshold steps. The two rarest
tokens in the entire gold set (titan idf 6.059, vakuum idf 6.347) belong to
the sveising question, so rarity weighting scores them as maximally
informative rather than as noise.

Two structural causes, not just a bad threshold pick: (1) df is measured over
the same four-character prefix matcher that collapses brannalarm/branntrapp/
branncelle/brannkonsept into one bucket, so a real topic word reads as the
LEAST rare term in its own question; (2) MIN_TOKEN_LENGTH=3 admits tokens
MIN_SHARED_PREFIX=4 can never match (hva/for/den are structurally df=0),
leaving some positive questions resting on a single surviving token.

No production code changed — the mechanism the order named is falsified
before implementation, so there is no red test to write against it. Returning
the order with this measurement as the reason.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 09:56:36 +02:00
d7751c0b9a test(consume): hit@8 over six questions against a random-ranker baseline
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>
2026-09-07 09:37:14 +02:00
51735fa7a8 feat(consume): instantiate the K2 consumption skill, and document the pre-pass
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>
2026-09-07 09:30:32 +02:00
5d066f1799 feat(consume): a CLI with three exit codes and an asserted ref
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 09:22:15 +02:00
42ab9c1d1a feat(consume): assemble a payload that measures its own emitted bytes
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>
2026-09-07 09:20:50 +02:00
7eb87a04cb feat(consume): cut by exact knapsack with a closing partition
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 09:16:05 +02:00
f4b1f4ef0b feat(consume): fuse concept signals by RRF with declared tie-breaks
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 09:14:04 +02:00
0550870ae2 feat(consume): rank documents from the indexes with stem matching
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 09:13:27 +02:00
b8c43198b5 feat(consume): a byte budget instrument that validates before it reports
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 09:11:54 +02:00
aa33555208 feat(consume): derive trust_tier, and withhold what cannot be tiered
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 09:10:37 +02:00
1ec3f5289c feat(consume): read a concept, with adjudication absence as its own state
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 09:08:41 +02:00
4f970f61c1 feat(consume): walk a bundle and compute a content-identity ref
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 09:07:40 +02:00
bd44929c89 test(fidelity): K2 on pptx, odt and rtf -- 59/59 and 20/20, on a set we wrote
`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>
2026-09-07 05:22:12 +02:00
a7b050b569 test(fixtures): a synthetic K2 denominator for pptx, odt and rtf
`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>
2026-09-07 05:17:18 +02:00
4ce14ae5dd feat(cli): okf build, one installed command for folder in, bundle out
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>
2026-09-07 05:06:33 +02:00
aa87eb8818 feat(inbox): walk the drop directory recursively
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>
2026-09-07 04:11:00 +02:00
190086fc3d docs(measure): K3 Arm D beside a re-rated Arm B -- reach and no threshold
The first rater's row moves 8/4/0/0 to 4/5/0/3; the two blind rows are
identical at 5/1/0/0, and the disagreement sits on exactly the three
positions labelled correct. Both are published, and the verdict is a
conditional on what counts as one unit of knowledge, not a number.

Controls: src/ untouched, flag-absent and --outline-run 0 byte-identical
over the corpus, the consumer bundle unchanged at 1108 files and
9cd745194346cda0c70eab9c7136fa44506203bbe85bc17d7eff2766c6e9b4d1,
door-level counts 39/43 unchanged, pre-gate totals 144 and 23/39 as
declared. No threshold is set.
2026-09-07 03:04:01 +02:00
16dab23947 feat(tools): a re-measurable outline-reach instrument for K3 2026-09-07 01:38:25 +02:00
6e2238454c feat(propose): --outline-run selects Arm D, default 0 is off 2026-09-07 01:34:59 +02:00
5080240366 feat(propose): the outline rule proposes boundaries, off by default 2026-09-07 01:32:20 +02:00
47ae1ed7e3 feat(propose): Arm D reads a document's own numbered outline 2026-09-07 01:23:29 +02:00
c97e250f69 test(propose): pin the default artifact with a committed golden 2026-09-07 01:20:31 +02:00
798f64ad6b fix(tools): do not blame the plan for a verdict okf_adjudicate built
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>
2026-09-05 07:35:14 +02:00
a72053f66f fix(tools): okf_adjudicate exits 2 on a malformed plan
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>
2026-09-05 07:31:00 +02:00
acf2ac24d7 docs(measure): K2 on pptx/odt/rtf -- denominator 0 in both named corpora
Re-measures the declared UMÅLT gap from docs/2026-09-02-korpuskjoring-k1-k2.md
against ground truth (find, not STATE). K2/trinn1 and "K3-korpuset" are the
same directory (both K1/K2 02.09 and K3 09.04 run against
~/corpora/okf-telling-20260829/K2/trinn1) -- one corpus, counted once. 0
pptx, 0 odt, 0 rtf, known-positive control (docx: 5) proves the query can
find. Sum 0 across both named corpora ends the order at step 1 per its own
instruction; no K2 step 2 work started, no code added. No public corpus with
these formats is on hand under ~/corpora -- reported as not verified, not as
nonexistent.

Order: 20260904T190727Z-589245685-from-.claude

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M7Qvq71UCQZwkrLT7WbeB9
2026-09-04 21:12:41 +02:00
446d97656a docs(measure): CID-glyph share across K2 -- Bilag 9.1 alone, not a K1 arm
Order 20260904T172353Z-6290714297-from-.claude. 39/43 K2/trinn1 documents
measured with tools/okf_cid_measure.py; the 4 not measured are the same 4
K1 already named as coded rejections. Over 10 %: 1/39. Over 50 %: 1/39 --
Bilag 9.1 both times, at 95.0743 % (reproduces the published 95.1 %). No
other document in the corpus crosses either line. Known-negative control
holds (a docx gives 0 CID characters on a nonzero total). The word count
(44) does not reproduce the earlier ad hoc 98 -- no script for that figure
was ever committed, so nothing exists to reconcile against; both counts
are gibberish letter runs, not prose, so the substantive finding does not
move. K2 bundle hashed before and after, byte-unchanged (1108 files).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 20:23:41 +02:00
861c11e9f4 feat(tools): a re-measurable CID-glyph-share instrument for K2
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>
2026-09-04 20:23:34 +02:00
98be70c144 docs(measure): K3 with Arm C beside the baseline, two numbers, no threshold
Arm C changed the proposal on 6 of the 12 sampled documents and moved the
K3 category counts by ZERO. Both arms: too coarse 8, too fine 4, duplicate
0, correct 0. That is the finding, not a null result -- the size rule makes
concepts smaller, which is what the consumer asked for, and K3 does not
measure size. It asks whether a boundary falls where a unit of knowledge
begins, and an arithmetic cut does not know where one begins.

Blind second rater per arm, n_blind = 6, two separate raters so an arm C
rating cannot anchor on an arm B one: 5/6 in each arm, and the two blind
raters' own six-document counts are identical to each other. The single
disagreement is the same document as the baseline's -- with the roles
reversed, and resolved by a specific offset in one direction and a general
impression in the other, exactly as the baseline described. The `too
coarse` count is still a floor.

Reported rather than smoothed: this first rater records 8/4/0/0 on the same
arm B proposals the 2026-09-02 baseline scored 7/3/1/1. The proposals did
not change; the rater did, and both differences are traced.

What did move, and is not K3: max concept body 217 472 -> 19 988,
concepts over 100 000 characters 4 -> 0, documents proposing nothing
11/39 -> 6/39, concepts 629 -> 680.

Found while measuring, not looked for: Bilag 9.1 -- the largest concept in
the bundle and the one the consumer flagged -- is an EXTRACTION failure.
95.1 % of its 217 470 characters are (cid:N) glyph codes and 98 alphabetic
words of 4+ letters survive in the whole document. Arm C divides unreadable
text into 14 pieces of unreadable text. Its verdict in both arms rests on
document identity, not on readable content.

The bundle a consumer is running against right now is byte-unchanged:
whole tree, 1108 files, sha256 9cd74519... before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 17:58:50 +02:00
0c7a485c11 feat(propose): Arm C cuts an over-long span at a paragraph boundary
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>
2026-09-04 17:06:58 +02:00