llm-ingestion-okf/docs/2026-09-11-k3-runde13-xml-sts.md
Kjell Tore Guttormsen 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

285 lines
16 KiB
Markdown

# 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.