Commit graph

311 commits

Author SHA1 Message Date
9f8a1bca42 fix(consume): a directory every concept id shares no longer ranks them
The first fusion signal read a concept's title together with every segment
of its id. On a one-document bundle every id starts with the same directory,
and since K3-19 an STS document names that directory after its own number, so
a question naming the document matched every concept except the one whose
title already named it. Measured on a 2 761-concept bundle, the known-positive
fell from rank 1 to not delivered at the default k (13 at k = 50).

`shared_id_prefix` returns the leading directory segments EVERY id shares,
never the leaf, and the signal reads the id below them. Where the ids share no
prefix the signal reads the same string as before.

Measured on a frozen export before this commit, four forms: the chosen one
gives KP rank 1 at both k with S1-S6 6/6, and K2 (12 payloads), N100/N200/N500
(15) and the five-document folder (5) byte-identical. Dropping each concept's
own document directory instead took a K2 hit@8 row from rank 5 to not
delivered, and is not shipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 04:48:51 +02:00
5ce8efe437 test(consume): a directory every concept shares must not order them
Red. On a one-document bundle every concept id starts with the same
directory, and the first fusion signal reads the id's segments beside the
title. When the question names that directory every concept answers those
tokens, except the concept whose title already carried them -- the overlap
counts a question token once -- so the concept distinguished by naming the
document loses that distinction, and a concept answering nothing but the
directory stops being a guess.

Three tests fail here and one known-negative passes before and after the
repair: with two documents no prefix is shared and a document's directory
still separates its concepts from the other document's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 04:43:37 +02:00
7eeafb35c8 docs(k3): round 19, a document's own identity, per-run frontmatter, and a description from the source
The report for K3-19, measured from a frozen export of 77e7cab: R761 from the
base 56b00e7 against 77e7cab -- directory `14ce59dc-...` -> `r761-prosesskoden`,
2 761 concepts and 710 shells unchanged, 110 = 110, 1 807 descriptions, the
flagged edition and address on 2 761 of 2 761, hit@1/8/50 over S1-S6 6/6 at
both k, `okf check` 4 of 4 with 0 findings, and --ingested-at alone
confirmed on 2 761 of 2 761.

Reported first rather than repaired: the known-positive falls from rank 1 to
not delivered at the default k (13 at k = 50), decomposed to the directory
name alone; and the order's own `sources` value is refused by PyYAML on every
concept while `okf check` accepts it.

CHANGELOG under [Unreleased], the README and CLAUDE.md paragraphs carry the
final numbers, and the README test count moves to 1659 collected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 03:59:34 +02:00
77e7caba2e fix(description): write a spec point only where a YAML reader reads it verbatim
K3-19 c, repaired before delivery on its own measurement. The first R761
build from de7849e wrote 2 024 descriptions, and PyYAML's safe_load refused
the frontmatter of 217 of those concepts -- every one carrying ": " -- where
the same document had parsed with 1 refusal of 2 761 before the key existed.

`inbox._yaml_plain` is checked where the description is written, so a direct
`render_inbox_concept` caller is held to it as well as `okf build`: no leading
YAML indicator, no ": ", no " #", no trailing ":", no tab or line break.
Decided by rule rather than by a parser, because the one runtime dependency is
the guard; over the 2 024 measured values the rule and PyYAML agree on every
one (217 refused, 0 refused that PyYAML reads, 0 kept that it does not).
Omitted rather than quoted or cleaned: a quoted value comes back from the
line-oriented readers here WITH its quotes, and a cleaned one is a sentence
the source does not carry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 03:48:26 +02:00
31ffc2c3d6 test(description): a spec point a YAML reader cannot read verbatim is not written
Red first. Measured on the first R761 build from de7849e: 217 of the 2 024
descriptions carry ": ", and PyYAML's safe_load refused exactly those 217
concepts' frontmatter -- the same document's frontmatter parsed without them
(1 of 2 761 refused before the key existed, on a title). Quoting would write
a value the line-oriented readers here return with its quotes; cleaning would
write a sentence the source does not carry. A colon with no space after it
is an ordinary character and stays.

