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>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-10 04:13:56 +02:00
commit 9d5ebc3c29
2 changed files with 327 additions and 0 deletions

View file

@ -9,6 +9,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **`.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
images; `okf build` on it was **110 of 110 unreadable, 0 plans, exit 2**, and
the conservation identity `merged + coded rejections == N` was never written
because the run aborted earlier. The one xml file is the whole product: 7 715
`<sec>`, **2 761 with a `<title>`**, 4 954 lettered points, 10
`<table-wrap>`, and a `<sec>`-nesting depth distribution row-for-row
identical to the publisher's own structure fasit.
- The output grammar is MARKDOWN, the same the office and HTML rows reach the
proposer through: `propose.py` is untouched. `<label>` + `<title>` become
one ATX line at the section's own depth; a `<sec>` with only a `<label>` is
a body line and never a heading (**0 of 4 954** became concepts);
`<table-wrap>` becomes one markdown table (**10 of 10**, against 0 of 10 on
the PDF path).
- The reader emits **2 761 of 2 761** heading lines and preserves text
exactly -- 1 283 395 of 1 283 395 non-whitespace characters, ratio
**1.000000**. The BUILD reaches 2 065 of 2 761 with `--no-unit-fold` and 15
of 2 761 on the shipped defaults; the whole distance is two proposer rules,
decomposed with denominators in the report.
- hit@k over six questions, k=50: **3/6 · 5/6 · 6/6**, matching the PDF arm
row for row, with the known-positive moving from **rank 13 to rank 1**.
2 022 concept ids are shared between the two channels -- 96.8 % of the XML
bundle.
- **No new dependency:** `xml.etree.ElementTree` is stdlib and `uv.lock` is
untouched. A `<!DOCTYPE` is REFUSED unparsed with its own code, which is a
guarantee about this package rather than about the installed libexpat.
- `.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
document and the same machine.
- Report: `docs/2026-09-11-k3-runde13-xml-sts.md`.
### Fixed
- **A PDF bookmark sharing a line with another left no trace.** `pdf_outline`
collected marks in a dict keyed on the destination line index, so a second
bookmark on a line was discarded by `setdefault` in silence: measured on a
701-page document, **2 763 nodes in, 2 762 marks out, `unresolved` = 0**.
`PdfOutline` now carries `collided`, and the identity `nodes in == marks +
unresolved + collided` holds. Keeping both nodes was measured and felled --
the two candidates then open at one offset and the first closes with an empty
span the orphan check deletes.
- **`--pdf-outline` (OFF): cut a PDF at the boundaries its own `/Outlines`
bookmark tree declares.** Measured outside this repository on one 701-page
process code whose publisher also ships a NISO-STS structure for it: the

View file

