Commit graph

297 commits

Author SHA1 Message Date
c7d662e91c test(skill): the shipped consumption skill must pass the checker against its own payload
Red on f5a002e + 07df6a0, by design. The hand-filled skills/okf-consume
was refused by the checker it tells its reader to run, against the payload
shipped beside it:

  NOT conformant: 16 rules over 3 excerpts and 0 withheld entries, 1 findings
    bundle_mismatch: the skill declares no readable bundle identity, ...

No existing test stood the SHIPPED pair against each other: the checker
tests generate a skill in memory, so the file on disk was never read.

The second test holds the regeneration to the generator's bytes. `okf
skill` writes the bundle root and the skill path absolute when --out is
not under .claude/skills/, so the shipped copy is the generator's output
with the checkout prefix stripped, and the test is that strip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 02:10:38 +02:00
07df6a0db6 chore(lock): sync llm-ingestion-okf version to 0.8.1
The R3 version bump moved pyproject.toml to 0.8.1 and never reached the
lockfile, which still recorded 0.7.0 for this package. Measured on a git
archive export of HEAD f5a002e with uv 0.9.8: `uv lock --check` exits 1
on the tracked lock ("needs to be updated") and 0 after `uv lock`, and
the diff between the two is exactly one line under
`[[package]] name = "llm-ingestion-okf"`. Nothing else in the lock moves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 02:05:31 +02:00
f5a002e4ed docs(k3): round 17, a covered title stops beneath a title that answers more
The diagnosis, the eight forms measured and the rows that did not move.
What separates the known-negative from the standard's six questions is not
the group size, the title length or the share of the question: it is that the
title directly above the covered concept shares 3 question tokens against the
covered title's 1, while on the standard no title above a covered concept ever
shares more than it holds.

(a) title length sells hit@1 back to 3/6; (b) question share holds only for
1/9 < s <= 1/6; (c) and (d) cannot act on a group of one; (e) ships. The one
shape still read wrong (a constructed shortening of the known-negative) is
the same shape as a scored question with the opposite answer, and the form
that fixes it takes that question from rank 1 to 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 01:45:53 +02:00
e15de71aa4 fix(consume): a covered title rises only past titles that answer no more of the question
Round 16's partition read every concept whose WHOLE title the question
accounts for before everything the fusion ranked above it. That is a claim
about the covered title's PRECISION, and it overrode the fusion even against
a title answering MORE of the question. Measured on a 26-concept bundle of
five documents: the question names a section by three title tokens and holds
a neighbour's whole one-token title (1 of 9 question tokens); the fusion put
the named section at rank 1, the partition moved the neighbour over it.

A covered concept now RISES through the fusion's order and stops beneath the
first concept whose title answers more question tokens, by equality, than it
holds, or beneath a covered concept the fusion put above it. With nothing
above it answering more it reaches the top exactly as before. Same flag
(--title-covered / --no-title-covered), no new parameter, no new constant.

Measured before this commit, delivered ranks from build_payload:
- known-negative: rank 2 -> 1; the payload equals --no-title-covered's
- three own probes on that bundle: 1, 1, 1 (unchanged from round 16)
- R761 XML, 2 761 concepts: hit@1/8/50 6/6 - 6/6 - 6/6 at default k and at
  --k 50, KP rank 1; 8 of 8 payloads byte-identical to 7cca9e0 at BOTH k
- payloads byte-identical to 7cca9e0: K2 pinned 6/6, Arm B 6/6, N100/N200/
  N500 15/15; tests/test_default_bundle_pin.py 7 passed, file untouched
- candidates measured beside it: min title length (R761 hit@1 3/6), share of
  the question (holds only for 1/9 < s <= 1/6), order inside the group
  (group of one: no effect), stop list (no title involved is one)

Suite on the staged set: 1600 passed, 1 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 01:32:49 +02:00
8cc2c54bd0 test(consume): a short title the question happens to cover passes the section it names
K3-17's known-negative as a mechanism, on a hand-written fixture in an
invented setting. The question names a long section by four of its title
tokens and happens to contain the whole of a neighbour's one-token title.
The fusion reads the long section first; round 16's partition lifts the
covered one-token title over it, because the partition states the covered
title's precision and never compares it with what the title above answers.

Two tests are red on 7cca9e0 and are the defect:
  test_a_covered_short_title_does_not_pass_a_title_answering_more_of_the_question
  test_a_blocked_covered_title_leaves_the_payload_as_the_fusion_built_it
Five are green and guard the repair's shape: the fusion order without the
rule, the group of one, round 16's upside past a narrower neighbour, the
blocker count by EQUALITY (a prefix-only match must not block), and
invariance to words no title holds (why the repair is not a share of the
question).

pytest -q on 7cca9e0 + this file: 2 failed, 1598 passed, 1 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 01:23:14 +02:00
7cca9e079e feat(check): a skill and a payload naming different bundles is a finding
`okf check` had fifteen rules and none asked whether the skill and the payload
were talking about the same bundle. Reproduced on this HEAD before any code
moved: three pairs reported `conformant: 15 rules over 8 excerpts and 438
withheld entries, 0 findings` -- a skill generated from one corpus against
another corpus's payload, the unfilled template against that payload, and a
payload sharing the skill's `bundle_id` at a foreign `ref`. All three now exit 1
with one `bundle_mismatch` finding over 16 rules.

BOTH halves are compared and the `ref` half is load-bearing: three distinct
builds on this machine carry one `bundle_id`, so an id comparison would pass a
stale skill. SS 3.3: "a version is the producer's assertion; a ref is a fact
about bytes". An identity the rule cannot read is a finding, never a silent
pass -- that is what refuses the unfilled template.

No new field: the identity was already in the generated skill's prose, now
factored into `skill.identity_line` and read back by
`contract_check.skill_identity`. Generated skill bytes unchanged, measured on
both tracked bundles on one interpreter.

The rule's first real find is this repository's own hand-made
`skills/okf-consume/SKILL.md`, which predates `okf skill` and declares no
identity a reader can act on: 1 of 1. Nine tests that asserted the old, false
conformance now pair a skill with its own bundle's payload.

Measured, nothing else moved: `~/okf-test/dokumenter` `diff -r` empty old
source vs new on one interpreter (52 files, 26 concepts), `okf project` still
byte-equal to `okf build`, K2 pin unmodified and green (453 concepts, ranks
1,1,1,1,1,5), known-negative `{}` unchanged at 9 findings.

Report: docs/2026-09-10-k3-runde15-bundle-mismatch.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 23:42:06 +02:00
b5df3355c5 test(check): a skill generated for another bundle must not be conformant
`okf check` reported `conformant: 15 rules over 8 excerpts and 438 withheld
entries, 0 findings` for three pairs it should have refused: a skill generated
from one corpus against a payload assembled from a different one, the unfilled
template against that payload, and the same skill against a payload sharing its
`bundle_id` at a foreign `ref`. Reproduced this round on this repository's own
two tracked bundles, which differ in both halves of the identity.

The `ref` half is what makes this a rule rather than an id check: three distinct
builds on this machine carry one `bundle_id`, so the id does not identify the
bytes. SS 3.3: "a version is the producer's assertion; a ref is a fact about
bytes".

Red: 8 failed, 1585 passed, 1 skipped. The three arms that already pass are the
controls -- the two bundles differ, the right pair is conformant, and a payload
declaring no identity stays `ref_missing`'s defect at 9 findings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 23:25:12 +02:00
113b3f801c docs(release): R3, v0.8.1 tagged locally on 3daf983
The release report, in S7 form. Three premises deviated from the order and
each is stated before the work rather than after: the push list has THREE
lines (main, `v0.8.0`, `v0.8.1`) because the remote carries no `v0.8.x` at
all; the XML folder holds 110 files, not one, of which 109 are images coded
`extractor_unknown`; and the downside the changelog had to publish was
recorded in NO tracked file, so it was reproduced here before publication --
rank 1 -> 2 on one constructed known-negative, the other seven delivered
places unmoved.

The acceptance gate is installation from the tag in an isolated export, run
entirely from that export's own venv. The operator's PATH was measured and
not touched: `which okf` reports not found both before and after, so this
round neither installed nor removed anything there.

Honesty limits are their own section: one machine and one interpreter, one
five-document corpus, one publisher's files for both large rows, a grep
criterion that deliberately leaves two `v0.8.0` history lines standing, and
the plain statement that "the tag installs" is not "the tag is correct" --
no row here re-measures hit@1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 23:10:30 +02:00
3daf983de1 chore(release): 0.8.1 v0.8.1
A patch release carrying round 16's ranking rule and nothing else. No
behaviour changes in `src/`: the version string is the only line touched
there, and it is touched because it is the second half of the version --
`tests/test_packaging.py` holds `pyproject.toml`'s declaration and
`__init__.__version__` equal, and a tag whose package reports the previous
number lies to every consumer that installs it.

- `pyproject.toml` and `src/llm_ingestion_okf/__init__.py`: 0.8.0 -> 0.8.1.
- `CHANGELOG.md`: round 16's own entry closes as `[0.8.1]`, with a new empty
  `[Unreleased]` above it and no comparison link, because the file has
  published none since `[0.6.0]`. The entry gains one bullet it did not
  carry: the rule's MEASURED DOWNSIDE, reproduced here on a five-document
  folder -- a constructed known-negative question demotes the answering
  section from delivered rank 1 to rank 2 because a neighbouring concept
  titled with a single common process word has its WHOLE title accounted
  for. Written as a known limitation of short generic titles, not as a fix.
- The five install lines (`README.md` 4, `llms.txt` 1) move to `@v0.8.1`.
  Measured after: `okf.git@v0.8.0` 0 hits, `okf.git@v0.8.1` 5 hits as the
  known-positive control, and the two remaining bare `v0.8.0` occurrences
  are the tag-history row and the test-count history -- both are statements
  about the past and both stay.
- The tag-history list gains `v0.8.1` as the current tag and demotes the
  `v0.8.0` row to what it was; `v0.7.0` is untouched, and `docs/` is not
  rewritten at all.
- The test-count history sentence named the `v0.8.0` release as the last
  one and would have become false on this tag: it now records 1582 through
  `v0.8.1` as well. The published figures 1583 collected / 1582 passed /
  1 skipped were re-measured here and stand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 23:03:28 +02:00
4d1b5222b7 docs(k3): round 16, the whole title takes the route precision could not
The report, the CHANGELOG entry under [Unreleased] and the README's ranking
section. No version bump and no tag: v0.8.0 was released this same day.

R761, delivered ranks, before -> after: hit@1/8/50 3/6 - 5/6 - 5/6 -> 6/6 -
6/6 - 6/6 at default k and 3/6 - 5/6 - 6/6 -> 6/6 - 6/6 - 6/6 at --k 50, with
the known-positive at rank 1 on all four and the known-negative still not a
hit. --no-title-covered on the new binary reproduces the old row exactly.

Nothing else moves, measured on the bytes: the pinned K2 bundle and Arm B keep
(1,1,1,1,1,5) with payloads byte-identical on 6 of 6 questions each,
n100/n200/n500 payloads byte-identical on 5 each, the 828-file HTML corpus at
828 plans / 0 unreadable / 6 015 md with diff -rq empty, the five-document
folder diff -r-identical at 26/52, and okf project still byte-equal to okf
build.

README's test count corrected to what this round measures: 1583 collected,
1582 passed, 1 skipped. The published 1575 was the passed count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 22:26:57 +02:00
ff06b92c35 test(consume): the lookup partition still wins, and the CLI defaults agree with the signature
Two guarantees this round STATED, now each with a test that goes red if it
stops being true.

The partition lands BELOW `lookup_hits`: asserted with its own control, since
with the lookup off the covered concept IS first on the same fixture, so the
assertion measures which partition wins rather than that only one fires.

`build_payload`'s signature defaults against the consume CLI's argparse
defaults, for every same-named parameter. This is O6's defect in the other
command: `cli.build` defaulted two flags `False` in the signature and `True`
in argparse, and a caller reaching it as a function read the signature.

Suite: 1582 passed, 1 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 22:20:42 +02:00
6bb21891b8 feat(consume): a question that accounts for a concept's WHOLE title takes it first
`title_covered_hits` names the concepts whose every title token is a token of
the question, and `concept_scores` reads them before the rest -- a PARTITION
below the lookup partition, never a fourth RRF signal.

The arithmetic that rules a signal out is the same one `lookup_hits` was made
a partition on: RRF consumes ranks alone, so with shared ranks a rule whose
positive group is SMALL separates least of all. Measured on a 2 761-concept
bundle of one standard, the group is 1, 1, 1, 1, 9, 2, 0 and 0 concepts over
the eight questions -- as a signal it moved hit@1 not at all.

By EQUALITY and never by shared prefix: four shared leading characters take
the group from 1 to 6 on one question and 9 to 31 on another, and the
answering section falls to candidate rank 6 with the known-positive to 2.

ON by default with the opt-out `--no-title-covered`. Suite: 1580 passed,
1 skipped; the pinned K2 bundle green and unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 22:08:57 +02:00
14ae36ad2e test(consume): a section whose whole title the question answers loses to its narrower neighbour
RED. The fixture is handwritten and carries no sentence from any corpus: one
source document, one broad section titled with the question's subject alone,
one narrower section adding a qualifier the question never uses, one
known-negative whose title shares four leading characters with the subject,
and twelve fillers.

Measured on the fixture at HEAD (02f9876):

  1 bb-narrow 0.04918 lex 4 'Temporary anchoring'
  2 cc-prefix 0.04918 lex 4 'Anchorage'
  3 aa-broad  0.04866 lex 3 'Anchoring'

which is the same shape as the three R761 misses. pytest -q on this file:
4 failed, 1 passed. The one that passes is the characterisation the rule
stands on -- with one source document the third signal takes ONE distinct
value over the whole bundle, so a third of the fusion carries no information.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 22:05:02 +02:00
02f9876e16 docs(release): R1, v0.8.0 tagged locally and installed from the tag
The release itself is `4d1f9d3` and the tag stays there; this is the record.

Three deviations are named rather than glossed. The commissioning acceptance
`grep -rn 'v0.7.0' = 0 outside CHANGELOG` cannot be met, because three files
under `docs/` name the old tag as a fact about the past and a report is made
false by being rewritten. It also contradicts the same order's instruction to
keep the `v0.7.0` row in README's tag history, so the criterion was narrowed to
what it protected -- no install line and no "current tag" claim on the old tag
-- and every grep here was run against a known-positive first. Third, the
version also lives without a `v` prefix in `src/`, where no search for `v0.7.0`
reaches it; a packaging test caught it, and left alone the tag would have
reported 0.7.0 to every consumer installing it.

The gate was installation FROM THE TAG, not from the worktree: 26 concepts /
52 md / 0 of 5 rejected on the five-document folder, `proposed 828 plan(s);
0 document(s) with no boundary` on the 828-file HTML corpus, and `conformant:
15 rules ... 0 findings`. The limits travel with them -- one machine, one
interpreter, one corpus per row, and "the tag installs" is not "the tag is
right".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 21:14:19 +02:00
4d1f9d3a5a chore(release): 0.8.0 v0.8.0
Round 13 (the `.xml` core file type read as NISO-STS, and the PDF arm's
collision counter) and round 14 (a section the source DECLARES takes the
declared-structure route: `.xml` goes from 15 of 2 761 to 2 761 of 2 761
boundaries and from 23 to 2 761 concepts at the shipped defaults, hit@1/8/50
0/6 - 0/6 - 0/6 to 3/6 - 5/6 - 6/6) are both landed. This commit adds no
functionality: it sets the version, closes the CHANGELOG entry, and points
every install line at the new tag.

- `pyproject.toml` and `src/llm_ingestion_okf/__init__.py`: 0.7.0 -> 0.8.0.
  The second is the only line in `src/` this release touches. It is not a
  code change but the other half of the version, written without a `v`
  prefix, so a search for `v0.7.0` cannot find it;
  `test_packaging.py::test_the_declared_version_agrees_with_the_packaged_one`
  is what did. Left alone, the tag would report the previous release to every
  consumer that installs it.
- `CHANGELOG.md`: `[Unreleased]` becomes `[0.8.0] - 2026-09-10`, with a new
  empty `[Unreleased]` above it. The entries are round 13's and round 14's own
  words, unchanged. No compare link is added: this file has carried none since
  `[0.6.0]`, and inventing one here would be a claim about a URL nobody checked.
- The five install lines and the two prose lines naming the current tag move to
  `v0.8.0`. The tag history list gains a `v0.8.0` row as the current tag and
  KEEPS the `v0.7.0` row: that list states it is not install lines, so a
  rewrite would delete history rather than update it.
- README's test count was 1515, measured 2026-09-09; this tree measures 1575
  passed / 1 skipped with ruff 0.16.6. The surrounding sentence about the
  earlier figure is repaired too, because changing the date alone would have
  made it false.

`v0.7.0` stays on 1260fac. No lock change, no history rewrite in `docs/`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 21:09:22 +02:00
078b7df94f docs(k3): round 14, the declared-structure route at the shipped defaults
The report, with every measurement carrying its denominator and both
instrument forms side by side on every row -- including the proof that the two
forms cannot both reach 2 761, because 2 739 of 2 761 fasit titles carry a
numbering token and the two keys then demand mutually exclusive strings.

Also states two things round 13 left unsaid:

- XML that is not STS gives 0 plans and exit 2, and the `.txt` known-positive
  shows it is general `okf build` behaviour for any structureless document,
  not an `.xml` defect. Separating the two states would change the outcome on
  0 of the 4 reference corpora, so the gate stays.
- Which two instruments were corrected in scratch and what the correction is.
  With the shipped matcher the XML bundle scores 0/6 with `rang: null` on
  every question, including the known-positive whose top excerpt IS the fasit.

Round 13's 14 two-file directories are re-classified on measurement: they were
false positives of the text route reading the document's own contents listing,
not the publisher reusing a title, and they are gone. Two remain, the same two
the PDF arm has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 07:53:22 +02:00
ee12f644ca feat(propose): a section the source DECLARES takes the declared-structure route
`extract.xml_outline` reports the marks the XML reader wrote itself -- no
bridge, no tolerance constant, no unresolved bucket, because the reader
appended the line it is naming. `find_candidates` gains `outline_rule` so the
route it already had for a PDF bookmark tree can carry a second reader's name,
and `build_plan` chooses it by the ROW (`DECLARED_STRUCTURE_IDS`), never by the
text: the same markdown from a `.md` file is still a guess and still keeps
`rule:heading`.

`rule:xml-section` is orphan-exempt for the reason the bookmark arm is -- the
check asks whether a guess was a heading, and a container section is not a
false positive.

MEASURED on R761 at SHIPPED DEFAULTS, no flag: 23 concepts -> 2761, and
2761 of 2761 declared sections became a concept with the directory and the
title the source states (0 unmatched, 0 concepts matching no declaration).
`a)`-points as concepts 0 of 4954, table blocks 10 of 10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 07:00:37 +02:00
958e9bc998 test(propose): a declared XML structure does not take the route declared structure takes
RED. Nine sections declared by `sts-container.xml`, a hand-written fixture that
carries no sentence from any source: Arm B delivers 8 (the orphan check takes
the container chapter) and the shipped build defaults deliver 3 (Arm F folds
every level below the shallowest repeated one). That is 2 761 -> 2 051 -> 23 on
R761 in miniature, measured.

`find_candidates` already skips both steps for `outline_marks`, which is why
the PDF bookmark arm reaches 2 762 of 2 761. These tests pin the same route for
the one row whose reader wrote the heading itself, under its OWN rule name, and
pin that no other row reaches it: the same markdown from a `.md` file is a
guess and keeps `rule:heading`.

7 failed, 1 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 06:55:00 +02:00
9d5ebc3c29 docs(k3): round 13, and the two proposer rules between a reader and its ceiling [skip-docs]
The reader reaches the ceiling and the build does not, and the report separates
the two with denominators and a control for each: the orphan check removes 710
of 2 761 (710 of 710 removed headings are followed immediately by another
heading; 0 of 2 051 delivered ones are), and Arm F removes 2 066 more, swept
one flag at a time.

Both instrument forms on every row, because the pair key scores 28 of 2 761
here for a reason that has nothing to do with segmentation -- and the shipped
instrument turns out to carry the round-12 correction in NO block, not just in
the three the order named. That is proposed to the owning repository, never
applied there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 04:13:56 +02:00
f280b14056 fix(extract): a label with no body under it is emitted, not overwritten
GREEN. A pending label prefix is now flushed as its own line when the next
thing out is a heading, when a second prefix would replace it, and at the end
of the document. Measured on R761: non-whitespace preservation goes from
1 283 395 against 1 283 393 to **1 283 395 against 1 283 395, ratio
1.000000** -- exact, which is what the invariant claims. ATX heading lines
2 761 of 2 761, unchanged.

Known-negatives held: `~/okf-test/dokumenter` still byte-identical against the
frozen `e1f4faa` export (`diff -r` empty), and that folder has 0 `.xml` files
of its 5, so it is a real control and not merely an unchanged number.

pytest -q: 1567 passed, 1 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 04:09:55 +02:00
fc238440d7 test(extract): an exact invariant does not get to be 0.999998
RED. Measured on R761 after the reader landed: non-whitespace preservation was
1 283 393 characters against the source's 1 283 395. Two characters, and a
percentage would have let them pass -- a ratio of 0.999998 reads like rounding.
Located by a prefix/suffix scan rather than a diff: the whole loss is one `x)`
marking a `<sec>` that carries a label and nothing else.

The mechanism is this reader's own. A label-only section holds its label as a
PREFIX for the body line beneath it, the way `li` is handled in the HTML
reader; when no body line follows -- the next thing emitted is the following
section's heading -- the pending prefix is overwritten by the next one and the
label never reaches the text.

`sts-empty-label.xml` is the case, hand-written and small: a lettered point
with a body, then one with none, then the next titled section.

pytest -q: 1 failed, 1566 passed, 1 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 04:07:35 +02:00
567a63d455 feat(extract): the file that IS the product now reads, as a core stdlib type
GREEN on the twelve tests from the two commits before it. `.xml` joins
`_CORE_EXTRACTORS` beside `.html`, and 110 of 110 unreadable becomes a bundle.

A CORE type, not an `[extract]` one, because the parser is stdlib: putting it
behind the extra would make a pure-stdlib file type binary-dependent for no
gain. And never through the converter, which the existing fence test now says
in one more line: a file routed there is read by a second parser that never
sees this reader's DTD refusal, so the hardening would be true of the code and
false of the file.

TWO PATHS, NAMED RATHER THAN GUESSED. STS when the root is `<standard>` or the
document holds any `<sec>`; generic otherwise. Generic XML keeps its text in
document order and gets NO structure -- an element name is never promoted to a
heading, because an RSS feed is not a numbered standard.

THE GRAMMAR IS MARKDOWN, the same markdown the office rows and round 11's HTML
row reach the proposer through, and `propose.py` is untouched. `<sec>` with a
`<title>` becomes one ATX line carrying `<label>` + space + `<title>` at its
own nesting depth; `<sec>` with only a `<label>` becomes a body line with the
label in front, the way `li` is treated in HTML -- 4 954 of R761's 7 715 are
lettered points and one heading each would bury its own 2 761; `<table-wrap>`
becomes its label plus ONE markdown table through this package's own
`render_table`, separator line included, which is what makes it a block.

TWO CHOICES THAT ARE MEASUREMENTS, not preferences:

- Inline by ALLOWLIST, block by default -- the inverse of the HTML reader,
  because block-versus-inline is a property of HTML and XML has no such
  universal. Assuming block is safe (an extra break never removes text and a
  boundary needs a line that matches a grammar); assuming inline is not. The
  allowlist is load-bearing: that document carries 1 701 `<italic>` and
  1 396 `<bold>` inside its prose.
- The ATX ceiling is 6 and STS nesting reaches 7. The depth is CLIPPED, not
  dropped: 9 of the 2 761 titled sections sit at depth 7 and `#######` matches
  nothing, so dropping loses the section while clipping keeps the boundary and
  states the nesting one level too shallow.

