feat(accounting): okf build accounts for every source element

okf build --accounting PATH inventories every source document before
extraction, in the gate's per-format vocabulary, and after the run gives
each element one fate (carried / pointer / coded rejection), written as
JSON and summarised in log.md. "carried" is checked against the written
concept bodies, so a gate that drops a line is found (test). Exit 1 on
anything unaccounted or double-booked. Opt-in: +744 s (+19 %) on the
43-document reference corpus, and that corpus fails the check on 24 real
losses (22 images on text-less PDF pages, 2 docx Title paragraphs).

Changed without the flag:
- okf build exits 1 when it extracted documents and persisted none.
  Door B and corpus.measure are unchanged. One test relied on exit 0.
- An image file carried through a persisted document is its own K1b
  column, no longer also extractor_unknown. The set is what the resolver
  actually carried (ExtractedDocument.files), never a byte match.

tools/okf_accounting_gate.py (checks untouched) is green on all six rows,
R761 110 of 110 under both gates.

Report: docs/2026-09-17-innholdsregnskapet-bygget.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-17 18:35:20 +02:00
commit 864570b320
13 changed files with 1751 additions and 59 deletions

View file

@ -0,0 +1,177 @@
# Content accounting in `okf build`: built
Capability loop, step 5. This work carries out order
`20260917T134442Z-3174112963`. The judge is `tools/okf_accounting_gate.py`,
written red at `0b00de4`. Its checks are unchanged here.
## Result
The gate is GREEN on all six rows (exit 0):
- rows 1 to 5 on the fixtures;
- row 6 on R761 Prosesskoden:2025, 110 of 110 units under both builds.
None of the three proposed exceptions was needed for these corpora. They are
still not approved, and the build does not apply them. A workbook carrying an
image, which the reader does not carry, is reported as one unaccounted element
and exits 1. `tests/test_content_accounting.py` holds that.
## What changed
1. **`okf build --accounting PATH`** (`src/llm_ingestion_okf/accounting.py`).
- **Before extraction:** an inventory of every source document in the
gate's per-format vocabulary.
- **After the run:** one fate per element — `carried`, `pointer` or a coded
rejection — written as JSON to PATH and summarised in `log.md`.
- **Exit:** 1 when anything is unaccounted or double-booked.
2. **`carried` is checked, not declared.** Every piece of an element's text
must be present in the concept bodies written for that document. Both sides
are compared as letters and digits only, case-folded. A container is
carried when its contents are.
- **Images** are booked from what the reader did with each placement:
carried, `asset_remote` (booked as `pointer`), or the reader's rejection
code.
- **A refused document** books every element under the gate's code.
3. **One fate per file.**
- An image file that a persisted document carried is no longer also an
`extractor_unknown` rejection. It has its own column in the conservation
identity.
- The set of carried files comes from what the resolver actually resolved
and carried (`ExtractedDocument.files`), never from byte equality.
- Why not byte equality: the gate's own first run on R761 credited 7
unpointed files that share bytes with pointed ones (d = 57, not 50).
4. **Exit 1 when documents were extracted and none persisted.** This is the
decision recorded in `docs/2026-09-17-innholdsregnskapet-rod-gate.md`.
- Door B's library function and `corpus.measure` are unchanged.
- One test in this repository relied on exit 0 here:
`tests/test_cli_gate.py::test_build_refuses_a_document_the_real_guard_refuses`.
- No script relies on it.
- `okf project` calls the build as a function, so it does not see the new
exit code. A project over a folder where every document was refused
still writes a skill. This is noted, not changed.
5. **`log.md`.**
- With `--accounting`, the `Images` bullet counts what the sources declare,
and each refused document gets its own line.
- The K1b line names the carried column only when that column is
non-zero. The pre-change line is therefore byte-identical for a corpus
without such files, which is tested.
## The check can fail, and where it is weak
- **Known-positive.** A gate that silently drops one line of `notat.md`
leaves K1b intact. It is reported as `paragraph 1 unaccounted`, and the
build exits 1 (test).
- **Control on R761, measured.** The document was built with `--gate none`,
as one concept, and then its written text was cut.
| Written text | Result |
|---|---|
| whole | 0 unaccounted over 31 096 elements |
| cut to half | 4 823 paragraphs, 3 621 sections and 55 list items unaccounted — but only 3 titles and 16 section labels |
| first 200 characters only | 12 475 of 12 528 paragraphs and 2 761 of 2 761 titles unaccounted, but 150 of 197 cells |
- **The limit.** The check proves that a string is present, not where it is.
A short element (a label such as `84.1`, a one-word title, a one-number
cell) is often present elsewhere in the same document, so its loss can go
unseen. Long elements are well covered.
- **Why not stricter ordered matching.** It would close part of this, but the
inventory's order is not always the written order: the pptx inventory lists
a slide's tables before its shapes. It is not built.
## Independence, stated precisely
`tools/okf_witness.py` imports nothing from this package; a test proves that on
the live import graph. The package's inventory implements the SAME
definitions a second time, and for markdown it reuses the proposer's own fence
rule (`propose.fenced_lines`), where the witness has its own. Agreement on the
fixtures (row 2, 13 of 13) is evidence that the definitions can be implemented
as written. It is not an independent count of the source. The independent
count is the witness's, and it is committed as data.
Known divergence, not exercised by any fixture: two fenced blocks with no
line between them are one `code_block` here and two in the witness.
## Cost
Measured on the 43-document reference corpus (`K2/trinn1`), built twice
from one snapshot of `src/`, each time as one run under `/usr/bin/time -l`:
| | wall | peak RSS | result |
|---|---:|---:|---|
| without `--accounting` | 3 902 s | 8.83 GB | exit 0 |
| with `--accounting` | 4 646 s | 9.36 GB | exit 1, 28 unaccounted |
- **The difference is +744 s (+19 %) and +0.53 GB.** The run without the flag
shared the machine with the test suite and two gate runs, so its wall time
is inflated and the true difference is likely larger. The 0.10.0 figure for
the same build was 3 088 s.
- **Most of the cost is the PDF inventory**, which opens every PDF a second
time with pdfplumber to list image placements.
- **Every concept file is the same in both builds except two,** and the
reason is not the flag. It is a 0.10.0 determinism defect (see Findings).
`log.md` differs by the accounting lines, as it should.
**Why the door is not the default.** It is opt-in for two reasons:
1. It costs about a fifth of the build time.
2. On the reference corpus, the check FAILS. A default-on flag would turn
every existing `okf build` of that corpus into exit 1 until the defects
below are fixed, or an exception is approved.
That second point is the door working as intended, and it is also a breaking
change nobody has decided on.
## Findings on the reference corpus
The door reported 28 unaccounted elements. Four of them were false positives
in the check itself, and both mechanisms are now fixed, each with a test:
- **A converter attribute between carried words:** `[Sted]{.mark}` for a
highlighted placeholder (1 element).
- **A `w:br` inside a table cell,** which the converter writes on two
grid-table rows (3 elements: the paragraph, its cell
and its table).
Recomputed on the same bundle after those fixes, **24 remain, and all 24 are
real losses:**
1. **22 images on PDF pages that have no text layer.**
- Affected: `Bilag 9.1` (16), `RIG-R02` (4), `Bilag 9.2` (2).
- Cause: `_pdf_pages` drops a page with no text, and its images with it.
They are neither carried nor rejected.
- pdfplumber lists 212 / 102 / 6 placements where the reader handled
196 / 98 / 4. The difference equals the placements on text-less pages,
exactly, in all three documents.
- These are plausibly the most important pictures in those files, because
a page with no text is a drawing or a scan.
2. **2 docx headings in the `Tittel` (Title) style.**
- Affected: Vedlegg 2 and Vedlegg 3.
- Cause: the converter moves a Title paragraph into document metadata, so
the body has an empty `# ` where the title stood.
Neither is fixed here. Both change bundle bytes, so each is a separate order.
The 24 combine two measurements: 22 image losses from the door build, and 2
text losses from recomputing the text accounting over that same bundle with
the fixed check. A fresh door build after the fixes was not run.
**A determinism defect in 0.10.0, found by the same comparison.**
- Inline PDF images (`BI … EI`) get their name from pdfminer, which derives
it from `id()` of a Python object. The pointer line
`Image: page-3-6344095824 (not carried: …)` therefore changes between runs.
- Affected: two concept files of `K2/trinn1` (`bilag-6-teknisk-oppsett`,
`bilag-9-1-...`).
- This breaks the bit-exact rebuild invariant. It is not caused by this work.
It is reported here and not fixed, for the same reason as above.
## Proposals, not carried out
- **Version:** `0.11.0`. The change adds a flag and changes an exit code for
existing users. The PM wants an independent review before any tag.
- **Not a default:** see Cost.
- **Separate orders:** carry the images of text-less PDF pages; keep a docx
Title paragraph in the body; make inline PDF image names deterministic.
- **A cheaper PDF inventory:** count image placements from the pages the reader
already parsed, and keep an independent count only in the gate.