@ -0,0 +1,285 @@
# K3 round 13: the file that IS the product had no reader
**Date:** 2026-09-11 · **Commits:** `3f8f03c` (red), `525d68e`, `324cd0b` (red),
`5bb3b04` (red), `567a63d`, `fc23844` (red), `f280b14` · **Base:** `e1f4faa`
## 1. What was measured, and against what
A zip a publisher's own viewer delivers as "Html" holds 0 html, 1 xml and 109
images. `okf build` on it gave **110 of 110 unreadable, 0 plans, exit 2**, with
`no extractor is registered for file extension '.xml'` -- reproduced here
before any code was touched. The conservation identity `merged + coded
rejections == N` was never written at all: the run aborts earlier on `FAILED -
no segmentation plans`, so the hole was not even visible as a count. After the
reader, that same run writes `merged + coded rejections = 110; N = 110`.
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 here with
`xml.etree.ElementTree`, reproducing SK1 exactly:
| row | measured |
|---|---|
| `<sec>` total | 7 715 |
| `<sec>` with `<title>` | **2 761** |
| `<sec>` with `<label>` and no `<title>` | 4 954 |
| `<table-wrap>` | 10 |
| `<p>` | 12 528 |
| root element | `<standard>` |
| `<!DOCTYPE` / `<!ENTITY` | 0 / 0 |
| `<sec>`-nesting depth over the 2 761 titled | 28 · 118 · 500 · 1 141 · 868 · 97 · 9 |
That last row is **row for row identical** to the fasit built from the same
file, so the ceiling here is structural rather than computed: **2 761 of
2 761**, against the PDF arm's 2 759 of 2 761.
### Two premises in the material do not hold, and one instrument is uncorrected
- **`<title>` does not carry the number.** SK2's report and `hitk_sk2.py`'s
docstring say "STS glues the token into `<title>`". Measured: **2 of 2 761**
`<title>` strings begin with a digit. `<label>` carries `2.1`, `<title>`
carries `Hovedprosesser`; the fasit's `"2.1Hovedprosesser"` is SK2's own
fasit-BUILDER joining them and setting `label: null`. The fasit is right, the
account of where the number comes from is not.
- **One titled section has no label:** `Forord` (`sec-type="foreword"`, depth 1).
- **The shipped instrument carries the round-12 correction in NO block.** The
order stated that its headline, depth and false-positive rows use the
corrected key. Measured against `experiments/sk2_grenser.py` as it stands:
line 86 is `cnorms = ckeys` and the `literal` set is used only on the
`[ARTEFAKT]` line. The correction is absent everywhere, not just in the
K3-9/DELMENGDE/FORSIDE blocks. A scratch COPY was corrected in every block
(headline, depth, false positives, DELMENGDE, FORSIDE, K3-9), and the
haystack was made a parameter; both forms are reported side by side below.
The correction is proposed to `vegnormal-okf`, never applied there.
## 2. Step 0: a bookmark that shared a line left no trace
Round 12's arm lost one `/Outlines` node without counting it: **2 763 nodes in,
2 762 marks out, `unresolved` = 0**. Reproduced exactly. The lost node is
`SVV - Forside`, sharing line 0 with the tree's own root node `R761
Prosesskoden`. The cause is structural: marks are collected in a dict keyed on
the destination line index, so `setdefault` discards the second in silence.
**The rule was chosen by measurement.** Keeping both as sibling marks was run
through `find_candidates` on R761's own text: the candidate list goes 2 762 ->
2 763 and the FIRST candidate closes with an **empty span (0, 0)**, which the
orphan check deletes without a word. Keeping both loses the same node one step
later and adds a front-matter title that is in no fasit. So: first in tree
order wins, the loser is COUNTED, and the identity holds -- **2 763 = 2 762 + 0
+ 1** on R761 with the shipped function.
Known-negatives: `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; the pinned K2 bundle's concept count and
per-row ranks unchanged.
## 3. The reader
`.xml` is a **core** type beside `.html`, not an `[extract]` one: the parser is
stdlib, and putting it behind the extra would make a pure-stdlib file type
binary-dependent. `defusedxml` and `lxml` occur **0 times** in `uv.lock` before
and after.
The output grammar is MARKDOWN -- the same markdown the office rows and round
11's HTML row reach the proposer through -- and **`propose.py` was not
touched**. `<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; `<table-wrap>` becomes its label
plus one markdown table through this package's own `render_table`.
Two choices are measurements rather than 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. The allowlist is load-bearing: that document carries 1 701
`<italic>` and 1 396 `<bold>` inside its prose, and breaking on them would
shred every paragraph.
- **The ATX ceiling is 6 and STS nesting reaches 7**, so the depth is CLIPPED,
not dropped: 9 of 2 761 sit at depth 7 and `#######` matches nothing. A
clipped heading still sets its boundary and states its nesting one level too
shallow; a dropped one loses the section. The fasit's depth row is the
source's own, so the clipping does not show there -- it shows in frontmatter
nesting. `tests/test_frontmatter_nesting.py` is unchanged and green.
**A DTD is refused unparsed**, with its own code. Measured on this interpreter
(3.14.0, `pyexpat.version_info` 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. Refusing every DTD is a
guarantee about the code instead of about the machine, and it costs nothing
here: 0 of 1 file carries one.
`.xml` never routes through the converter, which is a second parser that would
never see that refusal. The existing fence test says so in one more line and
`test_every_office_row_names_its_reader` is untouched.
## 4. The gate, in both instrument forms
The extractor reaches the ceiling. **The build does not**, and the whole
distance is two proposer rules.
| row | measured | denominator |
|---|---|---|
| ATX heading lines emitted | **2 761** | 2 761 |
| text preserved, non-whitespace | **1 283 395 = 1 283 395, ratio 1.000000** | exact, not a percentage |
| provenance: unit table vs `extract_text` | starts == line offsets, numbers 1..N | 15 271 lines |
| concepts, shipped defaults | **23** | -- |
| boundaries, shipped defaults | **15 of 2 761 (0.5 %)** corrected · 0 of 2 761 pair-only | 2 761 |
| concepts, `--no-unit-fold` | **2 089** | -- |
| boundaries, `--no-unit-fold` | **2 065 of 2 761 (74.8 %)** corrected · 28 of 2 761 pair-only | 2 761 |
| by depth, `--no-unit-fold`, corrected | 15/28 · 98/118 · 430/500 · 935/1141 · 554/868 · 30/97 · 3/9 | 2 761 |
| by depth, pair-only | 0/28 · 2/118 · 5/500 · 10/1141 · 8/868 · 3/97 · 0/9 | 2 761 |
| false positives | **10 of 2 089 (0.5 %)** corrected · 2 061 of 2 089 pair-only | 2 089 |
| lettered points that became concepts | **0 of 4 954** | 4 954 |
| tables as table blocks | **10 of 10** (separator lines emitted) | 10 |
| unnumbered titles | 10 of 22 corrected · 0 of 22 pair-only | 22 |
| directories with two concept files | 14 numbered + the document folder | 2 089 |
**The pair-only column is an instrument artefact, not a segmentation result**,
and the two are separated rather than mixed. The pair key looks up `(concept
directory, normalised residual title)`; this bundle's concept TITLES carry the
numbering token, because `propose._ATX` keeps the whole title and fills
`number` in addition. The corrected form -- accept a match when the concept's
own normalised title equals the fasit's `norm` -- is the one that measures
segmentation here. A row the instrument's key cannot reach is not a boundary
okf failed to set.
**The 10 false positives are the ten table blocks**, named `Tabell linje N`.
The instrument's table regex reports 12 rather than 10 because two data rows
are followed by a row whose cells contain only dashes, which its pattern reads
as a separator; counting the separator lines this reader actually emits gives
**10 of 10**. SK2 measured **0 of 10** on the PDF path, and the PDF bundle
built here reproduces that: 0 table blocks over 0 files.
### The measurement that fells the first attempt, and the second attempt
**Attempt 1 -- the pure `_HTMLTextExtractor` pattern, shipped defaults: 23
concepts, 15 of 2 761.** Two mechanisms, both in `propose`, both decomposed
with a denominator and a control:
1. **The orphan check removes 710 of 2 761 (25.7 %).** Verified as the SOLE
mechanism rather than assumed: **710 of 710** removed headings are
immediately followed by another heading line, and **0 of 2 051** delivered
ones are. This is not the reader losing anything -- 2 735 of the 2 761
titled sections carry no prose of their own in the SOURCE, and the 710 are
exactly those whose children are themselves titled sections. `propose.py`'s
own comment names this outcome: the bookmark arm is exempt because 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". An STS `<title>` is that same declaration; it just arrives with
`rule:heading`, indistinguishable from a heading recovered from a converted
`docx`.
2. **Arm F (`--unit-fold`, a build default since 2026-09-08) removes 2 066
more, 2 089 -> 23.** Swept one flag at a time: every other default leaves the
count at 23 or 24, and `unit_fold=False` alone restores 2 089. On a process
code whose headings are almost all `14.121 Langsgående sikring T1 oppgjort
etter lengde`, Arm F reads the whole document as a unit sheet. That is the
arm doing what it was built for, on a document where it is wrong.
**Attempt 2 -- `--no-unit-fold`: 2 089 concepts, 2 065 of 2 761 (74.8 %).** The
gate of 2 761 is NOT reached, and the remaining 696 are the orphan bucket, each
one named in the instrument's output rather than left as a remainder.
The PDF arm reaches 2 762 because `find_candidates` **early-returns** when
`outline_marks` are given: the bookmark route bypasses the orphan check AND Arm
F together. The closing move is therefore to let a declared XML structure take
the route declared PDF structure already takes. That is a `propose.py` change
this round was fenced out of, and it is in DU SKAL BESVARE with the number that
decides it.
## 5. Consumption, and the cross-arm number
hit@k over SK2's six questions, fasit validated as present in the bundle FIRST
(**7 of 7**, on both bundles, independent of key form):
| | hit@1 | hit@8 | hit@50 | KP | KN |
|---|---|---|---|---|---|
| XML bundle (`--no-unit-fold`) | **3/6** | **5/6** | **6/6** | **rank 1** | no rank |
| PDF arm (`--pdf-outline`), same machine | 3/6 | 5/6 | 6/6 | rank 13 | no rank |
| SK2's arm-off reference | 1/6 | 2/6 | 4/6 | -- | -- |
No row is worse. Per row at k=50: S1 4 against 3, S2/S3/S4 1 against 1, S5 11
against 11, S6 3 against 3. **KP goes from rank 13 to rank 1** -- the one row
round 12 left open on the ranking side is closed on this channel, and it is
delivered at the default `k` as well. KN (`prosess 97.4`, fabricated) still
gets no rank on either bundle.
**Cross-arm:** **2 022 concept ids are shared -- 96.8 % of the XML bundle and
73.2 % of the PDF arm's.** Only in XML: 67. Only in PDF: 740, which is the
orphan bucket plus front matter. A named differing pair:
`1-bruksomr-der-for-prosesskoden` (XML) against `1/bruksomr-der-for-prosesskoden`
(PDF) -- the same section, because a bare `1` is not split by `_NUMBERED`
(which requires a dot) while the bookmark arm's `_split_outline_title` does
split it. Normalised TITLES intersect on only 38, for that same reason: the ids
agree and the titles do not.
## 6. Cost, and it is machine-relative
Both arms measured in the SAME session on the same machine, `/usr/bin/time -l`,
whose maximum-resident figure is in **BSD BYTES** -- converted here and said so.
| build | wall | peak RSS | concepts |
|---|---|---|---|
| XML, shipped defaults | 2.10 s | 107 180 032 B = **102.2 MiB** | 23 |
| XML, `--no-unit-fold` | 14.93 s | 114 040 832 B = **108.8 MiB** | 2 089 |
| PDF arm, `--pdf-outline` | 181.52 s | 3 408 691 200 B = **3 251.1 MiB** | 2 762 |
**Direction and order of magnitude, not a number against a number:** the XML
route is about **12x faster and about 30x smaller in peak memory** at a
comparable concept count. Absolute values are not comparable across reports --
round 12's child measured 183.31 s / 3 251 MiB and PM measured 145 s / 3 347
MiB on the same commit; this machine reproduces the child's figures.
## 7. Nothing else moved, and it was measured
- `~/okf-test/dokumenter` **byte-identical** against a frozen `e1f4faa` export,
`diff -r` empty, 52 md / 26 concepts. That folder holds **0 `.xml` of 5
files**, so it is a real known-negative and not merely an unchanged number.
- The K2 corpus holds **0 `.xml` of 86 files**. The pinned bundle's
`EXPECTED_CONCEPTS` and `EXPECTED_RANKS` are unchanged (7 passed), including
its `--no-tie-shared-rank` known-negative.
- `okf project` still byte-equal to `okf build`, `diff -r` empty.
- `okf skill` + `okf consume` + `okf check --skill … --payload …`:
**conformant, 15 rules over 8 excerpts and 2 081 withheld entries, 0
findings**.
- Suite **1567 passed, 1 skipped** (1554 before); `ruff check` and
`ruff format --check` clean over 116 files; `mypy src` clean over 21 files --
all four after `git add` and after commit.
- `_EVIDENCE` gains a `.xml` row at **`measured`**, asserted by the suite. The
class was chosen against the definitions above the table rather than
assumed: `measured` is "real CORPUS files and a hand-counted fasit -- someone
wrote the document for their own purposes and we counted what we got", and
this is a publisher's own published document with the publisher's own
structure as the fasit, written long before any lookup of ours. It lands in
the same place as `.html`, whose limit already reduces its 828 files to one
product from one publisher; the file count is smaller and the fasit is
stronger, being explicit rather than a generator's cut.
## Honesty limits
- **The denominator is ONE file, from ONE publisher, in ONE schema.** `.xml` as
a file type is far wider than NISO-STS. 828 HTML files and eight corpus PDFs
are different denominators and not comparable ones.
- **The fasit is the source's own structure**, so "correct boundary" here means
what the publisher declared, not what is useful to a reader.
- **The denominator 2 761 is CHOSEN.** The file holds 7 715 `<sec>`; 4 954 of
them are lettered points inside a process description. Whether those should
ever be concepts is a different question, answered here as "no" and measured
at 0 of 4 954.
- **The ceiling is structural, not recovered.** This route reads the
publisher's claim about its own structure directly, where the PDF arm had to
bridge from (page, y) to a line index. A high percentage here is therefore
**not** comparable to an equally high percentage there.
- **A STS document with wrong or stale `<sec>` nesting carries that error
straight into the segmentation**, and one document says nothing about how
common that is.
- **The generic fallback is measured on fixtures only**, never on a corpus.
That half of the row is `constructed` whatever the STS half earned.
- **SK1's and SK2's figures are `vegnormal-okf`'s own documentation** except
where re-measured here and stated as such.
- **The unit worksheet is n = 12 positions** (8 `pdf`, 3 `docx`, 1 `xlsx`) and
holds no `xml`, so this row is measured outside it, as `md`/`odt`/`pptx`/
`html` are.
- **"Delivered" is not "answered."** hit@k says a gold document reached the
payload, not that the payload answers the question.