A DTD IS REFUSED UNPARSED, and that is a guarantee about this code rather than
about the machine. Measured on this interpreter (3.14.0, pyexpat 2.7.3): an
external SYSTEM entity is refused by the stdlib and never fetched, but the
amplification limit that stops a billion-laughs comes from libexpat >= 2.4.0
and NOT from Python -- five levels still expand -- while `pyproject.toml`
requires only `>=3.10` and no lockfile pins an interpreter. `XMLParser` exposes
no `.parser` attribute on the C accelerator, so the handler route is not
portable either. NO new dependency: `defusedxml` and `lxml` both occur 0 times
in `uv.lock` and still do.

pytest -q: 1566 passed, 1 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 03:41:02 +02:00
5bb3b045c2 test(extract): the two registries are coupled, and only a whole run says so
RED, 12 failing. Two corrections to the previous commit's tests, both found by
running rather than by reading.

ONE, AND IT IS A REAL GAP THIS COMMIT CLOSES: `okf build` on an STS document
does not fail in the extractor at all -- it fails two layers away, in
`segmentation.observed_extractor_version`, with `no extractor version is known
for extractor_id 'xml'`. That coupling is deliberate and its own comment says
so: a row added to the extraction registry and not to `_STDLIB_EXTRACTOR_IDS`
"fails loudly on the first proposal for that type". No unit test of an
extractor can see it, because the missing thing is the version the plan is
KEYED to, not the text. The previous commit's tests would all have gone green
with the build still refusing every file. This test is the one that would not
have.

