test(accounting): content-accounting gate for okf build, written red

Capability loop step 3, no capability. tools/okf_accounting_gate.py asks,
per README file type, how many of the elements a SOURCE holds okf build
books as carried / pointer / coded rejection, with unaccounted and
double-booked both required to be 0. Exit 1 today on rows 2, 3, 4 and 6.

The fasit is tools/okf_witness.py (stdlib + pdfplumber + poppler, no
package import; tested on the live import graph), committed as
tests/fixtures/accounting/*inventory.json over one fixture per type.

Measured: no source inventory (0 of 13); two graphics/ files carried
through documents AND counted extractor_unknown (50 on R761 under
--gate none); a refused document logged "0 carried of 0 found"; R761
refused whole because guard 1.4.0 treats its 71 U+00AD soft hyphens as an
invisible carrier (asked of the security repo). The two R761 witnesses
agree once STS labels are counted by role, not tag.

Report: docs/2026-09-17-innholdsregnskapet-rod-gate.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-17 15:40:37 +02:00
commit 0b00de4408
26 changed files with 2598 additions and 0 deletions

View file

@ -0,0 +1,149 @@
# Content accounting in `okf build`: the gate, written red
Capability loop, step 3. This session adds no capability. It adds the gate a
later capability has to turn green, together with the fasit that gate judges
against.
- Command: `python3 tools/okf_accounting_gate.py [--json] [--r761 DIR] [--consume]`
- Exit codes: 0 when every failing row (16) is green, 1 otherwise, 2 when
the gate did not run.
- Fasit: `tools/okf_witness.py`, committed as
`tests/fixtures/accounting/*inventory.json`.
- Tests: `tests/test_accounting_gate.py`, 42 tests, green.
## Why a gate at all
`okf build`'s conservation identity, `merged + coded rejections == N`, counts
FILES. Two things happen that the identity cannot see, and both were
reproduced here on `bc39e80` (v0.10.0) before any code was written:
- **A file can be merged while content inside it is gone.** No inventory of
the source exists, so nothing can state what a merged file lost.
- **A file can be carried and rejected at once (finding B).** The fixture
corpus has a `graphics/` directory next to its documents. Its two PNGs are
carried into `assets/` through the documents that point at them, and the
same two files are also counted as `extractor_unknown` rejections. Each has
two fates.
- **A rejected document is logged as if it held nothing (finding C).** The
fixture document the guard refuses produces
`Images: 0 carried of 0 found`, although its source declares one image.
## The fasit is independent
The witness imports no `llm_ingestion_okf` module. A test runs every witness
in a fresh process and then reads `sys.modules`. A control run of the same
check, with the package pre-loaded, shows the check fires.
The witness counts each format by the format's own rules:
- STS: ElementTree, and the publisher's JSON node tree.
- docx, pptx, xlsx and odt: the zip members' own XML.
- PDF: pdfplumber objects, and poppler (`pdfinfo`, `pdfimages -list`).
- HTML: `html.parser`.
- md, txt, csv, json and rtf: stdlib readers, and a control-word scan for rtf.
Four documents are also pinned to hand counts.
**Two witnesses disagreed by tag, and both disagreements were about placement
rather than content** (R761, measured). A section's label is `sec/label` on
7 714 sections in the XML. The JSON puts it at `sec/label` on 4 954 of them
and at `sec/title/label` on the other 2 760. A table's label is
`table-wrap/label` in the XML (10) and `table-wrap/table/caption` in the JSON
(10). The witness therefore counts ROLES, and the mapping is written in
`okf_witness._sts_role` together with these numbers. After the mapping, the
two R761 witnesses agree on all ten roles.
The PM's figures were re-measured and all hold: `p` 12 528, `title` 2 761,
`td` 197, `list-item` 111, `graphic` 50, 109 other files in the zip (50
pointed at, 59 not). The PDF witnesses agree at 701 pages and 50 images;
pdfplumber takes 50 s for that and poppler 1 s.
## The door the capability must open
The gate reads `okf build --accounting PATH`: one JSON object with a
per-document `inventory` taken before extraction and before the gate,
per-element `fates` (`carried` / `pointer` / `rejected{code}`), and exactly
one `fate` per inbox file that is not a document. It also reads one line in
`log.md` per rejected document:
<source_file>: <M> elements found in the source, 0 carried: document rejected `<code>`
The full contract is in the module docstring. Until the flag exists, row 2 is
0 of 13, and row 3 books every element as unaccounted. For a file, it derives
the file's fates from the bundle bytes (assets are named by content) and from
K1b.
## Finding A: why the guard refuses R761, and the decision
The cause is measured. The R761 XML carries **71 U+00AD SOFT HYPHEN** and no
other invisible codepoint, and the extracted text carries the same 71. Guard
1.4.0 lists U+00AD in `_ZERO_WIDTH_CPS` (`output.py:250`). One soft hyphen is
enough to fail_secure at both tiers; the same string without it is `clean`.
Image pointers are not the cause: `--no-assets` gives the same refusal, as the
PM also measured.
This is a question for the guard. It was sent to
`llm-ingestion-pipeline-security` with the repro, and nothing was built there.
**Decision on exit 0 when nothing was persisted:** `okf build` should NOT exit
0 when at least one document was extracted and none was persisted. The
conservation identity holding is necessary, not sufficient.
- This repository already refuses the analogous case: a run with zero plans
fails (exit 2) rather than emitting a flat bundle and calling it success.
- `okf build` is the trusted-source path, an operator pointing at their own
folder, where "everything refused" is a finding and not a normal outcome.
- A script running `okf build && okf skill` would otherwise wire a skill to an
empty bundle.
Door B's library function keeps its behaviour, because for a hostile inbox
"all rejected" is correct. The change is an exit code, which this order
forbids, so it belongs to the capability order and not here.
## Exceptions proposed, none approved
The gate lists three exceptions in every run. None of them lowers a
denominator:
- **PDF headings, paragraphs and tables.** A PDF without a structure tree
declares none of them, so no witness can count them.
- **xlsx images.** The reader deliberately does not carry them.
- **Images in md, txt, csv, json, odt and rtf.** No reader for these types
carries image bytes.
Approving any of them is the operator's decision.
## Output at this commit
See the order's closing message for the full text. In short:
| Row | Result | Status |
|---|---|---|
| 1 | 13 of 13 | GREEN |
| 2 | 0 of 13 | RED |
| 3 | 0 of 15, u = 248, d = 2 | RED |
| 4 | 0 of 1 | RED |
| 5 | 4 of 4 | GREEN |
| 6 | 59 of 110 (R761, two builds) | RED |
Row 6 runs two builds:
- **Default gate:** the document is refused, so every element is unaccounted.
- **`--gate none`:** the document is persisted, and the 50 pictures it points at
are both carried and rejected, which is finding B at scale (d = 50). A first
run reported 57: 7 of the 59 unpointed files share bytes with pointed ones,
and bytes in `assets/` had been credited to them. A file nothing points at
is never carried; a test holds that.
Row 7 (`--consume`) is a diagnostic and never fails.
## Limits
- The witness vocabulary is ours. A format element the witness does not name
is invisible to the gate.
- The fixture corpus is constructed, one document per type. The real-corpus
row is N = 1 (R761).
- Row 6 needs the R761 source on this machine. Without it the row is RED
locally and SKIPPED when `CI` is set, never green.
- The `--r761` default points into another repository's data directory, and
the gate only reads from it.