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>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-10 07:53:22 +02:00
commit 078b7df94f
3 changed files with 448 additions and 2 deletions

View file

@ -9,6 +9,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- **A section the SOURCE DECLARES now takes the route declared structure takes,
at the shipped defaults.** `.xml` gained a reader in the entry above and the
reader reached its ceiling -- **2 761 of 2 761** heading lines -- while the
build delivered **23 concepts and 15 of 2 761 boundaries**. Everything after
the reader ate it, and both steps are measured: the **orphan check** removed
**710 of 2 761** (710 of 710 removed headings are followed immediately by
another heading and **0 of 2 051** delivered ones are -- they are container
sections), and **Arm F** folded **2 066** more, 2 089 -> 23.
- `extract.xml_outline` reports the marks the reader wrote itself. There is
**no bridge** and therefore no tolerance constant and no `unresolved`
bucket: the reader appended the line it names. That is the difference from
`pdf_outline`, whose naive nearest-line rule was wrong on 1 840 of 2 762.
- `propose.RULE_XML_SECTION` (`rule:xml-section`) is its own name in
`RULE_NAMES` and in `_ORPHAN_EXEMPT`, so an artifact still distinguishes an
element the reader transcribed from a bridged bookmark
(`rule:pdf-outline`) and from a heading somebody guessed (`rule:heading`).
- The route is chosen by the ROW (`DECLARED_STRUCTURE_IDS = {"xml"}`), never
by the text: the same markdown arriving from a `.md` file is still a guess.
**No other file type changes one byte** -- `diff -r` on the five-document
reference folder is empty (52 md, 26 concepts, 0 of 5 rejected, 0 `.xml`
files in it), `okf project` is still byte-equal to `okf build`, the pinned
K2 bundle is unchanged, and the PDF arm still proposes 2 762 segments.
- Measured at SHIPPED DEFAULTS, not behind a flag: **2 761 concepts**;
**2 761 of 2 761** declared sections became a concept with the source's own
directory and title; **0** concepts match no declaration; `a)`-points
**0 of 4 954**; table blocks **10 of 10**; hit@1/8/50 **3/6 · 5/6 · 6/6**
(from 0/6 · 0/6 · 0/6) with the known-positive at rank 1. Cross-arm,
**2 761 shared concept ids** -- 100 % of the XML bundle and 2 761 of 2 762
of the PDF arm's, up from round 13's 2 022.
- Two directories of 2 738 still hold two concepts (`11`, `12`): the
publisher reuses a section number for two distinct sections, and it is the
same 2 the PDF arm has. Round 13's 14 such directories were false positives
of the text route reading the document's own contents listing, and they are
gone.
- **`.xml` is a core file type, NISO-STS aware, with a generic fallback.** A - **`.xml` is a core file type, NISO-STS aware, with a generic fallback.** A
publisher's own viewer delivers a zip that holds 0 html, 1 xml and 109 publisher's own viewer delivers a zip that holds 0 html, 1 xml and 109
images; `okf build` on it was **110 of 110 unreadable, 0 plans, exit 2**, and images; `okf build` on it was **110 of 110 unreadable, 0 plans, exit 2**, and
@ -38,6 +73,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `.xml` never routes through the converter, and it is measured about 12x - `.xml` never routes through the converter, and it is measured about 12x
faster and about 30x smaller in peak memory than the PDF arm on the same faster and about 30x smaller in peak memory than the PDF arm on the same
document and the same machine. document and the same machine.
- **XML that is not STS gives 0 plans and a FAILED build, and that is not an
`.xml` defect.** The known-positive that decides it: a folder holding one
`.txt` of prose with no headings gives exactly the same three lines and the
same exit 2. This is general `okf build` behaviour for any structureless
document -- extraction works, 0 unreadable, the text is there, and the
proposer has nothing to propose. The gate that refuses a run with no plans
stays: a run replaying zero plans would emit a flat bundle and report it as
a success. Separating "0 plans, 0 unreadable" from "0 plans because nothing
could be read" would change the outcome on **0 of the 4** reference
corpora, so it is not separated.
- Report: `docs/2026-09-11-k3-runde13-xml-sts.md`. - Report: `docs/2026-09-11-k3-runde13-xml-sts.md`.
### Fixed ### Fixed