TWO: the expectation that the proposer strips a numbering token out of an ATX
title was wrong about `propose`, not about this reader. Measured: `_ATX` keeps
the whole title and fills `number` in addition, and `_NUMBERED` needs at least
one dot, so `1 Bruksomraade` gets no number while `1.1 Omfang` gets one and
keeps it. Both shapes are now asserted, along with the table-wrap arriving as
ONE `rule:table-block` candidate rather than one per row.

pytest -q: 12 failed, 1554 passed, 1 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 03:36:13 +02:00
324cd0b1e5 test(extract): the file that IS the product had no reader, so state what one owes
RED, 11 failing. Reproduced first, before any code: `okf build` on the folder
a publisher's own viewer delivers gives **110 of 110 unreadable, 0 plans, exit
2**, with `no extractor is registered for file extension '.xml'`. The
conservation identity `merged + coded rejections == N` is never written at all
-- the run aborts earlier on `FAILED - no segmentation plans` -- so the hole
was not even visible as a count.

The one xml file IS the whole product: R761 Prosesskoden:2025, the document
round 12 met as a 701-page PDF, in NISO-STS form. Measured on the file with
`xml.etree.ElementTree`: 7 715 `<sec>`, 2 761 with a `<title>`, 4 954 with a
`<label>` and no title, 10 `<table-wrap>`, 12 528 `<p>`, root `<standard>`, 0
`<!DOCTYPE` and 0 `<!ENTITY`. Its `<sec>`-nesting depths over the titled
sections are 28/118/500/1141/868/97/9 -- row for row the fasit's own
distribution. The ceiling is therefore structural rather than computed.

FOUR HAND-WRITTEN FIXTURES, none through `make_fixtures.py` and none
serialised by `ElementTree`: a library that writes and reads its own format
proves only that it agrees with itself. A known-positive STS mini, generic
non-STS xml, a `<!DOCTYPE` with an entity expansion, and a malformed file.

Two assertions that already existed are extended rather than duplicated: the
converter fence, because a file routed to the converter is read by a second
parser that never sees this reader's DTD refusal, and the evidence table,
because a row without a class is the failure that test exists for.

pytest -q: 11 failed, 1554 passed, 1 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 03:30:48 +02:00
525d68ee90 fix(extract): a bookmark that shared a line left no trace, so count it
GREEN on the two tests from the previous commit. `PdfOutline` gains
`collided`, and the identity it buys is the point rather than the counter:
NODES IN == len(marks) + unresolved + collided. Measured on R761 with the
shipped function, 2 763 == 2 762 + 0 + 1, where before the same document
reported 2 762 marks and 0 unresolved and said nothing about the third
number. The lost node was `SVV - Forside`, sharing line 0 with the tree's own
root node.

THE RULE WAS CHOSEN BY MEASUREMENT, NOT BY ARGUMENT. Keeping both nodes as
sibling marks was run through `find_candidates` on R761's own text and text:
the candidate list goes 2762 -> 2763, and the first candidate closes with an
EMPTY span (0, 0) because two candidates open at one offset. The orphan check
deletes an empty span in silence, so keeping both loses the same node one step
later and adds a front-matter title that is in no fasit. First-in-tree-order
wins, the loser is counted.

Known-negatives, all measured rather than assumed: `outlined-krav.pdf` and
`outline-broken-dest.pdf` unchanged in outcome and reporting `collided == 0`;
`~/okf-test/dokumenter` byte-identical against a frozen `e1f4faa` export
(`diff -r` empty, 52 md); the pinned K2 bundle's concept count and per-row
ranks unchanged (7 passed). The arm stays off by default and no boundary rule
moved.