1 of 12 red on de7849e: the ": " description is written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 03:46:11 +02:00
de7849e35b feat(description): an STS section's description is its own first spec point
K3-19 c. The NISO-STS reader records, per titled <sec>, the FIRST <p> of its
FIRST direct-child <sec sec-type="spec"> as `OutlineMark.description`. The
plan entry carries it (`description`, only where the source has one, so every
other row's plan keeps its bytes), `parse_segmentation_plan` refuses an empty,
multi-line or non-string value, and the door writes it as the concept's
`description` after the gate has seen it: it is document text persisted
outside the body the gate screens, so it is kept only on the non-blocking
floor and as the sanitized text.

SPEC SS 4.1 makes `description` RECOMMENDED and sets no length, in SS 4.1,
SS 8 or SS 11. The limit is ours and structural -- one paragraph, whole --
because a cut inside it writes a sentence the source never wrote. Measured on
R761: 2 026 of 2 761 titled sections carry a direct-child spec point; the
first <p> runs 17 / 109 / 273 / 521 / 942 characters (min / median / p90 /
p99 / max). A section with none gets no key; nothing is derived from the
title. A stated `--frontmatter description=...` replaces it.

The extracted text does not move: the description is read beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 03:22:44 +02:00
0dbc331b76 test(description): an STS section's description is its own first spec point
Red first (K3-19 c). SPEC SS 4.1 makes `description` RECOMMENDED -- "A single
sentence summarizing the concept" -- and sets no length limit anywhere, so
the limit is ours: the FIRST <p> of the FIRST direct-child
<sec sec-type="spec"> of a titled <sec>, whole. Measured on R761, 2 026 of
2 761 titled sections carry such a point.

8 of 11 red on 912b850: OutlineMark has no description, the plan carries
none, the loader validates none, and the door writes none. The 3 that pass
pin what must not move: the extracted text, a type that declares no spec
point getting no key, and a stated `--frontmatter description=...` replacing
the derived one (landed with b).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 03:17:19 +02:00
912b85026b feat(frontmatter): --frontmatter KEY=VALUE stamps a key on every concept of a run
K3-19 b. `okf build --frontmatter KEY=VALUE`, repeatable, split on the FIRST
'=' (`cli.frontmatter_from_flags`) because a publisher's address carries '='
itself. The value is written verbatim on ONE line: the block form of a
`sources` list is invisible to this package's line-oriented readers, so the
flow form is the only one that survives them. Also reachable as
`build(frontmatter=...)`, `measure(concept_frontmatter_values=...)`,
`process_inbox(concept_frontmatter_values=...)` and
`render_inbox_concept(concept_frontmatter_values=...)`, keyword-only with
defaults, so every existing call site is source-compatible.

Precedence: a stated value beats what the document declares, which beats the
file name. A run may ADD any key and REPLACE only `sources` and
`description` -- the two with a derived layer below the flag. Every other key
the door writes is refused by `inbox.validate_concept_frontmatter` before a
proposal is written (`run_frontmatter_invalid`): measured from the bytes, owned
by another argument, the ownership stamp a later run reads back (including
Door A's `ingest_manifest`, which would make that door claim a Door B file),
or a derived facet whose `derived` marker would go on naming a replaced value.
A value that would not read back as stated -- empty, multi-line, or padded,
since `parse_frontmatter` strips -- is refused too.

SPEC SS 4.1 "Extensions" lets a producer add any key and SS 11 forbids a
consumer to reject one. Without the flag nothing moves: a test holds the
flagged tree to the plain one minus exactly the stated line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 03:14:46 +02:00
72617acc7a test(frontmatter): a run can state keys for every concept it writes
Red first (K3-19 b). A consumer's own build of the same kind of source
carries `description`, an edition key and a `sources` entry addressing the
publisher's URL -- eight keys of its own, `okf check` 0 findings -- and
`okf build` could carry none of them without a line of Python. SPEC SS 4.1
"Extensions" lets a producer add any key; SS 11 forbids a consumer to reject
one.

30 of 30 red on ee8d5b5, measured before any implementation: the flag does
not exist, nor `cli.frontmatter_from_flags`, nor `build(frontmatter=...)`.
They pin the form (split on the FIRST '=', one verbatim line, readable back
through parse_frontmatter and consume.read_sources), the precedence (a
stated `sources` replaces the derived one and never adds a second line), that
the flag adds exactly its line and moves nothing else, and the refusals
before anything is written: every key the door writes itself, and every pair
that would not read back as stated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 03:10:28 +02:00
ee8d5b5776 feat(identity): an STS document's doc-number names its directory and its title the address
K3-19 a. `extract.declared_identity` reads what a NISO-STS document states
about itself -- exactly one <std-ident> (<doc-number>, <year>) and exactly one
<title-wrap> (<full>, else <main>) -- and returns None for every other row,
for XML that is not STS, for an unparseable file, and for a document that
states neither. A value stated more than once is not read: an adopted
standard carries one <std-ident> per issuing body, and picking one is a guess.

`okf build` names a document's directory from its <doc-number> through the id
grammar, replacing only the file's stem. A declared name another document in
the run also claims falls back to the file name for both, said on stderr: the
existing collision gate would refuse both with "rename one", and a name read
from inside a document is not one a rename can change.

`sources[0].title` becomes <doc-number> + <year>, then the <title-wrap>
title, then the file name -- the first that survives the gate and can be
written into the flow mapping verbatim. Measured on R761, <full> carries a
comma, which ends a flow mapping, so it is never the title there; it is never
cleaned up either. `resource` stays the inbox-relative file.

Every other row, and every profile without an address, is untouched: the
identity is asked for only where `sources` is written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 03:08:30 +02:00
be169eeca0 test(identity): a document that declares a doc-number names its own directory
Red first (K3-19 a). A NISO-STS delivery built with `okf build` put every
concept under a directory named for the delivery path's file name -- a UUID
that occurs 0 times in the document -- and every `sources` entry named that
file twice, while the document carries exactly one <std-ident> with a
<doc-number> and one <title-wrap>, neither of which this package read.

11 of the 14 tests are red: `extract.declared_identity` does not exist, and
the build still names the directory and the address title from the file.
The 3 that pass today pin the fallback layer that must survive the change: no
declaration keeps the file name, a declared title carrying a flow terminator
falls to the file name, and a name another document's file already holds is
not taken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 02:59:36 +02:00
56b00e7f2f docs(release): R4, v0.8.2 installed and measured from the tag
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 02:40:31 +02:00
b6c54e9495 chore(release): 0.8.2 v0.8.2
The version moves to 0.8.2 in pyproject.toml, __init__.__version__ and
uv.lock (one line; `uv lock` touched no other package). CHANGELOG's
[Unreleased] becomes [0.8.2] -- 2026-09-11 with the entries K3-15, K3-17
and K3-18 wrote, unedited. The five install lines (README x4, llms.txt)
point at @v0.8.2; the guard pairing prose names v0.8.2, whose own
[tool.uv.sources] still pins guard v1.3.0; the tag history gains a v0.8.2
row and v0.8.1 stops being "the current tag". Test count re-measured on
2026-09-11: 1603 collected, 1602 passed, 1 skipped.

What the release delivers to a consumer on a tag: `okf check`'s sixteenth
rule (bundle_mismatch), which v0.8.1 does not carry, the --title-covered
stop beneath a title that answers more, and the regenerated
skills/okf-consume/. No behaviour change in src/ beyond the version string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 02:34:12 +02:00
6858ff2d35 fix(skill): regenerate skills/okf-consume from the golden bundle it ships a payload for
The hand-filled skill predated `okf skill`, declared no bundle identity,
and 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, ...

It is now the generator's output for
examples/ingest-golden-segmented-okf-v0-2/expected-bundle, the bundle the
payload always came from:

  conformant: 16 rules over 3 excerpts and 0 withheld entries, 0 findings

The payload's bytes do not move. --force and --example-question are both
required: without the question the payload is a different one (spent
2125 -> 1420, delivered 3 -> 2, withheld 0 -> 1) and the byte test fails.
`okf skill` writes the bundle root and the skill path absolute when --out
is not under .claude/skills/, so the checkout prefix is stripped in one
mechanical step that a test holds; the generator is unchanged.

The frontmatter name becomes b-golden-segmented-okf-v0-2-consume. Claude
Code takes a project skill's command from its directory, which stays
okf-consume, and nothing in this repository named the skill by name.
references/README.md is rewritten by hand with the full regeneration
command; living prose in README, CLAUDE.md, the test comment and the
skill.py docstring is corrected, dated journals are left as written.

Report: docs/2026-09-11-k3-runde18-konsumskillen-regenerert.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 02:21:35 +02:00
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