View file

@ -60,7 +60,7 @@ one boundary rule:
are 0 occurrences in `uv.lock` -- so it is core beside `.html` rather than are 0 occurrences in `uv.lock` -- so it is core beside `.html` rather than
behind `[extract]`, which would make a pure-stdlib type binary-dependent. behind `[extract]`, which would make a pure-stdlib type binary-dependent.
**The output grammar is MARKDOWN**, the same the office rows reach the **The output grammar is MARKDOWN**, the same the office rows reach the
proposer through, and `propose.py` is untouched: `<sec>` with a `<title>` proposer through: `<sec>` with a `<title>`
becomes one ATX line carrying `<label>` + space + `<title>` at its own becomes one ATX line carrying `<label>` + space + `<title>` at its own
nesting depth, `<sec>` with only a `<label>` becomes a body line with the nesting depth, `<sec>` with only a `<label>` becomes a body line with the
label in front (never a heading -- 4 954 of 7 715 are lettered points and one label in front (never a heading -- 4 954 of 7 715 are lettered points and one
@ -80,7 +80,47 @@ one boundary rule:
>= 2.4.0 and not from Python, while `pyproject.toml` requires only `>=3.10`. >= 2.4.0 and not from Python, while `pyproject.toml` requires only `>=3.10`.
XML that is not STS keeps its text in document order and gets NO invented XML that is not STS keeps its text in document order and gets NO invented
structure, and `.xml` never routes through the converter -- a second parser structure, and `.xml` never routes through the converter -- a second parser
that would never see that refusal. The registries are COUPLED: a row in that would never see that refusal. **XML that is not STS also gets 0 plans
and a FAILED build (exit 2), and that is NOT an `.xml` defect**: a folder
holding one `.txt` of prose with no headings gives the same three lines and
the same exit, so it is general `okf build` behaviour for any structureless
document. The gate stays -- a run replaying zero plans would emit a flat
bundle and call it success -- because separating "0 plans, 0 unreadable" from
"0 plans because nothing could be read" changes the outcome on **0 of the 4**
reference corpora.
**THE READER REACHED ITS CEILING IN ROUND 13 AND THE BUILD DID NOT, AND
ROUND 14 CLOSED IT AT THE SHIPPED DEFAULTS.** The reader emitted 2 761 of
2 761 heading lines while the build delivered **23 concepts and 15 of 2 761
boundaries** -- two steps after the reader, each measured: the orphan check
took **710 of 2 761** (710 of 710 removed headings are followed immediately
by another heading, **0 of 2 051** delivered ones are -- they are container
sections) and Arm F took **2 066** more, 2 089 -> 23. `find_candidates`
already skipped both for `outline_marks`, which is why the PDF bookmark arm
reaches 2 762; an STS `<sec><title>` is the same class of declaration and
only arrived as `rule:heading`. The fix is ONE new rule constant reached from
ONE row: `extract.xml_outline` reports the marks the reader WROTE ITSELF --
**no bridge, no tolerance constant, no `unresolved` bucket**, the difference
from `pdf_outline` whose naive nearest-line rule was wrong on 1 840 of 2 762
-- `propose.RULE_XML_SECTION` (`rule:xml-section`) is its own name in
`RULE_NAMES` and `_ORPHAN_EXEMPT`, and `build_plan` chooses the route by the
**ROW** (`DECLARED_STRUCTURE_IDS`), never by the text: the same markdown from
a `.md` file is still a guess and still carries `rule:heading`. At shipped
defaults, no flag: **2 761 concepts**, **2 761 of 2 761** declared sections
became a concept with the source's own directory AND title, **0** concepts
matching no declaration, `a)`-points **0 of 4 954**, table blocks **10 of
10**, hit@1/8/50 **3/6 / 5/6 / 6/6** from 0/6 / 0/6 / 0/6 with the
known-positive at rank 1, and **2 761 shared concept ids** with the PDF arm
(100 % of this bundle, 2 761 of 2 762 of that one) against round 13's 2 022.
**NO other file type changes one byte and it is MEASURED on the bytes**: the
whole 43-document reference corpus rebuilt is `diff -r`-identical to the
pinned bundle (865 md), the five-document folder is `diff -r`-identical,
`okf project` stays byte-equal to `okf build`, and the PDF arm still proposes
2 762. Two directories of 2 738 still hold two concepts (`11`, `12`) -- the
publisher reuses a section number, the same 2 the PDF arm has, and 0 is not
reachable without inventing an id; round 13's 14 such directories were false
positives of the TEXT route reading the document's own contents listing and
are gone. Report:
`docs/2026-09-10-k3-runde14-deklarert-struktur-tar-ruten.md`. The registries are COUPLED: a row in
`_CORE_EXTRACTORS` and not in `segmentation._STDLIB_EXTRACTOR_IDS` refuses `_CORE_EXTRACTORS` and not in `segmentation._STDLIB_EXTRACTOR_IDS` refuses
every proposal for the type, two layers away from the extractor. every proposal for the type, two layers away from the extractor.
`pdf`/`docx`/`xlsx` only via `pdf`/`docx`/`xlsx` only via

View file

@ -0,0 +1,361 @@
# K3 round 14: the extractor reached the ceiling, the build did not
Round 13 gave `.xml` a reader that emits **2 761 of 2 761** ATX lines for
R761's titled `<sec>` elements, with the text preserved exactly. At **shipped
defaults** the build then delivered **23 concepts and 15 of 2 761 boundaries**.
The distance between the two was not extraction and not a missing rule: it was
two steps that run *after* the reader and judge a publisher's own declaration
as if it were a guess.
This round closes that distance at the defaults, not behind a flag.
| row | before (HEAD `9d5ebc3`) | after (`ee12f64`) | denominator |
|---|---|---|---|
| concepts, shipped defaults | 23 | **2 761** | — |
| declared sections that became a concept | 15 | **2 761** | 2 761 |
| concept title == source `<title>` after NORM | — | **2 761** | 2 761 |
| concepts matching no declaration | 8 | **0** | 2 761 |
| `a)`-points that became their own concept | 0 | **0** | 4 954 |
| markdown table blocks | 10 | **10** | 10 `<table-wrap>` |
| directories holding two concept files | 14 (at `--no-unit-fold`) | **2** | 2 738 |
| hit@1 · hit@8 · hit@50 (k=50) | 0/6 · 0/6 · 0/6 | **3/6 · 5/6 · 6/6** | 6 |
| known-positive rank | 1 of 17 delivered of 23 | **1** | 1 |
Every figure in this file was measured in this session. Where a figure differs
from what the order stated, the measured one is used and the difference is
named.
## 1. What was reproduced first, and what did not reproduce
`HEAD` was `9d5ebc3`, `git status --short` empty, `git ls-remote origin
refs/heads/main` = `9d5ebc3c29b417f3bae90458ee041ea1b10a19ea`. **STATE.md said
15 unpushed commits and a remote at `6ff84d7`; the remote is at HEAD, so the
push list was already empty when this session opened.** That is the one premise
of the order's own package that did not hold, and it holds in the safe
direction.
Suite in the working tree before any change: **1 567 passed, 1 skipped**.
`ruff --version` 0.16.6, `ruff check .` and `ruff format --check .` clean over
116 files, `mypy src` clean over 21 files. Install channel
`uv sync --frozen --extra extract`. `pyproject.toml` version `0.7.0`, tag
`v0.7.0` on `1260fac` — no bump, no tag in this round.
The baseline was rebuilt from a frozen `git archive` export of `9d5ebc3`
(never from the editable tree) and reproduced the order's numbers exactly:
23 concepts, **15 of 2 761** under the corrected instrument key and **0 of
2 761** under the shipped one, 8 false positives of 23, and hit@1/8/50 =
**0/6 · 0/6 · 0/6** with the known-positive at rank 1 of 17 delivered.
### The instrument, and both of its faults are real
`experiments/sk2_grenser.py` sets `cnorms = ckeys` and uses the `literal` set
on the `[ARTEFAKT]` line **alone**, so HOVEDTALL, depth, FALSKE POSITIVE,
DELMENGDE, FORSIDE and K3-9 all score the **pair** key `(concept directory,
normalised title)`. `experiments/hitk_sk2.py` pairs `fasit_key` (which splits
the numbering token off) with `excerpt_key` (which does not). Confirmed by
reading both files. Both were corrected in a scratch copy under `/tmp`, run
with `PYTHONDONTWRITEBYTECODE=1` from a scratch cwd with the inputs symlinked
in; nothing was written into `vegnormal-okf` and no instrument code was copied
into this repository. The correction in both is the same: **accept a hit when
the pair key matches OR when the concept's own normalised title equals the
fasit's `norm`.** Every row below is reported in both forms.
**The two forms cannot both reach 2 761, and that is a property of the
instrument rather than of any bundle.** The pair key requires the concept
title to equal the fasit title *without* its numbering token; the literal form
requires it to equal the fasit title *with* it. **2 739 of 2 761** fasit rows
carry such a token, so for those rows the two forms demand mutually exclusive
strings. No bundle can satisfy both. The gate below is therefore stated on the
corrected (union) form, with the shipped form reported beside it and its
shortfall accounted for row by row.
`sk2_grenser.py`'s "present in the extracted text" row reads
`build/sk2-pagetext.json`, which is the **PDF's** page text and not the
extraction under measurement. It was given a haystack of this repository's own
extracted XML text instead (`SK2_HAYSTACK`), and the row is labelled with which
haystack it used.
## 2. The mechanism, and why a new rule rather than a flag
`find_candidates` returns early when `outline_marks` is given: the text
heuristics, both gates and Arm F's fold are all skipped, and the orphan check
is not applied. That is why the PDF bookmark arm reaches 2 762. An STS
`<sec><title>` is the same class of declaration — stronger, in fact, since the
publisher states it as an element rather than as a bookmark over a typeset
page — but until now it arrived as `rule:heading`, indistinguishable from a
heading a converter recovered from a font size, and was judged by:
- **the orphan check**, which removed **710 of 2 761**. Verified as the only
mechanism rather than assumed: 710 of 710 removed headings are followed
immediately by another heading, and **0 of 2 051** delivered ones are. They
are container sections. `propose.py`'s own comment already named the outcome:
the check "judges whether a GUESS was a heading — a question a publisher's
own tree has already answered, and one that deletes every container section
if asked".
- **Arm F** (`--unit-fold`, a build default since 2026-09-08), which took
**2 066 more**, 2 089 → 23. On a process code whose sections are almost all
`14.121 Langsgående sikring T1 oppgjort etter lengde`, the shallowest
repeated heading level is 1, so every deeper level folds into its chapter.
The change is **one new rule constant reached from one row**:
- `extract.xml_outline(name, data)` returns the marks the XML reader wrote
itself. There is **no bridge**: the reader appended the line it is naming, so
the index is exact, there is no tolerance constant and there is no
`unresolved` bucket. This is the difference from `pdf_outline`, whose naive
nearest-line rule was wrong on 1 840 of 2 762 nodes.
- `propose.RULE_XML_SECTION` (`rule:xml-section`) is its own name in
`RULE_NAMES` and in `_ORPHAN_EXEMPT`, so an artifact still says whether a
boundary came from a bridged bookmark, an element the reader transcribed, or
a heading somebody guessed.
- `find_candidates` gained one keyword, `outline_rule`, defaulting to
`RULE_PDF_OUTLINE` so the PDF arm is reached by exactly the call it was
reached by before.
- `build_plan` chooses the route by the **row**
(`DECLARED_STRUCTURE_IDS = {"xml"}`), never by the text. The same markdown
arriving from a `.md` file is still a guess and still carries `rule:heading`
— pinned by a test.
The broader variant — a flag exposing the orphan check to every type — was not
taken. It was not needed and it would put twelve unit-worksheet positions at
risk for one row's benefit.
`--pdf-outline` stays a flag and stays off; nothing in this round moved it.
That remains an operator question on a reach of **1 of 8** reference PDFs.
## 3. The gate, in both instrument forms
Built with the installed console script and no arm flag at all:
```
okf build <the STS folder> --bundle <scratch> --bundle-id r761xml --okf-version 0.2
```
**2 761 segments proposed, 1 plan, 0 documents with no boundary, 109
unreadable (the images), `merged + coded rejections = 110; N = 110`.**
Against the source's own elements — every titled `<sec>`, its `<label>` reduced
to a directory and its `<title>` to a title, which is what `_segment_path`
does:
| row | after | denominator |
|---|---|---|
| declared sections that became a concept (directory **and** title) | **2 761** | 2 761 |
| declarations with no concept | **0** | 2 761 |
| concepts matching no declaration | **0** | 2 761 |
| concept title == source `<title>` after NORM | **2 761** | 2 761 |
| `a)`-points (label, no title) that became a concept | **0** | 4 954 |
| contiguous pipe-table blocks | **10** | 10 `<table-wrap>` |
Against the 2 761-row fasit, through the corrected instrument:
| row | CORRECTED | AS-SHIPPED | denominator |
|---|---|---|---|
| titles that became a boundary | 2 759 (99.9 %) | 2 737 (99.1 %) | 2 761 |
| depth 1 | 28 | 12 | 28 |
| depth 2 | 118 | 116 | 118 |
| depth 3 | 500 | 499 | 500 |
| depth 4 | 1 141 | 1 140 | 1 141 |
| depth 5 | 868 | 866 | 868 |
| depth 6 | 95 | 95 | 97 |
| depth 7 | 9 | 9 | 9 |
| false positives | 2 (0.1 %) | 24 (0.9 %) | 2 761 |
| pages 68266 | 566 (100.0 %) | 561 | 566 |
**The two shortfalls are both instrument artefacts and both are accounted
for, not waved past.**
- The **2** rows the corrected form misses are `36.7331 48V strømforsyning
PoE` and `36.7332 24V strømforsyning` — the only two of 2 761 whose
`<title>` itself begins with a digit. The fasit glues `<label>` and `<title>`
with no separator, so its own key reads `36.733148` as the number and `V
strømforsyning PoE` as the title. Both sections **are** in the bundle, with
the right number and the right title, which is why the same two appear as the
corrected form's only two "false positives". Measured against the source
rather than against the glued string, they are hits: **2 761 of 2 761**.
- The **24** the shipped form misses are those two plus the **22** rows with no
numbering token at all (`Forord`, `Hovedprosess 81 Løsmasser`, …). Those
sections get no directory, so the pair key compares the per-document
directory name against `""` and can never match. That is the structural
proof in §1, in numbers: 22 of 22 unnumbered rows score 0 as-shipped and 22
of 22 score under the corrected form.
## 4. Consumption, cross-arm, and cost
hit@k over the eight published questions at `k = 50`, corrected instrument
(the as-shipped form agrees on every row here, because a concept title no
longer carries its numbering token):
| | before | after |
|---|---|---|
| hit@1 | 0/6 | **3/6** |
| hit@8 | 0/6 | **5/6** |
| hit@50 | 0/6 | **6/6** |
| known-positive rank | 1, of 17 delivered of 23 concepts | **1**, of 43 delivered of 2 761 |
Row by row after: S1 rank 3, S2 rank 1, S3 rank 1, S4 rank 1, S5 rank 11,
S6 rank 3, KP rank 1, KN no rank (it has no fasit). **S5 is rank 11 on the PDF
arm as well** — unchanged, and a ranking question rather than a segmentation
one.
**Cross-arm, and the number moved a long way.** Round 13 measured 2 022 common
concept ids — 96.8 % of the XML bundle and 73.2 % of the PDF arm's. Re-measured
after this change: **2 761 common ids, 100 % of the XML bundle's 2 761 and
2 761 of 2 762 (99.96 %) of the PDF arm's.** The single PDF-only concept is
`r761-prosesskoden.md`, the front-matter node the bookmark tree carries and the
STS body does not. The 740 that were PDF-only in round 13 were the
orphan-check bucket; they are now on both sides.
**Cost, and cost figures are machine-dependent — direction and order of
magnitude only.** `/usr/bin/time -l` reports BSD **bytes**, converted here.
XML build 7.6 s → **17.4 s**, peak RSS **145 MiB**. PDF arm with
`--pdf-outline`, built once: **209.2 s**, peak RSS **3 251 MiB**. The XML
channel remains roughly an order of magnitude cheaper in time and more than an
order of magnitude cheaper in memory, on the same machine in the same session.
## 5. Two-file directories: round 13's 14 were a defect, and they are gone
Round 13 reported 14 directories holding two concept files on the XML arm and
classified them as the publisher reusing a title. **Measured this round, that
classification is wrong.** Rebuilt from a frozen `9d5ebc3` export with
`--no-unit-fold` (2 089 concepts), the 14 are pairs like `35.54 Nøkkelsafe for
brannvesen` / `Nøkkelsafe for brannvesen`: the first is a line from the
document's **own contents listing**, a plain `<p>` that happens to read as
`<number> <title>` and was proposed as a boundary by the text grammar; the
second is the real `<sec><label>35.54</label><title>…</title>`. They were false
positives of the text route, not the publisher's doing.
On the shipped default after this change there are **2**, of 2 738 numbered
directories:
- `11``ARBEIDSSTIKNING, TEKNISK KONTROLL` and `Oversikt over prosesser`
- `12``Prosesser med tekster` and `RIGG, BYGNINGER OG GENERELLE
DRIFTS-OMKOSTNINGER`
These are **identical to the PDF arm's 2 of 2 738**, same two directories and
same two title pairs: the publisher reuses a section number for two distinct
sections. **0 is not reachable here without inventing an id**, so 2 is the
floor and it is named rather than counted.
The per-document root directory holds 22 concept files. That is not a
collision: those 22 sections carry no numeric `<label>`, so no directory is
formed, and each filename is distinct.
## 6. Generic XML: 0 plans and a failed build, and it is not an XML defect
The order to round 13 required "1 plan, exit 0, never 0" for XML that is not
STS. Measured:
```
okf build <folder with one generic-feed.xml> --bundle <scratch> ...
okf-propose-segments: nothing to propose for generic-feed.xml — the mechanical rules found no boundary.
okf build: proposed 0 plan(s); 1 document(s) with no boundary; 0 unreadable
okf build: FAILED - no segmentation plans in ... -- exit 2
```
**The known-positive that decides what this is:** a folder holding one `.txt`
with prose and no headings gives **exactly the same three lines and exit 2**.
This is general `okf build` behaviour for any structureless document, not an
`.xml` defect. Extraction works — 0 unreadable, the text is there — and the
proposer has nothing to propose. Round 13's own test asserts
`propose.find_candidates(text) == []`, which is the opposite of what its order
demanded, and the test is right; the divergence was simply never stated, and it
is stated here and in the CHANGELOG.
**Should such a folder fail the build at all?** The gate exists because a run
that replays zero plans would emit a flat bundle and call it a success. That is
a good reason and it is not being removed. The question is whether "0 plans,
N documents with no boundary, **0 unreadable**" is the same state as "0 plans
because nothing could be read". The deciding number:
| reference corpus | documents | plans | no boundary | unreadable | outcome changes if split? |
|---|---|---|---|---|---|
| K2 `trinn1` | 43 | > 0 | some | 4 | no |
| the five-document folder | 5 | 4 | 1 | 0 | no |
| the STS delivery | 110 | 1 | 0 | 109 | no |
| the 828-file HTML export | 828 | 828 (recorded, not re-measured here) | — | — | no |
**0 of 4.** The distinction only fires on a corpus where *every* document
yields no boundary, and none of the four is such a corpus. Separating the two
states would therefore change no outcome anyone has measured, and it is left
unchanged — as a measured result with its denominator, not as an omission. The
message a caller sees already distinguishes them by the counts it prints on the
same line.
## 7. Nothing else moved, and it was measured
- **The five-document folder is byte-identical.** `diff -r` between a tree
built from a frozen `9d5ebc3` export and one built from the same export with
only `src/` and `tests/` replaced by this round's code: **0 differences**.
52 md files, 26 concepts, 0 of 5 rejected. That folder holds **0 `.xml`
files**, so it is a real known-negative.
- **`okf project` is still byte-equal to `okf build`** on that folder;
`diff -r` empty.
- **The pinned K2 bundle is unchanged**: `tests/test_default_bundle_pin.py`
7 passed, 453 concepts, per-row ranks `(1, 1, 1, 1, 1, 5)`, and the
`--no-tie-shared-rank` known-negative still reproduces the fall it names.
- **The 43-document reference corpus is byte-identical.** Rebuilt in full with
this round's code and compared against the pinned
`K2-bundle-default-20260912`, which was built before the change:
**`diff -r` = 0 differences**, 865 md files on both sides, same rejection
codes (`extractor_empty_pdf` 1 of 43, `extractor_unknown` 3 of 43). The
corpus holds **0 `.xml` files of 86** (66 pdf, 10 docx, 4 xlsx, 2 zip, 2 smc,
2 doc), so the unit worksheet's 12 positions could not be reached by this
change — and now that is measured on the bytes rather than argued from the
code path. Build wall time 874 s, 20.3 s per file.
- **The PDF arm is unchanged**: built once with `--pdf-outline`, **2 762
segments**, exactly as round 12 and round 13 measured it.
- Suite after: **1 575 passed, 1 skipped** (1 567 + 8 new). `ruff check src
tests tools` and `ruff format --check .` clean over 117 files, `mypy src`
clean over 21 files.
## 8. The instrument correction is reported, not assumed
Round 13 corrected `hitk_sk2.py` in its scratch copy with the same fix as
`sk2_grenser.py`, but its report only said the fasit set was validated
"independent of key form". Verified this round: with the **shipped** matcher
the XML bundle scores `rang: null` on every question — including the
known-positive whose `topp_1` **is** the fasit — and 0/6 · 0/6 · 0/6; with the
correction it scores 3/6 · 5/6 · 6/6. **A reader who runs the shipped runner
gets zeros and concludes round 13 was wrong.** The two instruments corrected in
scratch are `experiments/sk2_grenser.py` and `experiments/hitk_sk2.py`, and the
correction in both is: accept a hit when the pair key `(directory, normalised
title)` matches **or** when the concept's own normalised title equals the
fasit's `norm`. This is sent to `vegnormal-okf` as a proposal for their
instrument, not applied there.
## Honesty limits
- **The denominator for `.xml` is ONE file, from ONE publisher, in ONE
schema.** Closing the build's ceiling does not change that. `.xml` as a file
type is far wider than NISO-STS; a document in any other schema keeps its
text in document order and gets no structure at all. The `_EVIDENCE` class
for the row stays `measured` — the file is a publisher's own delivery,
written for their purposes long before any lookup of ours, and the fasit is
theirs — and the row now carries that limit in its own comment.
- **The ceiling here is structural, not recovered.** 2 761 of 2 761 on this arm
is not comparable to a similar percentage on the PDF arm, which had to bridge
from (page, `/XYZ` top) to a line index and reports what did not bridge. This
arm has nothing to bridge and nothing to lose in bridging; the number is
cheaper than it looks.
- **The denominator 2 761 is a CHOICE.** The document carries 7 715 `<sec>`, of
which 4 954 are lettered points with a `<label>` and no `<title>`. Counting
those as sections would give a different fraction of a different question.
- **The generic fallback is measured on fixtures, not on a corpus.** The `.txt`
known-positive is one hand-written file; the four-corpus table in §6 counts
outcomes, not documents that would land differently.
- **The unit worksheet is n = 12 positions and contains no `xml` row.** Its
`pdf` 7 of 8 and `docx` 3 of 3 stand here by byte identity on the default
path, never by a re-score.
- **Cost figures are machine-dependent.** Nothing above should be compared
against a number from another machine except by direction and order of
magnitude.
- **"Delivered" is not "answered".** hit@50 = 6/6 says the gold document
reached the payload at k = 50; it says nothing about whether a reader would
answer the question from it. hit@8 is 5/6 and hit@1 is 3/6.
- **hit@8 is not neutral with respect to a rule that changes how many distinct
documents a payload holds.** This round changed segmentation inside one
document, so that particular caveat does not bite here, but the metric is the
same one and the caveat travels with it.
- The report for round 13 is dated `2026-09-11` in its filename while its
commit is dated 2026-09-10. This file uses the real date.