pytest -q: 1556 passed, 1 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 03:23:31 +02:00
3f8f03cff5 test(pdf): two bookmarks on one line, and nothing counts the one that is lost
RED. Measured on R761: 2 763 /Outlines nodes entered the bridge, 2 762 marks
came out, and `unresolved` was 0 -- so one node left no trace in the return
value at all. The lost one is `SVV - Forside`, sharing line 0 with the tree's
own root node `R761 Prosesskoden`. The cause is structural, not an edge value:
the marks are collected in a dict keyed on the destination line index, so a
second bookmark on a line is discarded by `setdefault` in silence. One node
today; a document with several bookmarks per line loses several and reports
none of them.

`outline-collision.pdf` is the fixture no existing one could stand in for --
every other outline fixture puts one bookmark on one line, which is exactly
the case where the defect cannot appear. It is laid out by hand through
`make_fixtures.py` like the other PDF fixtures, and regenerating that file
left every other fixture byte-identical.

The known-negative is in the same commit: `outline-collided.pdf` aside, the
two existing trees must report `collided == 0`, so the counter can be measured
at zero and is not green for an unstated reason.

pytest -q: 2 failed, 1554 passed, 1 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 03:16:14 +02:00
e1f4faa098 feat(propose): the PDF shipped a structure index and the build discarded it unopened
`okf build` recovers a PDF's boundaries from the shape of its page text and
never opens the file's own `/Outlines` bookmark tree. On a 701-page process
code whose publisher also ships a NISO-STS structure for it, measured outside
this repository and reproduced here exactly: the shipped default finds 1967 of
2761 titled sections, 0 of its 28 chapters, and 794 of 794 misses have their
heading text PRESENT in the extracted text. The line was read; the boundary
was never opened. The same file's bookmark tree matches 2761 of 2761 of those
titles exactly after normalisation.

`--pdf-outline`, OFF, cuts a PDF at the boundaries its tree declares.

  boundaries                 1967 of 2761  ->  2759 of 2761  (gate was 2700)
  depth 1                       0 of 28    ->     28 of 28
  titles identical to source        --     ->   2761 of 2761
  false positives             163 of 2182  ->      3 of 2762
  directories with two files  132 of 2050  ->      2 of 2738
  front-matter concepts        72 of 2182  ->      2 of 2762
  consumption fasit present       4 of 7   ->        7 of 7
  hit@1 / hit@8 / hit@50      1/6 2/6 4/6  ->   3/6 5/6 6/6

It is a SEGMENTATION arm, not a reader option: the extracted text is byte for
byte the same either way. A PDF with no tree builds byte-identically with the
flag on -- `diff -r` empty across the pre-change tree, the arm off and the arm
on. An unresolvable `/Dest` is dropped and COUNTED, never fabricated into a
boundary and never a refusal of the file.

The bridge from (page, y) to a line index is the whole risk, so both routes
are measured. `extract_text_lines` splits lines identically to `extract_text`
on 701 of 701 pages, and is CHECKED per page rather than assumed. The y route
and the title route disagree on 0 of 2762 nodes, flat from a 0pt tolerance to
8pt and collapsing at 12pt, so the rule ships with no tolerance constant. The
naive "nearest line" rule was wrong on 1840 of 2762, one line early every time.

The orphan check is not applied to a bookmark mark: it asks whether anything
stands under a candidate's first line, which is the right question for a
heuristic's guess and the wrong one for a publisher's declaration. 683 of 2762
marks are container sections; applying it scores 2079 instead of 2759.

No new dependency and no second parse of the pages: `pdfminer.six` already
ships under `pdfplumber` in `[extract]`. 119.22s -> 183.31s wall, peak RSS
3252 -> 3251 MiB. The default does not move; 1 of the 8 reference PDFs carries
a usable tree at all.

`.pdf` also gains its `_EVIDENCE` row, as `measured` -- it was the row with the
most measurement behind it and no entry in the table.

Report: docs/2026-09-10-k3-runde12-pdf-outlines.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 02:27:41 +02:00
8e1917946a docs(k3): round 11, and the corpus that rebuilt byte-identical [skip-docs]
The report for both deliveries, in S7 form, every measurement with its
denominator and an "Honesty limits" section.

Three of the order's own figures did not reproduce and are corrected in section
0 rather than smoothed over: the dominant document takes 22 of 43 places here
and not 23 of 42, the known-positive ranks 8 and not 12, and at k = 8 it takes 7
of 8 places and not 8 of 8. All three divergences point the same way and have one
likely cause -- SK1 measured a frozen export of `6ff18fd` and HEAD is `de9564d`,
with round 10's prefix work in between, which changes the matcher the ranking
reads. This session's numbers are the ones used.

A control caught a defect in this session's OWN measuring query: the first
dominance run reported 2 of 7 fasit prefixes missing from the bundle, and they
were not missing -- the harness read the last segment of a concept id where the
document is the first. Corrected to 0 of 7 missing, and only then were ranks
read.

Records the strong form of "no other file type moved": the whole 43-document
reference corpus rebuilt on this HEAD is byte-identical to the pinned bundle,
`diff -rq` 0 differences, digest 21af4a1aa98315cf... and 453 concepts. The file
census (0 of 86 corpus files are HTML) is an argument that the changed path is
never reached; the rebuild is a measurement of it, and round 10's own trap -- a
green aggregate over a set where the difference cannot appear -- is why the
census alone was not accepted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 01:10:59 +02:00
1e9f38b125 feat(consume): one source document took 8 of 8 delivered places, so cap it
Measured outside this repository on a 3206-concept bundle of a published
handbook: the code's own process overview contributes 28 of 3206 concepts
(0.87 %) and 117 488 of 1 469 225 source characters (8.0 %), and took 8 of 8
delivered places on one question and 7 of 8 on the known-positive -- which was
not delivered at all. Identical at 343 and 1651 concepts, so the cause is the
corpus's COMPOSITION, that it holds its own table of contents, and NOT its size.
Splitting the corpus would move the defect, not remove it: any corpus with a
contents list, a project overview or a summary document has the same property.

`--source-quota N` caps how many DELIVERED places one source document may take.
It cuts where the shortlist is cut -- before the pack, never inside the DP,
which maximises a sum over a set it is handed -- so the freed place goes to the
next candidate and `k` is still delivered in full.

DEFAULT 2 SINCE TODAY, and it is the third change here that alters a payload
with NO bundle changing (after `--tie-shared-rank` and `--stem-prefix`).
Opt-out `--no-source-quota` reproduces the previous excerpt order.

Swept over {2, 3, 4, off} on three bundles, with the fasit prefixes validated
against the bundle FIRST (that control caught a defect in the measuring query
itself -- it read the last id segment where the document is the first):

- K2, both bundles: at 2 and 3, hit@8 goes 5 of 6 to 6 of 6 with all five
  standing rank-1 rows unmoved. The recovered row had missed on every bundle and
  every configuration measured until now. At 4 and off it is 5 of 6.
- The handbook bundle: hit@8 2 of 6 -> 4 of 6, the known-positive from not
  delivered to rank 4, and the dominant document's share of delivered places
  8 of 8 -> 2 of 8 (7 of 8 -> 2 of 8 on the known-positive).
- 2 rather than 3 on rank alone: the recovered rows come in at 5 and 4 rather
  than 7 and 5.

WHAT THE GAIN IS NOT. hit@8 asks whether the gold DOCUMENT appears among the
delivered excerpts, and a document quota directly raises how many distinct
documents a payload holds, so that metric is not neutral with respect to this
rule. The five rows that were already rank 1 are neutral, and they did not move.

THE ADVERSE CASE IS NAMED, not left to a consumer. A bundle built from ONE
document carries the same `source_file` on every concept, so a quota applied
literally would deliver 2 excerpts where `k` were asked for -- a rule against
dominance turned into a rule against small bundles. The shortlist is topped back
up from the best-ranked over-quota candidates, which makes such a bundle
byte-identical to the quota being off, and a test holds it.

`--rarity-weight` was measured against the same defect and does NOT repair it:
it leaves the dominant document at 8 of 8 places on the question it floods,
delivers neither that answer nor the known-positive, and holds 5 of 6 on both K2
bundles. Combined with the quota it is worse than the quota alone (the
known-positive falls back out). It stays off.

The vocabulary stays CLOSED and the new code is published in all three places a
consumer can read it: `WITHHOLDING_RULES` (six -> seven),
`docs/consumption-contract.md` 5.3, and the generated SKILL.md -- verified by
reading the generated file, not the code that writes it. `source_quota_exceeded`
is a DIVERSITY drop and not a relevance one, so folding it into
`no_lexical_match` would tell a consumer the question reached nothing in a
concept the question in fact reached. `okf check --skill --payload` stays
conformant, 0 findings over 15 rules.

Editing the contract moved the 7.4 known-positive, which is the coupling
working as intended: 12 563 -> 13 238 encoded, 12 227 -> 12 893 raw, delta
336 -> 345, updated in the constant, the instantiated skill and the shipped
example payload.

Also adds the O6 guard on the reading side: `build_payload`'s signature defaults
are asserted equal to `okf consume`'s argparse defaults for every same-named
parameter. `okf project` shipped that exact disagreement for two rounds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 00:29:02 +02:00
732f84df6e fix(extract): HTML collapsed to one line, so 828 of 828 sections had no boundary
`_HTMLTextExtractor.text()` was `" ".join("".join(parts).split())`. `str.split()`
with no argument splits on newlines too, so extraction of ANY HTML file returned
unconditionally one line. Every boundary grammar in `propose` is line-anchored
(`_ATX`, `_NUMBERED`, `_TABLE_ROW`, `_GRID_RULE`, `_OUTLINE`, each with `^`), and
on one line at most the first can match while a match at line 0 opens no interior
boundary. Measured outside this repo on a consumer's export of a published
handbook: 83 / 414 / 828 `.html` files gave 0 plans, N documents with no boundary
and exit 2 at every point, and a coarser 145-document cut gave 145 of 145. The
same sections as markdown gave 828 of 828 plans -- so the instrument was fine and
`.html` was the one core-supported type that had never met a real document.

Block tags now open lines of their own, `h1`-`h6` carry the ATX marker for their
own level (not a flat `#`, which would hand `_ATX` three top-level boundaries
where the document declares one section and two subsections), `br` breaks the
line, and every other tag stays the word boundary it already was. The output
grammar is MARKDOWN and deliberately the same markdown the office rows reach the
proposer through, so no HTML-only heading grammar exists.

NOT via the converter: `.html` stays out of `_PANDOC_FORMATS` because routing it
there would add CVE-2025-51591 (SSRF via an iframe in HTML input), unpatched in
every converter version. The test asserting that exclusion is untouched and green.

The block set is wider than the five tags the corpus exercises, on purpose:
block versus inline is a property of HTML, not of one corpus, and a `div`-
structured page carries its prose in containers this corpus never uses.

Measured after, all with denominators:
- 828 of 828 plans, exit 0, `merged + coded rejections = 828; N = 828`; 3206
  concepts / 6015 md files, which is the markdown path's count EXACTLY -- 0.0 %
  deviation against the +/-2 % bar, and the same at 50 % (1651) and 10 % (343).
  The coarser 145-document cut goes 145 of 145 with no boundary to 145 plans /
  953 concepts.
- Text preservation as an EXACT invariant, not a percentage: strip the added ATX
  markers and the non-whitespace sequence is identical to the old extractor's for
  the same bytes. 828 of 828 files exact, character ratio 1.000000 against the
  >= 99.8 % bar. 7600 markers added; 31 141 lines produced where the old
  extractor produced 828, one per file.
- `_SKIP_TAGS` unchanged at {script, style}. Dropping nav/header/footer is a
  different change with a different guarantee and is not made here.
- No other file type moved, measured rather than argued: 0 of 86 K2 corpus files
  and 0 of 5 smoke-folder files are HTML, and the smoke bundle is byte-identical
  before and after (`diff -r` empty, 52 md / 26 concepts, 0 of 5 rejected).
  `okf project` stays byte-equal to `okf build` (`diff -r` empty).

Provenance moves with it: `source_units` routed `.html` through `_line_units`
already, but the table was trivial -- every offset resolved to line 1. The
numbers now mean something, and what they mean is a line of OUR extraction (a
BLOCK), never a line of the original markup.

`_EVIDENCE` gains a `.html` row at `measured`, chosen against the class
definitions: the files are a consumer's own export of a real published handbook,
produced for their ingestion and not to exercise this row. What the class does
not claim travels with it -- one product, one format, one publisher, and a
generator's cut.

One existing test changed because the behaviour changed, and it says so:
`test_html_text_via_htmlparser` asserted the collapsed form. The other two
(`test_html_skips_script_and_style`, `test_htm_is_an_html_alias`) were re-read
and hold unchanged -- the order expected three to move; only one did.

The corpus-wide invariant runs in the suite behind `OKF_HTML_CORPUS`: a corpus
path names a consumer's export and this repository is public.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 23:57:47 +02:00
de9564d06a docs(k3): round 10, and the fasit that held a working rule off the default [skip-docs]
The measurement record for `6ff84d7`, `191de89` and `36c201c`, in S7 form with
every figure carrying its denominator and an "Honesty limits" section.

Three of this round's own figures disagree with the order's and the
disagreement is stated first: the corpus has 5 `docx` and 43 files (round 9
counted `trinn1` and `trinn2`, which `diff -rq` shows byte-identical), the
lockfile drift is `uv run` without `--frozen` INSIDE the project rather than
outside it, and ruff 0.16.6 reports 148 rather than 144.

The result that decides the `rtf` rule's default is in section 1.7 and it is
not a missing measurement: `diff -rq` bounds the whole exposure at 1 of 33
document directories, that document is reference position 11, and its fasit
picks the arm that yields ONE concept where the rule yields two. The row would
get worse, so the flag ships off with every number that would justify moving
it. Whether that fasit should stand is the operator's, and it is one position.

Leak sweep with the control run first: 0 corpus paths and 0 K2 document names,
against 15 hits for a known-positive word in the same file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 23:27:03 +02:00
36c201cc8a chore(ruff): the acceptance was whatever the default happened to be [skip-docs]
`uv sync --frozen` resolved ruff 0.15.22 and the tree read clean. A loose
install resolves 0.16.6, under which the SAME untouched code reports 148
findings -- 4 more than round 9 counted, because this round added four files.
All of them are new rules rather than new defects: 0.16 widened the default
rule set to whole families (YTT, ASYNC, PL, ISC, C4, UP, B, SIM, FURB, ...).

(`[skip-docs]` is for CLAUDE.md, which a lint-configuration change does not
reach. README's developer section IS updated in this commit.)

THE DEFECT IS NOT THE 148, IT IS THAT NOBODY CHOSE THEM. `[tool.ruff]` set only
`line-length` and `target-version`, so the acceptance was ruff's default, and
the tree stayed green only as long as the lockfile froze an old ruff. `select`
is now written down: `E4`, `E7`, `E9`, `F` (the historical default), `I`
because this tree already keeps imports sorted, and `RUF100` so a `noqa` that
has stopped meaning anything is caught rather than left as decoration. Pin
`ruff>=0.9` -> `ruff>=0.16.6,<0.17`.

Per rule, before -> after: RUF100 50 -> 0, I001 20 -> 0, ISC004 19, PLW1510 8,
C408 8, EXE001 6, RUF007 5, PLE2515 4, UP031 3, B017 3, and fourteen more with
2 or fewer -- the families out of the declared set are 0 by selection, and 148
is the number to start from if they are adopted, which is a separate decision
and not one to take inside a version-pin commit. 57 were auto-fixed; one E402
was reintroduced by the import-sorting fix merging a block away from its
`noqa`, and got the directive back rather than a bare one.

`S` IS MEASURED OUT, NOT ASSUMED OUT: it reports 2657 `S101` on a suite whose
every assertion is an `assert`, and `S603` flags 19 subprocess calls of which
one was ever marked -- selecting it buys 18 suppressions and no defect. Two
`noqa` directives naming non-selected rules were dropped with that reason
recorded in the configuration instead.

THE TWO FILES 0.16 WOULD REFORMAT ARE MARKDOWN, NOT PYTHON: `README.md` and
`docs/2026-09-08-blindsone-below-k-k2.md`. 0.16 formats fenced Python inside
markdown, and both blocks are RECORDS -- the second is a quotation of
`COST_VOCABULARY` as it stood when that measurement was taken. Reformatting a
quotation makes it stop being one, so markdown is excluded from the formatter
and `ruff format --check .` stays in the acceptance over `.py`.

`tools/okf_consume_measure.py` is fenced by the order as run-not-edited, so its
three findings are exempted by path with the reason and the debt named, and its
bytes are untouched.

THE LOCKFILE TRAP IS CLOSED, NOT AVOIDED. `uv.lock` predated the `[ocr]` extra,
so any unlocked resolve wrote that extra's transitive tree back into it -- 681
insertions over 4 deletions, twice now, and round 9 recorded the cause as
`uv run` OUTSIDE the project when it is `uv run` without `--frozen` INSIDE it.
The relock is complete for every declared extra (703 insertions, 26 deletions),
and measured after it, an unfrozen `uv run` leaves the file alone.

`ruff check src tests tools`, `ruff format --check .` (0.16.6), `mypy src` over
21 files and 1535 tests, all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 23:15:17 +02:00
191de89f41 feat(propose,consume,tools): the type that declares nothing, and the prefix that is not a word
Three of round 9's four measured holes, each closed with a rule chosen on a
measurement rather than named as a limit.

`rtf` GIVES 0 SEGMENTS -> 6 of 6 AUTHORED TITLES over N = 4. The container has
no heading style, so the author's title is bold text. The grammar is markdown,
not `rtf`: the converter already writes that title as `**...**` in the same
output every office row produces, so no `rtf`-only heading form exists. Three
parameters were swept over 47 readable documents and ONE carried -- refusing a
line that ends in terminal punctuation takes false-positive lines from 9-12 to
1-2. A maximum title length (unlimited/40/60/80/120) and a
must-stand-between-blank-lines clause are both FLAT, so neither is in the rule.
The last false positive is closed by G1, the principle `_gate_outline` already
carries: recovery yields to declaration. False positives are then 0 of the 31
declaring documents by construction, and 0 of 27 on the corpus. Reach: 2 of 39
corpus documents, both `docx`, 0 of 33 `pdf` and 0 of 2 `xlsx`. Behind
`--bold-title`, default OFF pending the hit@8 measurement; the default bundle
is byte-identical without it.

BOTH ALTERNATIVES THE ORDER NAMED WERE MEASURED AND FELLED. A fourth hand-laid
fixture DECLARES heading styles in a stylesheet and the converter discards
them, emitting the same bold line -- so "read the declared headings out of the
markdown" has nothing to read. `rtf` -> `docx` -> markdown yields 0 ATX
headings on that same document, because the loss is in the `rtf` READER before
any writer sees the style. Fixtures are hand-laid in `make_k2_office.py` with
the fasit written first; they live in their own directory because Door B walks
a drop directory recursively and `k2-office/` reads its N off the listing.

THE PREFIX OVER-MATCH: THREE CANDIDATES MEASURED, ALL THREE FAILED ON ONE ROW.
Re-measured on the pinned 453-concept bundle with the control run first:
`under` occurs 79 times by equality and matches 172 by prefix, `undersjoisk` 0
and 172, `bilateral` 0 and 400 of 453, `standhaftig` 0 and 219. The two extra
known-negatives were FOUND, not chosen -- every 4-character prefix ranked by
document frequency, then a real word taken from the widest. A longer floor
(5-8), a coverage share (0.5-0.8) and a long-words-only floor (>= 8) each cost
row 1 its rank on the default bundle and the whole row on Arm B. Decomposed:
row 1's token `prisene` reaches its gold document through
`pris|sammenstilling` on four characters -- 0.57 of one word and 0.22 of the
other -- so the over-match and the wanted match are one mechanism.

THE FOURTH CANDIDATE IS THE ANSWER: the shared prefix must be a WORD the bundle
uses. `pris` is; `bila` and `stan` are not. `bilateral` 400 -> 0 and 512 -> 0,
`standhaftig` 219 -> 56 and 235 -> 33, every hit@8 row keeping rank 1 on BOTH
bundles. `undersjoisk` stops at 162 because `under` IS a word here -- a genuine
Norwegian morpheme, so that residual is a different answer, not a ceiling. ON
by default (`--no-stem-prefix`), pinned with its own known-negative on the
shipped bytes.

THE SHIM: a path importer holds the object `module_from_spec` made, and
`sys.modules[__name__] = _impl` never reaches it. Measured under both counting
methods -- 3 of 76 public names by `vars()`. One line copies the public names
into this file's globals; the dunder filter is load-bearing, because an
unfiltered copy overwrites `__name__` before the next line uses it as the alias
key. It restores attribute ACCESS and not patch-through, which is why the alias
stays. A CHANGELOG note under 0.7.0 and a shim docstring line say so, since
what the consumer asked for was the note.

Suite 1515 -> 1535.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 23:05:45 +02:00
6ff84d71c8 style(extract,propose): the formatter was never in the acceptance, so it went red unseen
`ruff format --check .` was clean on `fbad5b0` and red on `6ff18fd`: blank
lines around the new `strip_converter_attribute` and the new `member` closure.
Round 9's order named only `ruff check`, and STATE's "ruff clean" read wider
than what was measured -- an acceptance that names one of a tool's two gates
reports green for the gate it did not run.

Three hunks, blank lines only, no behaviour. From here the acceptance is BOTH
`ruff check` and `ruff format --check .`, both named in the report with the
version they ran under (0.15.22 here).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 22:05:51 +02:00
6ff18fd703 feat(propose,extract,cli): a title that ends in a number, and a converter's own anchor in a concept id
Round 9: the four rests in STATE's NESTE that needed no operator decision.

CLAUSE 1 CLASSIFIED BY THE NUMBER, NOT THE TITLE. `_TRAILING_PAGE_NUMBER`
admitted a candidate into a contents run by asking whether the title ended in
an integer -- a question about the number. A drawing's dimension chain, a
schematic's labels, a door schedule, a coordinate column and a soil-layer
table all end in integers and name nothing. Measured over the 43-document
corpus: 68 candidates discarded over 11 of 39 readable documents, of which
19 over 5 documents are data rows.

That corrects round 8's own decomposition. Its "four misclassified numeric
tables and seven real contents listings" needs each document on one side, and
two of the eleven are both. Read across all 68 titles rather than the
three-title sample: 5 documents carry a data row, 8 carry a real entry.

`--contents-name` requires a NAME to survive stripping the page number. The
threshold is SWEPT, not chosen, and collapses at both ends: at an alphabetic
run of 1 a door schedule keeps a stray `V` and 13 of 19 are rescued; at 3 the
two-letter section name `VA` stops being a name, falls out of run membership,
and takes `RIB`, `MMI` and `Tittelfelt` below `CONTENTS_RUN` with it -- one
acronym costing four REAL entries. At 2: 16 of 19 rescued, 0 of 49 regressed.
The three not rescued carry a real word and are named rather than rounded off.

THE CONVERTER'S ANCHOR WAS IN THE CONCEPT ID. Pandoc writes a sheet as
`## <name> {#sheet-N}` and a titled slide as `## <title> {#slide-N}`. Because
a filename is reduced FROM the title, the anchor reached both. Operator
authorised the strip 2026-09-09 after the exposure was counted: 2 of 810
concepts on the previous default bundle, 2 of 1108 on Arm B, 1 of 26 on the
operator's folder. Two ids renamed, one of which `portfolio-optimiser` has
cited in writing; both are in the report so that message can be sent.

One rule in one function, read by BOTH title-forming sites -- a rule in only
one would leave the id and the title naming the same concept differently. The
known-negative is the point: `Mal for {kundenavn}` is a title an author wrote.

odt/rtf/pptx MEASURED END TO END FOR THE FIRST TIME, on hand-built documents,
because the corpus denominator is genuinely zero (86 files: 66 pdf, 10 docx,
4 xlsx, 2 zip, 2 smc, 2 doc). `_EVIDENCE` gains a third class rather than
stretching an existing one: `constructed` means the row has met a document,
but not one anyone wrote for their own purposes. odt 1 of 1 declared headings;
pptx 2 of 2 on a deck that declares slide titles and 0 of 2 on one that does
not -- round 7's reading of pptx was a fixture property, not the format; rtf
0 segments, because the container has no heading style and the author's title
is bold text. rtf is the one open finding.

ACCEPTANCE, all four. The 12-position reference is label-identical in BOTH
readings (pdf 7/8, docx 3/3, xlsx 0/1 or 1/1, sheet 10/12 or 11/12). One K2
bundle carrying both changes: 453 concepts / 865 md, hit@8 [1,1,1,1,1,None]
on it AND on Arm B, with the known-negative still reproducing on the new
bytes. `okf project` byte-equal to `okf build`, `diff -r` empty. Consumer
cost is a re-run: 436/832 -> 453/865, digest 21af4a1aa98315cf.

Three published numbers corrected: README's 596 tests (1515), README's "15
concepts out" for `okf project` (that was the O6 defect; it is 26), and O6's
print-mode method, which does not reproduce without --allowedTools.

Report: docs/2026-09-09-k3-runde9-restene.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 21:45:08 +02:00
fbad5b0eab docs(o6): the measurement record, including the query that could not find
Restates the order's premises on HEAD (README is 679 lines, not 676; the
`okf project` install line is 320, not 317), records the defect the
reproduction turned up (`okf project` 15 concepts / 30 files against
`okf build`'s 26 / 52, one signature default apart), and states what the
three end-to-end runs and the two-bundle routing measured with the
denominator beside each figure.

Two of this repository's own published figures are corrected: O5's "4
absolute paths -> 0" was measured with `grep -c "^/"` against paths indented
by two spaces, so the zero was never a measurement; and the order's own
"0 occurrences of hypothes/task/write/produce" is false per term (`write` 4,
`produce` 7, all unrelated) even though the modes were genuinely absent.

Honesty limits name N = 1 per row, that routing is 2 questions over 2
bundles and worked on the bundle ID rather than the description, that run 2
overstated one negative (NS 8407 is in 3 of 52 files, all withheld), and
that run 3's document numbers are for a non-default payload it changed and
declared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 18:31:04 +02:00
1260fac154 feat(readme,skill,cli): the first screen an agent reads, three modes, and one flag that made two builds v0.7.0
`okf project` built a bundle two rules behind `okf build`. `cli.build`'s
signature defaulted `keep_table_heading` and `sheet_section_rows` to `False`
while argparse defaulted both to `True`, and `project.create` calls `build()`
as a function. Measured on a five-document folder: 15 concepts / 30 files
where `okf build` wrote 26 / 52, the whole difference in the priced sheet --
the document a question about price has to reach. The invariant test could not
see it: it compared `project.create` against the same function, and its two
fixture documents had neither a table nor a sheet. Both gaps are tests now,
and the two paths are byte-equal on that folder (`diff -rq`, 0 differences).

README opens with what / one install line / two commands / the three shapes of
request; the phase-status paragraph moved down, nothing deleted. One tag is
pinned everywhere: README pinned v0.4.0 on its install lines and v0.6.0 below,
llms.txt pinned v0.4.0, so an agent reading from the top installed a tag
without `okf project`.

The skill states three modes -- question, hypothesis (per premise, `confirmed`
/ `refuted` / `undecidable-from-bundle`), and a task producing a document
(source per claim in the artefact, an ungrounded paragraph written and marked
rather than dropped, the cut declared inside the document). The five markings
are untouched.

Generated skills state relative paths in the project layout: `okf consume
.okf/<id>` and `okf check --skill .claude/skills/<id>-consume/SKILL.md`,
runnable from where `okf project` tells the reader to start `claude`. Two
absolute paths to zero, measured with a query shown capable of finding first --
O5's published "4 -> 0" used `grep -c "^/"` against paths indented by two
spaces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 18:12:02 +02:00
956714594d feat(propose,cli): the coverage gap had one cause, and round 7's own decomposition did not reproduce
Round 7 named two open items: a table-block candidate displacing a declared
heading (26 041 characters between entries, `md` at 3 of 4 declared headings)
and 17 590 characters after the last entry, never examined. Measured on
`a364ef4`, the first premise does not reproduce and the second is not a
separate mechanism.

`md` recovers 4 of 4, not 3 of 4. D1 (`--keep-table-heading`) is the repair
for exactly the mechanism round 7 § 5 describes, and round 7 moved it into
the default in the same commit -- so § 5 is a pre-move measurement presented
as the post-move state. That is round 7's own trap, stated in its own report:
a number about a rule is a measurement of a configuration. Nothing held the
cell, which is why it could be wrong and stay wrong.

The remaining gap has ONE cause. Every rule closes a span against the NEXT
MARK; three steps then remove a mark after its neighbour's `end` was fixed
against it. The orphan check leaks 18 527 characters over 15 of 39 documents,
`fold_units` clause 1 leaks 7 514 between entries, and the same clause on the
last run leaks all 17 590 tail characters -- with `unit_fold=False` the corpus
tail gap is 0. Round 6 already established the principle (filter at admission,
let the mark above carry the text) and it was never applied post-filter.

`--close-span-gaps` states it once, after the fold, where every removal has
happened: a span runs to the next SURVIVOR, the last to the end of the text.
It adds no boundary. Measured: 43 631 characters (2.51 %) -> 0, entries
429 = 429, K2 concepts 436 = 436, the operator's folder 52 md = 52.

All four acceptance conditions hold at once. The 12-position reference is
label-identical -- 11 of 12 under |F|[3]=12 and 10 of 12 under |F|[3]=11,
both readings reported, not one position moved. hit@8 holds [1,1,1,1,1,-] on
the round 8 bundle (436), the round 7 default (436) and Arm B (629), and the
known-negative reproduces on the new bytes: `--no-tie-shared-rank` gives
[2,1,1,1,1,-].

The pin is rebuilt against the default that actually ships
(`K2-bundle-default-20260911`, 832 files, digest 8c93e5e3...); two independent
builds of it differ in nothing, `log.md` included.

Two measurement failures are recorded rather than hidden. The corpus identity
check first used `xargs` without `-print0`, every filename split on its
spaces, `shasum` read nothing, and both sides agreed on a digest of the
failure; a control against the digest of empty input separated them. And the
smoke build cannot prove the flag arrives -- the operator's folder has a
coverage gap of zero already, so `diff -rq` is 0 either way; the plumbing has
its own test on a document that has the defect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 17:43:56 +02:00
a364ef4c7d feat(cli,consume): the first span, and the fusion that punished fine-graining
Round 6 left two things behind: a measured coverage hole with the repair built
but not the number to move it, and a retrieval regression that kept two
spreadsheet rules off the default. Both are measured; four defaults move.

THE HOLE IS BIGGER THAN THE RULE BUILT FOR IT. Measured over the 39-document
corpus, the pre-move default left 207 435 characters -- 11.92 % -- in no
segment: 163 804 above the first entry, 26 041 between entries, 17 590 after
the last. `--first-span-from-zero` closes the first part entirely, 79 % of the
whole, leaving 43 631 (2.51 %) over 8 of 32 documents. It adds no boundary and
the K2 concept count is identical with and without it (425 = 425); hit@8 holds
[1,1,1,1,1,-] under both tie-breaks and the 12-position reference does not move
one cell.

THE RANKER WAS PUNISHING FINE-GRAINING FOR BEING FINE-GRAINED. Round 6 held
`--sheet-section-rows --keep-table-heading` back because on a bundle built with
them row 1 fell rank 1 -> 2, and ruled out the document prior because its rank
over 39 DOCUMENTS was 1 on both bundles. RRF reads a rank over CONCEPTS:
splitting the gold document 1 -> 12 puts its own twelve concepts in that
signal's whole top tie group, so the one leading the body signal takes position
11, contributing 1/71 where the undivided concept contributed 1/61. A signal
that scored them all EQUALLY still emitted twelve ranks, ordered by concept_id,
and the fusion read alphabetical order as a measurement.

AND THE REPAIR WAS ALREADY IN THE TREE, OFF FOR A COST THAT HAD BEEN REMOVED.
`--tie-shared-rank` shipped off 2026-09-08 because hit@8 fell 5 of 6 to 4 of 6.
Swept over 2 prior exponents x 3 bundles x 6 rows: that fall exists only at
DOCUMENT_PRIOR_EXPONENT 1.0. Round 6 moved the exponent to 0.5 for an unrelated
reason and correctly reported it moved no hit@8 row -- nobody measured the
pair. A flag's "off by measurement" is a measurement of a CONFIGURATION, not a
property of the flag.

Shipped, each with an explicit opt-out and both directions measured rather than
asserted: --no-first-span-from-zero --no-sheet-section-rows
--no-keep-table-heading reproduces the previous bundle byte for byte (diff -rq,
0 differences), and the no-flag build equals the explicit-flag build.
--no-tie-shared-rank reproduces the previous excerpt order -- the one change
here that alters a payload with no bundle changing.

K2 default: 436 concepts / 832 files, digest 8dff8a8e6c15d2f7..., hit@8 5 of 6
ranks [1,1,1,1,1,-]. The pin now holds its own known-negative on those bytes:
read with --no-tie-shared-rank the same bundle gives [2,1,1,1,1,-], so the
green assertion names its cause. Consumer cost is a re-run: 425/810 -> 436/832
on the reference corpus, 15/30 -> 26/52 on a five-document folder.

pptx and md measured end to end for the first time, on two hand-built
documents: md recovers 3 of 4 declared headings, pptx segments per slide only
where the converter recognised a title placeholder. A converter attribute leaks
into titles ({#slide-N}, {#sheet-1}) on 2 of 810 K2 files and 1 of 30 on a
five-document folder -- not fixed, because a filename is reduced from its title
and the fix renames concept ids a consumer has cited.

Suite 1486 (1478 before), ruff and mypy --strict clean over 21 files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 15:40:01 +02:00
38104b7df5 feat(propose,consume,profiles,importer): recovery yields to declaration, and 9 % of the corpus that was in no segment
One rule explains every remaining `pdf` miss on the twelve-position reference:
where a document DECLARES headings, Arm D's RECOVERED headings are the whole of
the excess, and every declared one is a unit the reference wants. `--outline-gate`
admits recovery only where the document declares none of its own, plus any one
recovered heading covering OUTLINE_SHARE (0.20) of the text. It is `fold_units`
clause 2's own principle moved from voting to admission, and it filters at
ADMISSION so the text a removed mark opened is carried by the mark above it --
the post-filter form scores identically on all twelve positions and loses that
text, which is why only one of them shipped.

`--outline-gate` and `--drop-wrapped-outline` become the package default, one
decision because neither carries the reference alone: `pdf` 2 of 8 -> 5 of 8
alone, 7 of 8 together; the sheet 5 of 12 -> 10 of 12; `docx` unchanged at 3 of
3. Each keeps an explicit opt-out. The bar the move had to clear was not the
reference: hit@8 on a K2 bundle built with it holds 5 of 6 at ranks 1,1,1,1,1,-,
no row losing rank 1. `--sheet-section-rows --keep-table-heading` reaches 11 of
12 and does NOT ship, because on a bundle built with it row 1 falls rank 1 -> 2.
Cost to a consumer is a re-run: 492 concepts / 944 files -> 425 / 810.

DOCUMENT_PRIOR_EXPONENT makes the document prior sublinear (total/n**0.5). A sum
measures size and a density is diluted by every unit carrying none of the
question, so a document split 1 -> 12 lost its prior by 12. Swept over five
values on 18 rows it is at least as good as the delivered density everywhere and
strictly better on three. Stated plainly: end to end it moved NOT ONE hit@8 row
on any of four bundles, so it did not solve the knot it was adopted for -- what
did is that the `pdf` gain never needed `--sheet-section-rows`.

`--first-span-from-zero` is off and repairs a measured loss found while chasing
one position's 940 characters: 32 of the 32 documents that get a plan leave the
text above their first concept in no segment -- 159 704 characters, 9.18 % of
the corpus, 45 841 from one document. It changes nothing on the reference. Off
because it moves the first span of essentially every bundle with no hit@8 number
behind it yet.

vegnormal-okf FUNN 2: SPEC section 8's own star row parsed as prose, so every
concept behind one was unreachable to the section 9.2 walk. `IndexPolicy.also_reads`
carries it for the SEGMENTED profiles, read-only, after the emitted pattern
misses -- the asymmetry `sources` already has. DEFAULT and STRICT_V1 untouched (O2).

vegnormal-okf FUNN 1: Door C's own outcome was refused at exit 1,
`bundle_id_missing`. `import_bundle` now takes `root_frontmatter_values`,
keyword-only, rendered before any disk mutation, written only when the index is
created -- Door B's mechanism and ordering.

Report: docs/2026-09-09-k3-runde6-outline-gaten-og-prioren.md.
Suite 1478 passed (1449 before), ruff and mypy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 14:17:00 +02:00
b01492b7f5 feat(propose,cli): typography as a reserve, and the two of our own numbers it took to measure it
K3 round 5. Three questions, three answers, and two of them correct a figure
this repository published.

RETRIEVAL FIRST, because it could have reversed a default. hit@8 over the six
questions on BOTH K2 bundles -- Arm B at 629 concepts and the shipped default
at 492 -- is 5 of 6 with ranks 1,1,1,1,1,- on each, so 0 of 6 rows lost. The
order's rule reverses `--unit-fold` at >= 2 of 6; it does not fire, and the
default stands. The gold sets shrink (49->26, 20->17, 43->36, 11->18) while
every rank holds at 1, which is the fold merging concepts rather than removing
a document from the top.

TWO PUBLISHED NUMBERS CORRECTED, both ours. The S7 candidate ranks 96 of 629
and 159 of 492 were measured with the cost vocabulary passed to
`concept_scores` and NOT to `document_scores`, while `build_payload` passes it
to both; scored the way the shipped payload scores it, the same concept is 10
of 629 and 19 of 492. And round 4 attributed its non-delivery to the default
move -- measured here, it is not delivered on the Arm B bundle either, for a
different reason (knapsack eviction at 68 046 bytes of a 120 000 budget, versus
`below_k`). That column had been inherited from round 3's own build, never
re-measured.

`--pdf-headings font-reserve`, OFF, and the hypothesis behind it is falsified
by its own condition rather than by a score: position 7, the one position the
flag exists for, has THREE outline runs, so the reserve is silent there at
every minimum. It changes 0 of 12 cells on the reference and reaches 4 of 39
corpus documents, none of them rated. Built anyway because it was authorised
and because the condition is now measured rather than assumed. The predicate
lives in one place (`propose.heading_reserve_applies`) and the door receives it
as a callable, like `gate`: a plan indexes the exact string it was proposed
against, so a reserve firing on one side only would make every document it
touches a coded rejection.

The `xlsx` re-reading is confirmed on the artifact -- 11 `rule:sheet-section`
units plus 1 `rule:table-block` ingress -- but the number alone makes the cell
worse (distance 1 -> 2), because the criterion counts that ingress as a table
that should have been merged. A hit needs both halves ratified, and the
reference is the operator's.

`--sheet-section-rows` as a default: three cells better and none worse on the
twelve positions, but the K2 control moves -- row 1's gold document splits 1 ->
12 concepts and its best concept ranks 2 instead of 1. Condition not met,
default not moved.

Default build byte-identical before and after (`diff -r`, 30 md files).
Suite 1441 -> 1449; three of the eight were red first.

Report: docs/2026-09-08-k3-runde5-hitat8-og-skriftakse.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 00:25:51 +02:00
53d5c74c96 feat(extract,cli): typography as a PDF heading source and OCR behind an optional group, both off
A PDF carries no notion of a heading -- a heading in a PDF is a typographic
fact -- so the text stream `pdfplumber` hands the segment proposer has already
thrown away the only evidence there was. The `docx` path never had that problem:
the converter emits ATX headings and `_ATX` cuts on them. Two readers close the
gap, and both are OFF.

`--pdf-headings font` infers a heading from the conjunction this repository
already measured (size above the document's character-weighted body median AND
a bold font name, recall 1.000 / precision 0.846) and emits it as ATX in the
SAME markdown the office path produces, so `_ATX` applies unchanged and no
PDF-only heading grammar exists.

It stays off BY MEASUREMENT, and the measurement is the point of the round:
against the operator's unit worksheet it takes `pdf` from 2 of 8 to 0 of 8,
losing two exact matches. The mechanism of the loss is stated rather than
guessed -- on those documents the outline rule already recovers the document's
own numbered chapters, so a second heading source can only add. Whole-corpus
screen: 25 of 32 `pdf` change, 0 of 5 `docx`, 0 of 2 `xlsx`. The default bundle
is byte-identical before and after this commit (`diff -r`, exit 0).

`--ocr` reads a page as an image when its own text never arrived: empty, or
`(cid:N)` placeholder codes at or above a threshold READ OFF a measured
distribution -- 834 pages over 32 files, 818 at exactly 0.0 and 16 at 0.93 or
above, nothing in between. On the one corpus document with the failure: 95.07 %
cid to 0 %, 44 to 2561 words of four or more letters, 17 to 18 pages with text.
Its engine is an optional dependency group and never a runtime dependency; a
packaging test pins both halves, and without the group every affected file is a
coded rejection (`extractor_ocr_group_missing`) rather than a crash.

Also corrects two stale published facts found while measuring: the README still
said two segmentation rules were on by default after `f6fea13` made it three,
and CLAUDE.md's K2 digest named the round-3 default. The current default is
492 concepts / 944 files, `bdefa679...`.

Report: docs/2026-09-08-k3-runde4-pdf-skrift-og-ocr.md

Co-Authored-By: Claude <claude-opus-5>
2026-09-08 23:10:47 +02:00
f6fea13299 feat(cli): okf project/consume/check/skill, and a generated skill with no path into a checkout v0.6.0
The reading direction existed only for someone standing in a clone. `consume`,
`contract_check` and `skill` moved from `tools/` into the package and are
reachable as `okf consume`, `okf check` and `okf skill`; `okf project` is new
and does the whole thing in one command.

The red measurement: a consumption skill generated from a checkout carried 4
lines naming that checkout by absolute path, 2 of them the commands the skill
tells a reader to run. It now names `okf consume` and `okf check`, and a test
asserts this repository appears in it nowhere, with a known-positive so the
zero is a measurement rather than a search that could not find.

The `tools/` files stay as ALIASES, not re-exports: a re-export binds copies of
the names into a second module object, so a caller patching one patches a
binding the implementation never reads. Two tests that monkeypatch okf_consume
went green again only under the alias. Every published reproduction block runs
unchanged.

The template and docs/consumption-contract.md (the section 7.4 known-positive)
are force-included into the wheel from the file they are authored in, so both
travel with the commands that cannot run without them and there is still one
authored copy of each.

Step 0, before any of it: okf build's default gained Arm E (--table-grid),
with --no-table-grid as its opt-out. The default moved to D plus F earlier the
same day on Arm F's published 5 of 12 -- a figure measured with Arm E ON.
Without it the fold has no joined table to fold, and the shipped default scored
2 of 12 with docx 0 of 3. Measured on the operator's folder: 30 md / 15
concepts on the new default against 43 / 28 without Arm E.

Install measurement from a fresh uv tool install, empty folder, this repository
nowhere on PYTHONPATH: 5 documents in, 15 concepts out, 0 references to tools/
in the generated skill, okf check conformant (15 rules, 0 findings).

Deviation stated rather than hidden: the order asked that
tests/test_okf_consume.py be left untouched. Two assertions in it read a PATH,
which is the one thing this work changes. Both were moved and the second made
stronger -- it now asserts every command the README recipe names is a
subcommand the CLI registers, which a file existing on disk never proved.

Suite 1414 -> 1427. ruff clean, mypy --strict clean over 21 files.
Record: docs/2026-09-08-o5-okf-project.md

Co-Authored-By: Claude <claude-opus-5>
2026-09-08 21:47:38 +02:00
efa92ce170 docs(cli): the new default's K2 digest, measured on the bundle it produces
The default move landed in the previous commit; this is the number it costs a
consumer, measured rather than predicted. `okf build` with no flag over the
43-document reference corpus now yields 517 concepts in 969 files, content
digest `42c8646d...`, `merged + coded rejections = 43 = N`, 817.6 s. The
delivered 2026-09-03 bundle (629 concepts, 1108 files, `9cd74519...`) is
unchanged on disk and reproducing it needs `--outline-run 0 --no-unit-fold`
AND the stamp it was built with -- "the opt-out flags" alone is true and
incomplete, so the report says both.

README and CLAUDE.md now state which two rules are on, what each opt-out is,
and that the proposer's own defaults did not move. One CLAUDE.md sentence was
false as of the move ("a no-flag build stays byte-identical") and is corrected
in place rather than deleted: adding a flag still leaves the default
byte-identical; moving it does not, which is why it took an operator decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 21:14:51 +02:00
47f6619e6f feat(propose,cli): the sheet's rows, the wrapped sentence, and a default that moved
K3 round 3, order 20260908T170037Z-3622420612-from-.claude, plus the PM
injection of 17:55Z carrying the operator's D3 answer.

TWO NEW RULES, both OFF by default.

`--sheet-section-rows` cuts an open table block at the rows that label its
sections: a RUN of at least three rows whose first cell is a bare numeric
label. It is the first rule here that opens a span INSIDE a table, and the
opposite direction from Arm E -- that arm decides how far a block extends,
this decides where it is cut inside. The spreadsheet is the one file type no
arm had ever moved: 1 concept -> 12 on the priced sheet, of which 11 are the
reference's 11 cost groups. The run guard buys exactly three candidates on the
corpus (the rows stating a computation basis), and each section carries its
own `source_sheet` + `source_rows`, verified on the artifact.

`--drop-wrapped-outline` declines an outline candidate whose line continues
onto the next one. Round 2 falsified the LENGTH axis on this case; this one is
not about size. Over the 12-document sample it separates 8 of 34 candidates --
the quoted regulation paragraphs and the risk-table rows -- and none of the 26
the operator kept. Position 4: 4 concepts -> 1, the reference. The cell is
`treffer` by count and lossy by bytes, and the report says so.

Whole corpus, one cached extraction: 1 of 39 documents changes under the first
rule (0 of 32 pdf, 0 of 5 docx), 5 of 39 under the second (all pdf). Arm B and
F2 are byte-identical against a frozen c3b645b, both halves by `diff -r`.

THE `okf build` DEFAULT MOVED, and this is the operator's decision executed,
not a side effect: no flag now means `--outline-run 3 --unit-fold`. Each arm
keeps an explicit opt-out (`--outline-run 0`, `--no-unit-fold`) and the pair
reproduces the pre-move bytes exactly. The proposer's own defaults do NOT
move: they are what the goldens and every published reproduction block are
pinned to, so the two layers now disagree on purpose and `cli.py` says where.
Two shipped expectations moved with it and both are stated in place. MEASURED
AND REPORTED BACK: the configuration made default scores 2 of 12 on the unit
worksheet, `docx` 0 of 3 -- Arm F's published 5 of 12 was measured with
`--table-grid` ON, and without it the fold has no joined table to fold.

Position 1 is declined for the second round on the axis the order named: 3 of
3 recovered chapters have body under them, so "heading-on-heading" separates 0
of 3.

The K2 ranking control round 2 could not measure: two bundles from frozen
c3b645b differing only in `--keep-table-heading`, 2 of 1108 files apart. The
priced concept ranks 96 of 629 in BOTH and is delivered at rank 10 in both, so
the flag buys 35 payload bytes and zero rank positions. Round 2's prediction
is falsified: the concept's NAME was already restored by the orphan-name
inheritance; the flag adds the heading LINE.

The stale S7 literal is restated where it stood (`c759a657...`, 171 614 B,
re-measured on this HEAD), so the next order cannot inherit the superseded one.

1397 -> 1414 tests. Report: docs/2026-09-08-k3-runde3-per-filtype.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 20:34:18 +02:00
c3b645bccf fix(consume): the compound-word miss is a degenerate signal's tie-break, behind a flag
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>
2026-09-08 18:53:18 +02:00
ff79cfa19b feat(cli,propose): reach the arms from okf build, keep a sheet heading behind a flag, fix two PDF contents mechanisms
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>
2026-09-08 18:21:25 +02:00
dfaf3cc134 feat(propose): Arm F, one unit fold behind a flag, measured against the operator's worksheet [skip-docs]
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>
2026-09-08 16:28:41 +02:00
171798ed32 docs(consume): the Claude Code recipe, measured end to end on two bundles
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>
2026-09-08 15:32:32 +02:00
c95d18905a feat(consume): carry every source_* key by prefix, and generate a skill per bundle
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>
2026-09-08 15:13:22 +02:00