Commit graph

396 commits

Author SHA1 Message Date
b486fea939
test(assets): the lossless guard gets a stdlib arm that always runs
19 passed in this file, 0 skipped. Green on arrival and stated as such: no
production code changes here, so there is nothing for a red commit to precede
-- the defect is that the property was never MEASURED on a core install.

Measured by PM 2026-09-19 on one: 4 of the 13 guards in this file were
SKIPPED, the lossless one among them, because they decode through Pillow --
which this package neither uses nor ships and which arrives only as a
transitive dependency of `pdfplumber` under `[extract]`. A plain
`pip install llm-ingestion-okf` therefore never measured the property this
round is named after.

The new arm decodes the carried PNG with `zlib` and the five filters of PNG
SS 9.2, and compares against pixels written out in this file: `rle8_indices`
for the RLE8 source and `RGB_ROWS` for the 24-bit one. Still independent in
the way that matters -- the package compresses rows it filtered, the test
reverses filters it decompressed. Two arms: one over `read_image`, one over
what the BUILD wrote.

The environment is MADE rather than assumed: a `sys.meta_path` finder raising
for every `PIL` name, with a known-positive that it fires before the guards
re-run under it. Control, run once and not committed: a Pillow-dependent line
placed inside the blocked section turns the guard red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 09:33:34 +02:00
76e407f368
docs(accounting): the judge names the fidelity it does not check
Green: 112 passed in the gate's own file. The docstring now says what the two
routes do NOT reach -- neither decodes a pixel, so a converter writing a blank
PNG is accepted, and "claiming a conversion it did not perform" means claiming
one whose FILE is missing, never one whose pixels are wrong.

Teaching the judge pixels is a different job and is not this order. Leaving a
reader of the gate to infer the gap was the defect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 09:31:50 +02:00
7bc9882100
test(accounting): the judge must SAY it proves carriage and not fidelity (red)
1 of 1 red, on an ASSERT about the published sentence. The behavioural half of
the same test is already green and that is the point: PM's M10 measured that a
mutated converter writing a BLANK PNG gives `asset_holds = True`, because the
bundle is internally consistent -- the digest it claims to have written really
is the asset's digest -- and the judge has no opinion about whether those bytes
hold the source's picture.

The limit is therefore asserted in both directions: it is REAL (the blank PNG
is accepted here) and it must be STATED (the docstring names what the route
cannot see). "A bundle claiming a conversion it did not perform still fails"
reads wider than the route reaches; the suite fells that mutant, this gate
cannot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 09:30:00 +02:00
24a828469f
fix(accounting,assets): a conversion claim counts only where the code wrote it
Both red guards green, and the whole suite is 2204 passed / 1 skipped (2199
before this round, +5 new tests, no golden moved).

TWO HALVES, AND NEITHER IS SUFFICIENT ALONE. The judge now reads the clause
only from inside a POINTER BLOCK -- the markdown image line plus the detail
line under it -- and only where the clause names the asset that block points
at, anchored to the end of the line because the build writes it last. That
closes ordinary body text and a table cell. It cannot close an image's own
alt text, because a label is document text that the build writes INSIDE a
pointer block, which is the second half: `assets._inline` disarms a checksum
field in anything that came from the document.

WHERE THE BOUNDARY RUNS, stated in both files. Everything `_inline` returns
came from the document -- an alt attribute, an STS caption, a publisher's file
name. Everything `render_block` appends after it came from the run: the size
it measured, the type it sniffed, the digests it computed. The second line
carries both, so document text may not emit the grammar the run writes there.
The digits are kept, because a reader is owed what the document said; the
colon that makes them a FIELD is not.

The judge's expression stays restated rather than imported, for the reason
`asset_holds` already gives about the naming rule: a judge sharing the
judged's own expression agrees with it by construction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 09:29:20 +02:00
39f6f0a4fb
test(accounting): a document must not be able to forge a conversion claim (red)
2 of 2 new guards RED, both on an ASSERT about behaviour (`assert True is
False`), and the second REPRODUCES PM's measured path end to end through the
real `okf build`: a BMP declaring 50 000 x 50 000, refused `asset_too_large`
and absent from `assets/`, gives `asset_holds = True` because an `<img alt>`
in the document states the conversion clause. The unit arms reproduce the
other two ways in -- ordinary body text and a table cell -- and a third the
report did not name: the clause inside a pointer block for a DIFFERENT asset.

The judge's first sentence is "THE FASIT NEVER COMES FROM THE READER IT
JUDGES". Before the conversion route landed, `asset_holds` hashed the source
file and looked in `assets/`, so no document could reach it; the route added
for converted images reads two digests out of the bundle text with a free
expression, and `claimed and not found` can now be silenced by a document
that asks for it.

Each arm carries a source that was never carried while the bundle holds one
unrelated REAL asset, so the digest the forgery names is genuinely in
`assets/` -- the property that made the measured forgeries work. The
known-positive on the same bytes keeps the arms from passing on a route that
has merely stopped working.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 09:23:08 +02:00
ad532f3c6d
fix(assets): an RLE8 stream that never reaches end-of-bitmap is refused
The four cuts now raise `asset_samples_invalid`, the code the UNCOMPRESSED
path already raises for a body that stops short. No pixel is guessed: the loop
may end at an explicit end-of-bitmap escape and nowhere else, and running out
of bytes -- mid encoded run, mid absolute block, or one opcode before the
terminator -- is a refusal with a row instead of a partly blank PNG.

WHY THE TERMINATOR AND NOT `biSizeImage` OR A COVERAGE COUNT. `biSizeImage` is
a claim by the same untrusted header, and a coverage count would refuse the
delta escape the format defines. Read off the corpus instead: over the 19 real
RLE8 assets of the frozen R761 delivery, 19 of 19 end at an explicit
end-of-bitmap, on 19 of 19 it is the stream's LAST two bytes, and on 19 of 19
`biSizeImage` equals the available bytes. A whole stream that omits the
terminator is refused alongside a cut one, because from a reader's side they
are the same bytes and the measurement says no real writer here produces the
first.

An absolute run shorter than its own declared count also stops the loop rather
than painting what arrived, so the refusal names the cut and not the frame.

KNOWN-POSITIVE, re-measured on the frozen delivery with stdlib on BOTH sides
(an independent BMP reader and an independent `zlib` + filter-reversal PNG
decoder, no Pillow anywhere): 19 of 19 still convert losslessly, 2 366 365
pixels compared, 0 refused, and a one-byte control confirms the comparison can
fail. 92 passed over the three asset test files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 09:21:13 +02:00
c2080c4b34
test(assets): a truncated RLE8 stream must be refused, not painted (red)
1 of 3 new guards RED, on an ASSERT about behaviour: `DID NOT RAISE
ExtractionError` on all four cuts. The other two are the section's own
known-positives and pass by describing what is already true -- where each
cut lands in the fixture's opcode stream, and that the UNCOMPRESSED BMP path
already refuses the same shape with `asset_samples_invalid`.

Measured by PM 2026-09-19 on a real R761 asset (352x548 = 192 896 pixels): a
stream cut to 90 % was carried with 13 923 pixels wrong, to 50 % with 95 890,
to 10 % with 166 525 -- no code, no row, a partly blank PNG standing under a
content address that says it holds the source's pixels.

The test brings its OWN permissive RLE8 decoder, so the pixel cost of each cut
is a number this file computes (8, 11, 0 and 0 of 32) rather than the package
agreeing with itself; two of the four cuts lose no pixel at all, which is the
whole reason the rule has to be the terminator and not a pixel count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 09:19:50 +02:00
28f879849c
docs: the reach clause was true of U+00AD alone, and the CHANGELOG says so
"0 across `tests/fixtures`, `examples`, `skills`, `docs`, `src`, README and
CHANGELOG" was published for all SIX characters and is false. Counted here
over `git ls-files` in those seven places, with a planted known-positive
proving the scanner can find one:

    TRACKED in the seven places: 257 | readable: 230 | binary: 27
    files carrying ANY of the six: 2
        tests/fixtures/accounting/rejected-inventory.json {'200B': 1}
        tests/fixtures/accounting/rejected/avvist.html    {'200B': 1}
    files carrying U+00AD: 0 []
    KNOWN-POSITIVE planted file: {'00AD': 1, '2060': 1}

The two are this repo's own known-negative fixture for a carrier the door
refuses to remove. For U+00AD -- the character the door actually removes, and
the one the reach argument needs -- the figure is 0 of 230. (PM measured 229
on `43331fc`; the tree has gained one readable tracked file since.)

CHANGELOG under the untagged [0.10.1]: the three now-held sentences, the log's
stated provenance, and both corrected numbers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 08:43:17 +02:00
7509b6b6bc
feat(accounting): the log states where the soft-hyphen count comes from
Chose the stated provenance over a second, independent counter, because a
second counter would be a number built to disagree. The door acts on the
EXTRACTED text; a counter inside `accounting.py` would have to re-read the
SOURCE bytes, and for every type extraction does not carry verbatim (pdf,
xml, html, docx) those are counts over two different strings. The gate would
then have to decide which difference is a loss and which is extraction doing
its job -- inventing a discrepancy where there is none. `accounting.py` does
not hold the source bytes at that point either; being a second reader of a
document is what `tools/okf_witness.py` exists for, outside the package.

Where an independent denominator DOES exist the tests already pin it from
outside the door: the fixture writes its own five, and R761's 71 is PM's
count over the delivery.

The `**Normalisation**` bullet now ends: "The count is the door's own, read
off the run and not recounted from the source."

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 08:42:15 +02:00
eee9d98541
test(accounting): the log must say where the soft-hyphen count comes from (red)
`normalised_soft_hyphen` is read off the RUN (`result.normalised`) and never
recounted from the source. It is the one number the accounting publishes with
no independent denominator behind it, and nothing in the output said so.

RED on the claim:

    assert "The count is the door's own, read off the run and not recounted" in log
    AssertionError: ... '* **Normalisation**: 5 soft hyphen(s) (U+00AD) removed
    from 1 of 1 document(s) before the persist gate. No other character is
    touched.'

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 08:40:51 +02:00
ee827b9f47
test(accounting): row 3 has to SAY what a whole refusal cost
P11 and P12, PM 2026-09-19: the `refused={u.refused}` column on row 3's
detail line and the clause "N element(s) lost with R of D document(s) refused
whole" in its reason could each be deleted with this file green at 106
passed. The LOSS is held -- `Unit.refused` keeps the unit unclean and the
note names the source and its code -- but what the ROW says about it was
decoration nothing pinned, and the row is what a reader of the gate's output
sees first.

R, D and the element total are counted over the units the test builds, never
read back off the row. The known-negative is the same units with no refusal:
the sentence must move with them, or it is a constant that happens to read
true.

Red proven in a scratch copy of HEAD (`/tmp/shy-mut`), control green at 109
passed:

  P11 detail line drops `refused={u.refused}`        -> 1 failed (line 666)
  P12 reason drops the whole "... refused whole" clause -> 1 failed (line 661)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 08:40:27 +02:00
0da282894f
test(extract): the door's promise "no other character is touched" is held
`log.md` writes that sentence on every run and README and CLAUDE.md repeat
it. Until now nothing held it: PM's mutant P6 -- a door that ALSO removes
U+00A0 -- passed the entire suite green (2 171 passed, RC 0) while it would
have eaten all 6 633 NBSP in R761 with the log still claiming otherwise.

Ten characters, each a way for the door to reach past its own name: the four
real zero-width carriers, U+2060, U+00A0, the three hyphens a reader confuses
with a soft one (U+002D, U+2010, U+2011) and a combining accent (the door is
the one step that does NOT normalise to NFC). Each appears at least twice in
the fixture -- asserted, so the loop cannot pass over an empty set.

The invariant is the stronger of the two equivalent forms: rather than
reinserting the removed characters at their booked positions, the test builds
the expected string with its OWN filter over the source. That pins ORDER as
well as multiset, and needs no positions -- which the door does not return.
All three numbers (21 removed, the expected string, every surviving count)
come from the test, never from the door. The second test repeats it through
`extract_document`, the one place the door is applied, so a second remover
beside it is red too.

Red proven in a scratch copy of HEAD (`/tmp/shy-mut`, verified that the
scratch `src/` is the code that runs), control green at 7 passed:

  P6    door also removes U+00A0     -> 2 failed
        `assert text == expected` / `AssertionError: assert 'arbeider  pa...'
        == 'arbeider \xa...'`
  P2010 door also removes U+2010     -> 2 failed
  P6b   door collapses U+00A0 to " " -> 2 failed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 08:37:40 +02:00
d1de1a6a9d
fix(docs): N101 ships 568 tbx: tags, not 574
Counted three ways over `N101-2025-860031.json`, none of them importing this
package: raw `tbx:` substring 568, regex over `"tag": "tbx:` 568, node
traversal 568 across 10 distinct tags (94 x 6 + 1 x 4), out of 7 374 tags in
the delivery. The number is corrected in all four places that published it --
CLAUDE.md, `tools/okf_witness.py`'s `_sts_role_json` docstring,
`tests/test_accounting_gate.py` and the round's report -- and the test from
the previous commit now counts it rather than repeating it.

The one remaining `574` in the tree is `docs/2026-09-11-k3-runde8...md:89`
("11 016 and 6 574"), a character count of two documents and a different
number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 08:35:50 +02:00
0ac474582c
test(accounting): N101's prefixed tags are counted in the test (red)
The known-negative for stripping a namespace prefix -- "N101 ships 574 `tbx:`
tags, and not one of their local names is a role" -- was published in four
docstrings (CLAUDE.md, tools/okf_witness.py, this file, the round's report)
and asserted nowhere. A measurement presented as a fact, with nothing that
could turn red when it stopped being true.

The walk is written HERE rather than taken from `okf_witness`: the witness's
own reader is what this known-negative judges, so counting through it would
make the two agree by construction.

RED on the claim, not on an import:

    assert sum(tbx.values()) == 574
    AssertionError: assert 568 == 574
      where 568 = sum(dict_values([94, 94, 94, 94, 94, 94, 1, 1, 1, 1]))

Three independent counts of the delivery agree on 568 -- raw `tbx:` substring,
regex over `"tag": "tbx:`, and node traversal -- against 574 in the tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 08:35:24 +02:00
c39b666c11
docs: the viewable-asset round, measured per image
README, CHANGELOG (under the untagged 0.10.1), CLAUDE.md and the round's
report. The numbers are the census's, not a summary of them.

- 9 714 image rows over 18 403 files and 67 PDFs, two pinned trees: exactly
  35 rows moved, 9 321 of 9 321 JPEG and PNG rows byte-identical.
- The 16 the round did not go looking for: JPEG 2000 objects out of PDF
  streams, also unreadable by a model, with no stdlib route to convert them.
  They become `asset_not_viewable` -- 16 pictures lost from a bundle, 16
  statements gained that a picture stood there. Stated in the README, the
  CHANGELOG, CLAUDE.md and the report rather than left in a total.
- The accounting gate: R761 0 -> 19 claimed-and-not-found when the conversion
  landed, 0 again after the judge learned its second route; row 6 90 of 111
  -> 110 of 111. The gate's verdict is the same `RED: rows 2, 3, 6` as before
  the round, with row 3's numbers identical.
- `<!-- asset-viewable-media-types: ... -->` is pinned to
  `assets.VIEWABLE_MEDIA_TYPES` by a test, compared as a whole set: a README
  naming three of four would pass every containment check and tell a consumer
  that a format is refused when it is carried.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 08:26:17 +02:00
b0b5e71658
feat(assets): every carried image is one a model can be shown
Chosen: a stdlib BMP reader, because `read_image` is on the CORE path and an
asset's name is its content digest. Measured first, as the order requires:
Pillow 12.3.0 IS in this tree (transitively under `pdfplumber`) and it DOES
decode RLE8 correctly -- a hand-written stdlib decoder and Pillow agree on
19 of 19 of R761's real files, RGB per pixel. So the choice does not rest on
capability. It rests on two properties of this package: `.html` and `.xml`
carry images with no `[extract]` extra installed, so a Pillow converter
either makes a core path depend on an optional binary wheel or buys the
second runtime dependency; and encoding through an installed library would
make a bundle's identity move with that library's version, which is the
property 0.10.0 felled page rasterisation over and `encode_png`'s docstring
already defends. Pillow keeps the job it is good for: the INDEPENDENT decoder
in the tests, on neither side of the conversion.

The defect, measured over the frozen R761 delivery's `assets/`, denominator
50: 29 JPEG, 2 PNG and 19 RLE8 BMP. The 19 are byte-correct files nothing
reads, so 19 figures were present and invisible while `images: N` reported
that they had arrived.

- `VIEWABLE_MEDIA_TYPES` is tested against every asset's SNIFFED type, so it
  is a property and not a list of formats we met. WebP is on it and `sniff`
  does not recognise one; the limit is stated, not implied.
- `bmp_to_png`: 8-bit uncompressed, 8-bit RLE8, 24-bit uncompressed. All five
  RLE8 opcodes. 19 of 19 real files convert with RGB identical to Pillow's
  decoding of the source, 2 366 365 pixels compared.
- `asset_not_viewable` and `asset_bmp_unsupported`, both published, both
  leaving the concept's "not carried" line.
- Traceability on the pointer's second line, where the rest of the asset
  metadata already lives: original media type, original sha256 in full, new
  sha256 in full. A converted asset is ONE asset.
- The ceiling is paid on the DECLARATION before a row is allocated, and an
  RLE run is one clipped slice -- painting pixel by pixel leaves the memory
  bounded and the CPU unbounded.

Two repairs the change forced, each measured rather than assumed:

- `tests/test_assets.py`'s "dimensions absent is absent" used a TIFF, which
  is now refused before `read_image` returns. The property still has a
  reachable case -- a JPEG whose frame header never arrives -- and uses it.
- `asset_holds` in the accounting gate proved a carry by hashing the SOURCE
  file, which a converted image's bundle cannot satisfy. It now also reads
  the two digests the bundle states and HASHES THE ASSET ITSELF, so a bundle
  claiming a conversion it did not perform still fails.

`tools/okf_asset_census.py` is the committed instrument for the
known-positive: one row per image, from two pinned trees. It was caught by
the rule it serves -- its first version handed `_pdf_images` the wrong page
object and reported 0 images over 67 PDFs with exit 0. The attribute is
asserted now and a known-positive runs before the sweep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 08:07:04 +02:00
955ec4b2ca
test(accounting): the judge cannot prove a CONVERTED image was carried (red)
Found by running the gate against the working tree: R761 goes from 0 to
**19 claimed and not found** the moment the conversion lands, and 19 is
exactly its RLE8 BMP count.

The cause is a rule that was right until this round. `asset_holds` proves a
carry by hashing the SOURCE file and looking for those bytes under their own
content address in `assets/`. A converted image's source bytes are not in the
bundle and never will be -- the run carried the picture under a new digest,
which the bundle states on the pointer line beside the old one.

Red on the claim: `asset_holds` returns False for a build whose bundle
declares the conversion and whose `assets/` holds the named file. Two
known-negatives are in the same test so the second route cannot become a way
of believing the report: a bundle that claims a conversion and ships no file,
and a bundle shipping a file under a digest it never tied to this source.
Both must stay False, because the judge has to hash the asset itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 07:57:43 +02:00
9e99bb2cec
test(assets): two guards the mutant survey found missing (red)
Written after walking the five mutants the order names against the eval as
committed. Two of them SURVIVED it, which makes them holes in the eval and
not in the code that does not exist yet.

1. "the format is read from the file extension instead of the bytes" survived,
   because every image in the fixture is named after what it is: a `.bmp` maps
   to image/bmp either way and a `.tiff` to image/tiff either way, so the
   fixture could not tell a sniffed type from a claimed one. A BMP named
   `graphics/figur.png` can. Red today: `image/bmp` != `image/png`.
2. "the ceiling is checked after decoding instead of before" survived because
   the guard only asserted that the refusal happens, and `encode_png`'s own
   `check_size` refuses too -- one frame later, after the memory is spent. The
   guard already measured the peak; what it could not do was measure it
   affordably, because a 50 000 x 50 000 frame is 2.5 GB. At 7 000 x 7 000 the
   declaration is still over the 40 MP bound and the unbounded frame is 49 MB
   -- measurable, and two orders of magnitude over the 4 MB the guard allows.

Nine of thirteen guards are now red on their claim; the four green ones state
properties that already hold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 07:29:05 +02:00
fea04355b5
test(assets): every carried image must be one a model can show (red)
The eval the round is judged on, written before the decoder exists. Eight of
twelve guards are RED on their claim, four are green because they state
properties that already hold.

Red, and each on an assertion rather than an import:

1. Visibility as a PROPERTY of every carried asset, read off the bytes: 3 of
   3 carried assets in the fixture bundle are image/bmp or image/tiff, which
   no model displays. The denominator is counted in the test from the inbox,
   so a build that carried nothing could not pass over an empty set.
2. Lossless: 0 carried assets in the viewable set hold the source's pixels.
   The holder must BE viewable -- a BMP carried verbatim trivially holds its
   own pixels, so a guard without that clause would be green today.
3. Traceability: the bundle states neither source checksum and neither
   original format.
4. One asset, not two: the assets directory holds 3 files for 2 convertible
   images plus one that should be refused.
5. An RLE stream far longer than its declared 8x4 frame is carried, in a
   format nothing reads.
6. A TIFF and a truncated BMP are both carried silently; neither raises.
7. The concept says "not carried" nowhere.

Green already: the fixture's own known-positive (Pillow decodes the
hand-built RLE8 opcode stream to the literal grid this file states), the two
byte-identity controls for PNG and JPEG, and the bound on a BMP declaring
50 000 x 50 000.

The fixture is built byte by byte in the test and exercises all five RLE8
opcodes -- encoded run, absolute run with its pad byte, delta, end-of-line,
end-of-bitmap. A fixture of runs alone would pass with a decoder that
implements none of the other four. The independent decoding is Pillow, which
this package neither uses nor ships, so "identical pixels" is not this
module agreeing with itself; the viewable set and the rejection codes are
written out in the test rather than imported, for the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 06:46:56 +02:00
43331fc4d4
test(mutants): repair M21, add X3 [mutation-ok], and the round's report
The `if False:` in this diff is a mutation DEFINITION -- a string in
`MUTANTS`, applied only to a throwaway copy of the tree inside the
harness and restored in a `finally`. No branch in this repository is
pinned by it.

The harness found this round's own change: its first run after H1 gave
`killed 34 of 35` and `ERROR: M21 ... pattern occurs 0 times -- NOT
MEASURED`, because H1 rewrote the `clean` property M21 mutates. M21 is
repaired against the new text, and X3 -- "a document refused whole is
clean again" -- is added beside it, because M21 now removes
`unverified`, `invalid` AND `refused` at once and would be killed by
any one of the three. Final run: killed 36 of 36, 0 survived, 0
errors, exit 0.

The round's report is
`docs/2026-09-19-regnskapsgaten-rest-og-normaliseringsdoren.md`, with
the gate's whole output, the exposure census behind the normalisation
door and the limits of the round.

CHANGELOG: this round's entries are folded into the UNTAGGED `[0.10.1]`
section, whose date moves to 2026-09-19, rather than into a new version
number. `v0.10.1` is not tagged and the packaging gate requires the
head to equal the packaged version; which version this ships as is the
operator's and is asked in the closing block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 06:32:35 +02:00
be5fe2baa5
docs: the normalisation door, the refused column and the role map
README gains the partial-refusal paragraph and the soft-hyphen door
with its measured reach; CHANGELOG gets an Unreleased section over
this round; CLAUDE.md's open commitment "R761 is refused whole by
guard 1.4.0 ... that is the security repo's question" is replaced by
what was decided and built, with the alternatives that were felled and
the numbers behind each rule.

No version bump and no tag: `v0.10.1` is not tagged yet and releasing
is the operator's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 06:13:06 +02:00
eebaf534fa
feat(extract): one normalisation door removes U+00AD before the guard
Operator decision 2026-09-18, and the whole of it: okf removes the soft
hyphen, the guard is untouched, and the number is BOOKED.

`normalise_extracted(text) -> (text, removed)` in `extract`, applied
once at the end of `extract_document`, so both entry points and both
sides of a plan's `text_sha256` see one string. `_pdf_units` measures
its page offsets through the same door, because that table is
CHARACTER offsets rebuilt from the pages while the text it indexes
comes back normalised -- two readings of one document, and a table
built against the other names the wrong page with full confidence.
Removing a character removes no newline, so every line-indexed rule
downstream is unmoved by construction.

`ExtractedDocument.soft_hyphens` carries the count out;
`InboxResult.normalised` carries it per document; `account_run` reads
it off the RUN rather than recounting the source, because a second
count would be a second reader. It reaches the accounting JSON as
`normalised_soft_hyphen` at both levels and `log.md` as a
`**Normalisation**` bullet naming the count, the documents touched and
that no other character is moved.

EXPOSURE, measured and not assumed: 0 of the 78 readable documents of
the K2 reference corpus carry U+00AD or any of the four real
zero-width characters (the 8 unreadable ones raise before extraction),
0 in the pinned K2 bundle's concept bodies, and 0 across
`tests/fixtures`, `examples`, `skills`, `docs`, `src`, README and
CHANGELOG. The door cannot have moved a byte anyone here has measured.

Suite 2179 passed, 1 skipped; ruff and `mypy --strict src/` clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 06:10:53 +02:00
68cfa905a3
test(extract): the soft-hyphen door in front of the persist gate (red)
Operator decision 2026-09-18, the alternative chosen verbatim: okf gets
ONE documented normalisation door before the guard, removing U+00AD and
BOOKING the count in the content accounting. The guard is not touched.

The defect it answers, PM's measurement: R761 Prosesskoden:2025 is
refused whole by guard 1.4.0 as `output:zero-width-present` -- an
any-tier carrier, `fail_secure` at every trust level -- over 71 U+00AD
and 0 real zero-width characters. The 71 are Norwegian hyphenation
points inside words. Reproduced here in the small on this tree before
any code moved: three soft hyphens in one markdown file give `0 of 1
extracted document(s) persisted; rejection codes: fail_secure 1` and
exit 1.

Five tests, each red on its claim:

- the source passes the DEFAULT gate, the accounting carries
  `normalised_soft_hyphen: 5` at both levels, and `log.md` says it;
- text preservation as an EXACT invariant and never a share: the
  extracted text of the hyphenated source EQUALS the extracted text of
  the same source written without them;
- the known-negative, U+200B, still `fail_secure` and still in the
  text -- removing a real carrier would be the guard's decision and
  would take a screen away from every consumer;
- a PDF's `source_pages` table is CHARACTER offsets rebuilt from the
  pages while the text comes back through the door, so both must be
  measured over the same rendering or every locator drifts;
- R761's own 71, on the delivery the decision was taken for.

U+00A0 NBSP is untouched: `_ZERO_WIDTH_CPS` is exactly {200B, 200C,
200D, FEFF, 00AD} on guard 1.4.0, read off the installed source, so
the guard has no rule about NBSP and nothing here needs one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 06:03:40 +02:00
280d6bd48e
fix(accounting): a corpus that measures no class says so in row 6 (H6)
`measures_no_class(units)` returns a sentence when EVERY document of a
corpus has no declared fate, and row 6 prints it beside that corpus's
tally. On N200 that is 16 549 elements of `u` which look like a finding
about the build and are a finding about the run: the build exits 2 on
0 proposed plans before the accounting door is reached, so no
accounting file exists to read a fate from.

The function's docstring carries what it would take, because that is a
capability and not a threshold: `.json` is read as generic JSON, and
the publisher's STS delivery in that form would have to reach the
markdown grammar `_extract_xml` writes for the XML one. Measured
2026-09-19, with the query validated against a known positive:
`standardContent` occurs 0 times in `src/` and 4 in the witness.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 06:00:51 +02:00
e6d9f16107
test(accounting): a corpus that measures no class says so (H6, red)
Reproduced 2026-09-19: `okf build` on N200 proposes 0 plans, prints
`FAILED - no segmentation plans` and exits 2 BEFORE the accounting door
is reached -- no accounting file is written at all. Every one of the
16 549 elements then lands as `u` with `no declared fates`, which reads
like a finding about the build and is a finding about the run.

Two tests and a known-negative, so a rule that calls every corpus blank
fails too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:57:56 +02:00
8b4f409aa5
fix(accounting): the skipped-row guard measures the machine, not the argument (H5)
`real_corpora(R761_DEFAULT, N200_DEFAULT)` instead of the parsed
arguments. Row 6 is SKIPPED exactly when the corpora the arguments
name are absent, so asking the arguments made the branch unreachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:57:02 +02:00
253c4fb988
test(accounting): a row skipped while its source is here exits 1 (H5, red)
The guard at the end of `main` asked whether the corpora named by the
ARGUMENTS are available. Row 6 is SKIPPED precisely when none of them
is, so the branch could never fire; PM read it as dead code and no test
covered it. The question it meant to ask is about the machine: a corpus
that is HERE and was pointed away from is a row that did not run, and
with `CI=1` the gate then printed a qualified GREEN and exited 0.

Two tests, so a guard that fires on everything fails too: with the
default source present and the arguments pointing elsewhere the run
exits 1 and names the row; with no corpus on the machine at all it
exits 0, because nothing to measure is not a row that did not run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:56:01 +02:00
d31ab73289
fix(witness): the JSON role map reads the publisher's own tags (H3)
Two edits, one line each. `count_sts_json` takes the tag through
`_local` the way `count_sts_xml` always has, and `_sts_role_json` gains
a `figcaption` clause beside the NISO-STS `fig/caption` one. The
`figcaption` clause is in the JSON map ALONE: the XML map has never
been measured carrying one, and the two maps are written twice on
purpose so row 5 can see a hole in either.

NOT ONE OTHER COUNT MOVES, and it is measured rather than argued.
Old rule against new over the eight JSON deliveries on this machine
and the three committed twins, role by role:

  N100-2022  figure_caption 0 -> 123
  N100-2023  figure_caption 0 -> 127
  N101-2025  figure_caption 0 -> 62
  N200-2024  figure_caption 0 -> 49,  math 0 -> 74
  N303-2026  figure_caption 0 -> 43,  math 0 -> 3
  N500-2024  figure_caption 0 -> 27,  math 0 -> 88
  N601-2022  no change
  R761-2025  no change  (row 5's pair, unmoved)
  the two existing twins  no change

N101 ships 574 `tbx:`-prefixed tags and not one of their local names
is a role, which is the known-negative the prefix rule is measured
against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:55:24 +02:00
37599a3758
test(witness): the JSON role map sees mml:math and figcaption (H3, red)
PM measured it on N200 Vegbygging:2024: 74 `mml:math` carrying 2 811
characters and 49 `figcaption`, and the witness counted 0 and 0. Two
independent causes and both are in this red:

- `count_sts_xml` takes every tag through `_local`, which strips a
  Clark-notation namespace AND a prefix; `count_sts_json` compared the
  raw string, so `mml:math` never reached `tag == "math"`.
- the publisher's JSON puts a figure's caption in `figcaption` under
  the `graphic`, not in the `fig/caption` NISO-STS writes. Measured
  over the eight deliveries on this machine: 430 `figcaption`.

Three tests. The JSON half of `sts-rikt.xml`'s family carries both
shapes as a committed fixture; the real N200 carries PM's four numbers
and skips where the corpus is absent; and the known-negative for
stripping a prefix is N101's 574 `tbx:` tags, whose local names name
no role -- a generalising rule measured against what it must NOT take.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:54:01 +02:00
a94b0c5d8c
test(accounting): X2 is killed -- a rejected document the bundle holds (H2)
The mirror of the `persisted` refusal, driven from both sides: a report
declaring `rejected` while a concept in the bundle names the document
is `invalid >= 1` with the note quoted, and the same declaration with
its elements booked as coded rejections and no concept naming it is
`invalid == 0` and `refused == 3`. Without the second arm the test
would pass on a gate that refused every rejected document.

Measured against the X2 mutant from the previous commit: rc 0 before
(98 passed), rc 1 after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:52:37 +02:00
a59898ee8f
test(mutants): X2 -- a rejected document the bundle holds (H2, red)
PM wrote this mutant outside the tree 2026-09-18 and it survived 155
green tests. Reproduced here on the mutant as committed: `98 passed`,
rc 0, the pattern applied exactly once.

`_document_unit` refuses a report that declares a document `rejected`
while a concept in the bundle names it -- one of the six refusals B-1
introduced, and the only one no test drives from either side. The check
works; nothing stops the next refactor removing it. With H4 in place
the harness now exits 1 on this survivor, so the red is the harness's
own verdict and not a line in its output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:51:19 +02:00
f5b263f5ef
fix(mutants): a surviving mutant exits 1 (H4)
`verdict(survived, errors)` is the one place the run's exit code is
decided: 2 when a mutant could not be applied (it was never measured,
and that outranks everything), 1 when one survived, 0 otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:49:59 +02:00
6abe4b4358
test(mutants): a surviving mutant is not exit zero (H4, red)
The harness returned `2 if errors else 0`, so a run printing `killed 0
of 1` and naming its survivor exited 0. PM measured it on a copy
carrying only the X2 mutant. Red on the claim: the module imports, and
offers no verdict to ask.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:49:37 +02:00
d27ca503c8
fix(accounting): a document refused whole is never clean, and the run says how many (H1)
The gate gains a FIFTH column, `Unit.refused`, and it is the only one
that is not a defect in the report: the elements of a document the
build read and persisted nothing of. Their fate is declared honestly,
so `unaccounted` and `double_booked` both stay 0 -- which is exactly
why nothing else could see the loss. `refused_whole` asks its question
only for a corpus that persisted NOTHING, so one refused source beside
an accepted one, the ordinary case on a heterogeneous corpus, reached
row 3 as clean.

Row 3's reason now carries `N element(s) lost with R of D document(s)
refused whole`, and each unclean unit's detail line carries
`refused=` beside u, d, unverified and invalid, with the document's
own rejection code in the note.

On the build side `Accounting.refused` is written into the JSON and
into the `**Accounting**` bullet of `log.md` as `R of D document(s)
refused whole`. The exit code is NOT moved: it belongs to the whole
run, and a corpus holding one unreadable file among many is ordinary,
so the order's other half -- state it in the accounting -- is the one
taken. `okf build` still exits 1 when it persisted nothing at all.

`test_a_corpus_refused_whole_under_the_default_gate_is_red` kept its
point and lost its premise: the numbers still balance, and that is now
asserted as u = 0 and d = 0 rather than as a clean unit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:48:57 +02:00
5d1060d467
test(accounting): a document refused whole is never clean (H1, red)
PM's checkpoint of ed8d9d7 measured it: an inbox holding one refused
document and one accepted one gave `clean = 4 of 4`, `refused_whole =
None` and `okf build` exit 0, while three elements of `avvist.html`
were gone unseen. `refused_whole` asks its question only when the
corpus persisted NOTHING, and a refused document's own unit balances
by construction -- every element booked as a coded rejection is u = 0
and d = 0.

Three tests, all red on the claim and not on an import:

- the unit of a document the build refused is not clean, and its note
  names the code;
- the same beside a PERSISTED neighbour, which is the ordinary case on
  a heterogeneous corpus, with row 3 RED and the source named in the
  detail line;
- `okf build`'s own report says how many of M documents it refused
  whole -- the exit code belongs to the whole run, so the count has to
  stand in the accounting and in `log.md` or the loss is silent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 05:44:54 +02:00
3b3b8ae0ca
fix(assets): budget every link by what its decoder COSTS (0.10.1)
Round 3 of the 0.10.1 review, and the finding is the pattern the three rounds
share: each bound an OUTPUT, and the bomb stepped one link along. The
declared size, then the first `FlateDecode`, then every `FlateDecode` -- and
then a link this package had documented as safe.

`ASCII85Decode` was classed as bounded "by its own input because it shrinks".
It quadruples: `z` is the shorthand for four zero bytes. And the output was
never the cost -- `base64.a85decode` appends one 4-byte object per group to a
list, about a hundred bytes of memory per byte of INPUT (101.4x at 1 MiB,
96.1x at 4 MiB, 94.5x at 16 MiB on CPython 3.14).

Paired subprocesses, idle machine, both sides from PINNED trees, the document
built once by a third process and read from a file because `ru_maxrss` never
falls and `b"z" * 64 MiB` alone costs 171 MB:

  [/Fl /A85]      z x 32 Mi  33 475 B   CARRIED 3 261 599 744 -> too_large 42 070 016
  [/Fl /A85]      z x 64 Mi  66 090 B   CARRIED 6 461 558 784 -> too_large 40 280 064
  [/A85]          z x  8 Mi   8.4 MB    CARRIED   933 085 184 -> too_large 62 484 480
  [/Fl /A85 /Fl]  z x 32 Mi  33 488 B  samples_invalid 3 519 180 800 -> too_large 43 438 080

The picture was CARRIED in three of the four: not a bound that fired late, no
bound at all. Doubling the `z` run trebles the old cost and leaves the new one
where it was.

WHY THIS FORM. `assets.MAX_FILTER_DECODE_BYTES` (512 MiB) is what decoding ONE
link may cost -- a separate number from `MAX_IMAGE_BYTES`, because that one
bounds the picture and this one bounds producing it. `FlateDecode` is measured
as it is paid; every other permitted filter carries a MEASURED cost ratio
(`assets.PDF_FILTER_COST_RATIO`) checked against its input BEFORE its decoder
is called, since those decoders take a whole string and return a whole string.
A filter with no ratio is refused unread. The budget TRAVELS: a deflate link
is inflated under the smaller of the picture's bound and what the next link's
decoder may be handed, or `[/Fl /A85]` pays 256 MiB for a refusal.

A chunked ASCII85 decoder written here was the alternative and was FELLED: it
would bound `_check_stream_cost` and not the run, because `stream.get_data()`
decodes the whole chain again with pdfminer's own decoder, and it would make
this package rather than pdfminer the authority on an image's bytes. The cap
is the only number that bounds that. `resource.setrlimit(RLIMIT_AS)` was
MEASURED before anything was built on it, as the order required, and is not
usable: Darwin 26.6.2 raises `ValueError: current limit exceeds maximum limit`
and does not enforce it. No child-process cap exists.

THE CAP IS READ OFF THE CORPORA, the posture `MAX_IMAGE_PIXELS` has: over the
9 668 image objects of the 77 PDFs on this machine, 16 decode through an
ASCII85 link and the largest input to one is 450 739 bytes, against a cap of
about 5.0 MB.

A PROPERTY TEST REPLACES THE LIST OF KNOWN SHAPES: every chain of length 1-3
over the ten filters pdfminer decodes, 1 110 of 1 110, both payload fills,
each delivered under the bound or refused with a published code and never paid
for on the way (`tracemalloc`, which counts allocations and is not disturbed
by load). Known-positive beside it: 258 of 258 chains over the permitted
filters still carry a small image.

MAJOR -- the backstop had no test. `check_payload` at the end of
`_check_stream_cost` could be deleted with the whole suite green, because the
second one after `get_data()` gives the same code one step later. The two
differ in whether the payment was made, so the test asserts `get_data` was
never called.

10 OF 10 MUTANTS KILLED, control green, each killer named in the report. Four
survived a first pass and two tests exist because of it.

NOT ONE PICTURE CHANGES HANDS, MEASURED BY NAME: `_pdf_images` over every PDF
on this machine from both pinned trees -- 9 306 -> 9 306 carried over 77
files, 50 -> 50 on R761, 0 of 78 files moving a count and 0 moving a code.
R761 also settles a question raised while this order was open: 50 objects, 29
[/DCTDecode], 21 [/FlateDecode], 0 ASCII85 links -- so round 2's count of 580
`[/FlateDecode /ASCII85Decode]` objects is reproducible from nothing on this
machine. It changes no decision; a bomb shape does not need a corpus.

Version stays 0.10.1, no tag. README, CHANGELOG, CLAUDE.md and errors.py
corrected TO what the code does; the round-2 report carries a correction block
rather than a rewrite. Report:
docs/2026-09-18-utgangsbudsjett-per-ledd.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 19:00:38 +02:00
33d3269380
test(assets): the cost of a link, and a property over all 1 110 chains
Round 3 of the 0.10.1 review. Rounds 1-3 each bound a NUMBER and the bomb
moved to the next one: the declared size, then the first `FlateDecode`, then
every `FlateDecode`. It now lives in a link this package had documented as
safe. `ASCII85Decode` was called bounded "by its own input because it
shrinks"; `z` is ASCII85's shorthand for four zero bytes, so one input byte
becomes four, and `base64.a85decode` appends one 4-byte object per group to a
list, so the DECODER costs about a hundred bytes of memory per byte of input
(measured on CPython 3.14: 101.4x at 1 MiB, 96.1x at 4 MiB, 94.5x at 16 MiB).

Measured on the pinned `0c3c490` tree, its own interpreter, idle machine: a
33 475-byte PDF decoding an image through `/Filter [/FlateDecode
/ASCII85Decode]` cost 3 827 003 392 bytes of peak RSS -- 114 000x the file --
and the picture was CARRIED, with no rejection at all.

Seven tests red, three green:

* the two bombs above, in their own interpreters, at the shipped bound;
* the cost ratio as a re-measurable known-negative, so the constant cannot
  rot the first time CPython changes `a85decode`;
* the input cap against the corpora (9 668 image objects over 77 PDFs, 16
  behind an `ASCII85Decode` link, largest input 450 739 bytes);
* THE PROPERTY, both payload fills: every chain of length 1-3 over the ten
  filters pdfminer decodes -- 1 110 of them -- is either delivered under the
  bound or refused with a code in the published vocabulary, and never paid
  for on the way, which `tracemalloc` measures because that is where
  `a85decode`'s cost lives.

Green but previously uncovered, which is the MAJOR of the same checkpoint:
`check_payload` at the END of `_check_stream_cost` could be deleted with the
whole suite still passing, because the second one after `get_data()` gives
the same code one step later. The two differ in whether the payment was made,
so the test asserts `get_data` was never called. The known-positive beside
the property -- every bounded chain still carries a 64-byte image -- is green
too, and a rule that refuses everything would pass the property alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:20:51 +02:00
0c3c4904ee
fix(assets): bound every link of the filter chain, and cover the backstop
The two findings of the 18.09 PM checkpoint of `0f308c1`. Red tests landed
first in `3b587ea`; this is what turns them green.

BLOCKER -- `_check_inflated` read `filters[0]`, measured that one link and
returned, which is not a bound: a PDF decodes a stream through a LIST of
filters. Measured in paired subprocesses from two pinned trees, idle machine:

  [/FlateDecode]        400 MB  408 516 B   59 232 256 ->    62 017 536 B
  [/FlateDecode x2]     400 MB    1 636 B  886 554 624 ->    52 367 360 B
  [/FlateDecode x3]     400 MB    1 070 B  889 393 152 ->    61 390 848 B
  [/FlateDecode x2]     1,2 GB    2 927 B  2 567 204 864 ->  60 403 712 B

542 000x the file at two links, and the picture WAS refused at the end -- by
`check_payload` after `get_data()`, once the memory was spent. The single-link
row is the control and does not move. It also left the 16 corpus objects behind
an `[/ASCII85Decode /FlateDecode]` chain unmeasured, since `filters[0]` is not
`FlateDecode` there.

`_check_stream_cost` walks every link. THREE CLASSES and no fourth
(`extract.bounded_pdf_filters`, pinned by a test): `FlateDecode` MEASURED, a
link with another expanding link behind it inflated under the same bound and
handed on; `ASCII85Decode`/`ASCIIHexDecode` bounded by their own input because
they SHRINK; `DCTDecode`/`JPXDecode`/`JBIG2Decode` PASS THROUGH. Everything
else -- `LZWDecode`, `RunLengthDecode`, `CCITTFaxDecode`, `/Crypt`, anything
written later -- is refused UNREAD with a new code `asset_pdf_unbounded`,
decided before the FIRST link is decoded so a document cannot make the run pay
for the links in front of the one we cannot bound. An encrypted stream is
deciphered and then measured, where `stream.decipher is not None` used to
return unmeasured; 0 of 5 142 objects here are in an encrypted document, which
is why nothing caught it.

NOT ONE PICTURE CHANGES HANDS, AND IT IS MEASURED BY NAME. Every PDF on this
machine -- 78 documents, K2 in both trinn1 and trinn2, the shipped fixtures and
R761 -- run through `_pdf_images` page by page from both pinned trees:

  images carried          9 356 -> 9 356
  documents losing one              0 of 78
  documents gaining one             0 of 78
  asset_pdf_unsupported     322 -> 314
  asset_pdf_unbounded         0 -> 8

The 8 are the 4 `CCITTFaxDecode` stencil masks (`/ImageMask true`,
`/BitsPerComponent 1`), counted twice because trinn1 and trinn2 hold the same
document. They were refused before and are refused now, one step earlier and
under a code that says why.

MAJOR -- `check_payload(len(data))` after `get_data()` is the counted refusal
four documentation surfaces point at, and deleting exactly that line passed all
2 132 tests. It is reachable through a stream pdfminer has ALREADY decoded
(`decode()` sets `rawdata` to `None`), which is now the ONLY case outside the
bound and has a test.

Eight mutations, one line each, every one DEAD, with the unmutated tree run
first as the control: first-link-only, loop dropped, inequality reversed,
encrypted skipped, backstop deleted, unknown filter passed through,
intermediate link not carried forward, whole check removed.

`tools/okf_accounting_gate.py` gains one line, the new code in
`REJECTION_CODES` -- what a rejection code requires and nothing more. Gate
unchanged: exit 1, GATE RED rows 2, 3, 6. Version stays 0.10.1, untagged.

Suite after `git add` against a clean tree: `uv run pytest -q` ->
2152 passed, 1 skipped (226 s). ruff, ruff format --check, mypy --strict clean.

Report: docs/2026-09-18-filterkjeden-og-backstoppen.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:34:07 +02:00
3b587ea567
test(assets): the chain and the backstop, written red first
Eight tests for the two findings of the 18.09 PM checkpoint of `0f308c1`.
Seven are RED here and one is green-but-uncovered; the fix is the next commit.

BLOCKER -- `_check_inflated` read `filters[0]`, measured that one link and
returned, so a stream decoded through `/Filter [/FlateDecode /FlateDecode]`
was bounded by nothing. Reproduced here in its own interpreter, same commit:

  links=1  408 516 B file ->  77 971 456 B peak RSS  (bounded, 0.10.1)
  links=2    1 636 B file -> 889 573 376 B peak RSS  (unbounded)
  links=3    1 070 B file -> 888 401 920 B peak RSS  (unbounded)

543 000x the file size at two links, and the image IS refused at the end --
by `check_payload`, after the memory has been spent. PM measured the same
shape at 1 606 B -> 835 MB and 2 839 B -> 2 439 MB.

The chain is not a hypothetical. Over the 5 092 image objects of the 78 PDFs
on this machine (measured 2026-09-18): 1 625 `[/DCTDecode]`, 2 215
`[/FlateDecode]`, 596 `[/FlateDecode /DCTDecode]`, 580 `[/FlateDecode
/ASCII85Decode]`, 40 unfiltered, 16 `[/ASCII85Decode /FlateDecode]`, 16
`[/JPXDecode]`, 4 `[/CCITTFaxDecode]`. So refusing every chain would cost
1 192 real pictures, and bounding only the first link leaves those 16
unmeasured -- `filters[0]` is not `FlateDecode` there, so nothing ran at all.

Two of the cheap tests assert WHICH check fired ("decompresses to more than"),
because the backstop refuses the same document by code and a test reading only
the code is green on the defect.

MAJOR -- `check_payload(len(data), name=name)` after `get_data()` is the
counted refusal that four documentation surfaces point at, and deleting
exactly that line passed all 2 132 tests. The path that reaches it is a stream
pdfminer has ALREADY decoded (`decode()` sets `rawdata` to `None`), so there is
no raw stream left to measure. That test is green here and RED under the
deletion, measured before this commit: `asset_too_large` -> `asset_pdf_unsupported`.

Each red test carries a known-positive beside it: the two chains the corpora
hold still deliver their 64x64 picture, and an already-decoded stream under
the bound is still read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 14:15:31 +02:00
0f308c1f56
fix(assets): bound what the run pays, not what the document claims (0.10.1)
A second independent review read `230d1cb` -- the commit that closed the
`v0.10.0` review's two MAJOR findings -- and found one of them open. The
bound read `/Width` and `/Height`, which an untrusted document writes, while
`get_data()` pays for the stream beside them; `/Length` is the COMPRESSED
length and the two numbers are independent.

Re-measured here on `ed8d9d7` before anything changed, in its own
interpreter: a 408 516-byte PDF declaring 1x1 and carrying 400 MB of deflated
zeros was CARRIED, no rejection, 891 904 000 B peak RSS. After: 0 carried,
`asset_too_large`, 57 065 472 B. At 1,2 GB of zeros, 2 436 MB -> 64 569 344 B
-- the cost no longer scales with the bomb. End to end through the CLI with
the shipped defaults: 838 000 640 B and an asset written -> exit 0,
79 650 816 B, `0 carried of 1 found`, no `assets/`.

Three numbers are bounded now, not one: what a container DECLARES, what a
carried FILE measures (`read_image`, so a 49 MP PNG of 47 705 bytes is not
passed on to a consumer), and what a PDF stream DECOMPRESSES to
(`assets.inflated_size`, a chunk at a time, output discarded, before
`get_data()`). The limit is stated rather than implied: the stream
measurement runs where `FlateDecode` is the first filter and the document is
not encrypted; every other chain is a check on the decoded length AFTER the
decode, a counted refusal and not a bounded one.

A non-positive declared dimension is `asset_size_invalid`, its own code,
raised before the stream is read. `-1 x 40000000000` is a NEGATIVE pixel
count, under which every `>` bound read as satisfied, so the check returned
silently and the refusal arrived from `encode_png` as
`asset_samples_invalid`. Its own code because a publisher shipping a picture
bigger than this package carries and a dictionary written to be read wrong
are different facts about a document.

Two smaller findings in the line that says what is missing, both introduced
by the first fix: the address was written twice, once bare, and a linkifying
renderer autolinks a bare URL -- written once now, in one code span; and
`label` became a dead parameter, so the figure's caption was dropped, a
regression against 0.10.0. It is written again in the `-- <label>` form a
carried pointer uses.

Version bumped to 0.10.1 across all ten places. Nine were unbound and stale:
four README install lines naming the previous release, two prose lines, the
"current tag" entry, `uv.lock`, and a CHANGELOG whose 0.10.1 content sat
under `[Unreleased]`. Two new packaging tests bind them to `__version__`, and
the README's guard tag to `[tool.uv.sources]`.

Every test was red first. The fate of every image is identical with and
without the new bound on three K2 PDFs carrying 800 images (464/464, 311/311
with the same 12 rejections, 25/25), and the second inflate is below the
noise floor there. 0 shipped artifacts move: no bundle under `examples/`,
`skills/` or `tests/fixtures/` carries an image pointer at all, measured
against a known-positive control.

`asset_too_large` was undocumented in the error registry; both codes are
there now. `tools/okf_accounting_gate.py` gains the new code in its closed
list -- one string, no behaviour change, stated because that file belongs to
another order.

Suite 2141 passed / 1 skipped, ruff + format + mypy --strict clean.
Report: docs/2026-09-18-bildestien-holder-0-10-1.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:41:18 +02:00
ed8d9d709f
test(accounting): row 6 sees a refusal, a second real corpus, and 34 of 34 mutants
Row 6 was GREEN with R761 100 % rejected: every element of a refused document
is booked as a coded rejection, so u = 0 and d = 0 and the bundle is empty.
`refused_whole` asks that question on its own now -- the build order asked for
an honest red there, and PM re-measured the green on 2026-09-18 with
`okf build` exiting 1 unseen.

A skipped row no longer leaves the verdict unqualified (`GATE GREEN (row 6 not
run: ...)`), and the exit code is non-zero locally when a corpus source is on
the machine and its row did not run. m-2.

N200 Vegbygging:2024 joins R761 as a second real corpus. R761 holds 0 `fig`,
0 formulas and 0 references, so the only real corpus could not have found the
hole in the STS role map; N200 carries 194 citations, 49 figures and 135
footnotes. A `.json` whose root holds an STS node tree is counted as STS
rather than as keys and leaves -- the container is not the content.

M-4: the review's 26 mutants, ported to the code as it stands, plus 8 for the
new checks. 34 of 34 killed. `tools/okf_gate_mutants.py` runs on a copy of the
tree, and a pattern that does not match is an ERROR and exit 2 -- a mutant
that could not be applied was never measured. That fired once, on M13, after a
refactor moved the line it edits.

m-3: `APPROVED_EXCEPTIONS` was read by no row, so approving one changed
nothing. Each pair is now checked against the witness's own vocabulary and the
run says why it moves no denominator. The gate also prints its OWN limits
beside the verdict, m-5 among them.

The product's accounting tests state the new truth instead of the old one:
`okf build --accounting` over the fixture corpus exits 1 with SIX unaccounted
elements in its own vocabulary -- its first real finding, reachable only now
that fixtures carry the constructs. Four shared element names disagree with
the witness, each pinned with its cause; one of the four is a double count
this package makes (a text box's paragraph, once inside the box and again in
the paragraph carrying it).

Three fixture defects were found and fixed while building them, each of which
would have reported a loss the build never had: a shared string table not
related to the workbook, a `graphicData` with no `uri`, and an odt
`styles.xml` without `<office:styles/>`.

Report: docs/2026-09-18-regnskapsgaten-herdet.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 02:48:34 +02:00
e5dc21ec2f
test(accounting): the witnesses see what the formats actually hold (M-1..M-3)
Rows 2 and 3 require the build's inventory to EQUAL the witness's, so what
the witness does not count, nothing can lose visibly. An independent review
put a header and a comment in a docx, measured 0 of either in the bundle,
and the accounting still read "2 of 2 carried".

Thirteen classes are now counted, each with a red test written first:
docx header/footer, comment, endnote and text box (a box's paragraphs are
its own, or the text is booked twice) - pptx speaker note and hidden slide
(`show="0"`, no longer counted as an ordinary slide) - xlsx formula and
hidden sheet (the state lives in `workbook.xml` and is reached through the
relationship id, so the sheet part itself says nothing about it) - odt
header/footer from `styles.xml` and annotation (counted as prose, it made
the accounting demand a reader carry a note the author wrote to themselves)
- STS `mixed-citation`, `mml:math`, `fig` and its caption, measured by the
review at 4.1 % of N200's source text and 3.9 % of N100's.

M-2: the two STS witnesses had ONE role map between them, so row 5 -- "two
witnesses agree" -- could not see a hole in it. `_sts_role_xml` and
`_sts_role_json` are written apart, each for its own delivery, and a test
holds them apart.

M-3: 20 of 63 element types had a count of ZERO in their only fixture. Seven
hand-built documents close it, every element type now occurs at least once
(a test asserts it), and ALL TWENTY documents carry a hand count read off
the fixture's own bytes (four did before). `.xlsx image` -- the operator's
own proposed exception -- could not be exercised at all until now.

Every witness also states WHAT IT STILL DOES NOT COUNT, per file type, and
the gate prints that list on every run.

THE FIXTURE ROWS ARE RED NOW, AND THAT IS THE POINT. Row 2 red on .docx,
.odt, .pptx, .xlsx and .xml; row 3 at u = 25, d = 2 over the new classes,
including a footnote and four spreadsheet cells the build genuinely drops.
`0 claimed and not found` on the same run: nothing the build DOES book as
carried failed the bundle check, so the red is the build's and not the
instrument's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 01:53:50 +02:00
656cbe5d02
test(accounting): the gate opens the bundle itself (BLOCKER B-1)
An independent review of `0b00de4` found the judge was a calculator over a
report the judged writes: `account()` compared BOOKED NUMBERS with the
witness's counts and never opened a concept file. Reproduced here first --
a report that changes not one byte of the bundle and books every element as
carried gave `GATE GREEN`, exit 0, and so did booking every element as
rejected.

The witness now gives every element THE PIECES OF TEXT IT IS MADE OF, and
the gate looks for each of them in the concept bodies the run wrote. Pieces
and not one joined string: a reader writes a heading's marker and a
picture's pointer block between the parts of a container, so a section is
never one contiguous run even when every word of it is there.

Also in the judge, each with a test driving it from both sides:

- a negative booking, a document declared persisted that no concept names,
  a document declared rejected that the bundle holds, a rejection code
  outside a closed list, and an `accounting_version` the gate does not read
  are each REFUSED rather than summed;
- a document the build PERSISTED whose report carries nothing from it is
  never clean ("everything rejected" was);
- an asset proves a carry only when its BYTES hash to the source's and it
  stands under the name the layout gives it. The check was a name check, so
  a zero-byte file called `<sha12>-x.png` read as a carry (m-1).

NOT ONE ELEMENT COUNT MOVED: the 13 fixture documents' counts are identical
before and after, so this commit changes what the gate CHECKS and nothing
about what the witness counts. `texts` is additive in the committed fasit.

The rtf text scanner reads `\uN` escapes and skips `{\fonttbl}`-class
groups, or a fixture's font table reads as the first paragraph of its prose;
xlsx cell text is resolved through `sharedStrings.xml`, where a
spreadsheet's words actually live; a PDF page carries its own text lines,
which no row could see before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 01:40:48 +02:00
751441c17d docs(accounting): record the operator's two decisions of 2026-09-17
The content accounting stays OPT-IN until the losses it reports on the
reference corpus are fixed; a default-on door would fail builds that pass
today. Of the three exceptions the gate proposed, only the PDF one
(heading, paragraph, table) is approved. Approving it moves no number,
because no witness counts a heading in a PDF: what it changes is that the
gap is a stated limit of the instrument rather than an open question
about the build. An image in a workbook, or in md/txt/csv/json/odt/rtf,
stays unaccounted and therefore stays red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 00:37:04 +02:00
230d1cbccd fix(assets): a remote reference is inert and a declared size is bounded
Two MAJOR findings of the independent v0.10.0 review, both with the
shipped defaults, both new in 0.10.0. Repros rebuilt as tests first.

- A remote <img src>/xlink:href became a LIVE markdown image link in the
  persisted concept, with the address and query string chosen by whoever
  wrote the document. Extraction opens no socket; a consumer rendering
  the bundle does. Now inert text with the address in a code span,
  pinned by a property over the readers rather than by one string. The
  tier asymmetry (user-upload refuses, trusted-source persisted) went to
  the guard repo with the repro.
- Nothing bounded a declared image size: 9.6 KB of PDF declaring
  3000x3000 grayscale zeros took 83 MB peak RSS, linear in pixels.
  MAX_IMAGE_PIXELS (40 000 000) and MAX_IMAGE_BYTES (256 MiB) are read
  off the corpora (largest measured 18.6 MP on K2, 1.4 MP on R761) and
  checked on what the container declares, before any decompression;
  over them is asset_too_large, counted. The same bound closes the
  inline data: URI, which the review flagged and did not measure.

Also fixed, added by PM to this order: an inline PDF image was named
from id() of a Python object, so two concept files of the reference
corpus differed between builds. It is now named from its position.

R761 unchanged: 50 carried of 50 found, assets diff -rq clean.

Report: docs/2026-09-17-bildestien-0-10-1.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 00:32:50 +02:00
864570b320 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>
2026-09-17 18:35:20 +02:00
0b00de4408 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>
2026-09-17 15:40:37 +02:00
bc39e8091f feat(assets): a bundle carries the images its sources declare (0.10.0) v0.10.0
Until now no reader in this package fetched, named, described or copied a
single image. `<img>`'s attributes were never read, a NISO-STS `<graphic>`
was walked past, a PDF was opened for its text alone, the converter's
markdown writer dropped every picture, and the only writer into a bundle
took `content: str`. The two lossiness warnings said so on every run, which
made the loss honest and did not make it smaller.

Measured on R761 Prosesskoden:2025, published as a 701-page PDF and as a
NISO-STS delivery: the process text is carried in full while 12 `Tabell N-N`
and 9 `Figur N-N` captions stand over nothing, because that publisher ships
those tables as raster pictures in both. Process 84's "toleranseklasse ...
er gitt i tabell 84-2" points at empty space.

THE GATE WAS WRITTEN FIRST AND RED. `tests/test_asset_gate.py` reads its
denominator out of the source (`page.images`, `word/media/`, `ppt/media/`,
`<img`, `<graphic`), never from a constant here. Measured at 332961a, built
from `git archive` and not from the editable tree: carried 0 of 8 local
images across 5 documents (9 declared), and no `assets/` at all. After: 8 of
8, with the ninth a remote source carried as a pointer without a file.

FIVE READERS PLACE, ONE MODULE DECIDES. `assets.py` owns what an image is
(sniffed from the bytes, never from the claimed extension), what it is
called (`<sha256[:12]>-<the source's own basename>`) and how it is pointed
at (one two-line block, one regex). `.xlsx` is deliberately not a row: a
block inside its pipe tables would break the `source_rows` locator, and 0 of
4 K2 workbooks hold media.

A PDF stream that is already a file is carried VERBATIM (29 of R761's 50
objects are DCTDecode); raw samples are encoded to PNG with stdlib zlib, so
no new dependency. Rendering the page region was the alternative and was
felled on determinism: a rasterised crop's bytes, and therefore the asset's
content-addressed name and the bundle's digest, would depend on the
installed rasteriser. What the encoder cannot express exactly is refused
with a code and counted, never approximated.

NO SIZE FLOOR, and that is a measurement: over the 4 828 image objects of
the K2 corpus the size distribution is a broad spread with no gap, unlike
OCR_CID_SHARE's bimodal one, so a threshold would be a number we chose.

ON BY DEFAULT, AND THE CONTROL IS TWO WHOLE BUILDS. The 43-document
reference corpus at 332961a versus rebuilt at HEAD with `--no-assets`:
865 files on both sides, `diff -rq` reports ONE difference, the added
`Images: NOT CARRIED` line in log.md. Every concept byte-identical.
Against the default: 453 -> 454 concepts, 865 -> 867 md, 0 -> 2 964 assets
(2 964 carried of 3 145 found, 4 622 pointers), 4.7 MB -> 115 MB, 2 414 s ->
3 088 s, peak RSS 6.26 -> 8.74 GB, 422 of 865 md files differ. The one new
concept has a measured cause: the pointers are body text, so a section
holding 146 of that document's images grew from 19.0 % to 30.6 % of the
extracted text and crossed `--outline-gate`'s 0.20 share clause.

THE IMAGE BYTES ARE NOT SCREENED. The guard is text-only, the pointer block
passes the gate as body text, the picture beside it passes nothing, and
log.md says so on every run.

Also fixed, both found by measuring rather than by reading:

- a markdown image is no longer read as a cross-reference. `structure._LINK`
  never looked at the character in front of the bracket, so every pointer
  would have arrived in the index as an edge to a concept that cannot exist.
- Door C carries the assets its merged concepts point at. Before this,
  importing a bundle built with `--assets` merged 6 of 6 concepts and wrote
  no `assets/` at all, so every pointer named a missing file.

Report: docs/2026-09-17-bilder-i-bundlen-trinn1.md
Spec proposal: docs/plan/okf-assets-section-6-4.md
Suite 1 955 passed / 1 skipped (from 1 896), ruff and mypy --strict clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 10:01:31 +02:00
332961a19c fix(gate,propose): okf build runs a real guard; a code fence declares no structure
Two defects reported from outside by claude-code-llm-wiki (order
20260915T202332Z-228694739), both reproduced against this tree before
anything moved.

F1 -- the packaged CLI never ran the guard. corpus.measure wired an
unconditional approve-everything stub into process_inbox and 0 of 90
add_argument calls named a gate, so the one path most people use screened
nothing while pyproject.toml made the guard a mandatory runtime dependency
and the README recommended a composition the command line could not reach.

  --gate takes guard-trusted-source (default), guard-user-upload or none.
  corpus.resolve_gate is the one name->callable map, with the guard imported
  lazily so importing the package still does not pull it in; an unknown name
  RAISES rather than falling back, because a fallback reproduces the defect
  with an extra step. The gate's NAME goes into the section 9 log.md -- a
  stub is only dangerous when nothing downstream can see it -- and --gate
  none renders NOTHING WAS SCREENED.

  The default was chosen on a measurement: over the 453 concept bodies of
  the pinned reference bundle, PRESET_TRUSTED_SOURCE persists 453 of 453 and
  PRESET_USER_UPLOAD holds 1, costing that concept's whole source document.
  Neither tier waves anything through -- an invisible carrier and a CRITICAL
  finding fail secure at both. Door B's library default is UNCHANGED at
  PRESET_USER_UPLOAD: an inbox drop is an untrusted upload, an operator
  pointing this command at their own folder is not. The second tier ships as
  guard_adapter.inbox_gate_trusted_source, the three-line adapter that
  module's docstring already described, never a preset parameter.

  process_inbox(segmentations=..., gate=inbox_gate) now has a test. Before
  this, `grep -rl inbox_gate tests/` gave 1 file with 0 occurrences of
  `segment` -- the recommended composition was untested, which is how the
  defect survived.

F2 -- a fenced code block declared structure. `# Use the opus[1m] alias`
inside a ```bash fence became a level-1 ATX heading: the document was
refused entirely where the line carried [ or ] (5 of 191 pages of the
reporter's corpus), and the concept TITLE came from somebody's shell session
on 62 of 191 (32.5 %). The fix is in the proposer and never in Door B's
title rule -- that rule is right, and a heading that was never a heading is
what has to stop being proposed. propose.fenced_lines is computed once per
text and no rule reads a fenced line, including Arm D's outline RUN, which
selects from the whole line list. Backtick and tilde fences, three leading
spaces, a closing fence at least as long as its opener, and no backtick in a
backtick fence's info string -- that last one keeps a line holding only
`okf build` from silencing a document.

MEASURED ON THE BYTES, and this is the number that decides: the 43-document
reference corpus built at b6da09c (from git archive, never the editable
tree) and rebuilt at the shipped defaults differ in log.md alone, by the one
added bullet. 865 concept files on both sides, every concept byte-identical.

Found by that control and NOT caused by this work: the pinned artifact
K2-bundle-default-20260912 was written 2026-09-09, two days before ed0418f
changed title: quoting, so it differs from what HEAD produces on 42 concept
files. test_default_bundle_pin stays green because it pins the count and the
hit@8 ranks, not the bytes. Re-pinning is the operator's call.

Suite 1896 passed / 1 skipped (+27 from 1869). ruff, ruff format and mypy
--strict clean. No version bump, no tag, no push.

Report: docs/2026-09-15-f1-f2-gaten-og-kodefencen.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 00:19:50 +02:00
b6da09cc97 feat(quality): okf quality --fasit, boundary recall against a declared structure
The bundle-only gate returned UNMEASURED and exit 3 on the very arm it was
built for: no metric computable from a bundle alone reaches boundary recall.
`boundary_share` -- declared boundaries that became a concept, over declared
boundaries -- is the one metric measured that orders the arms correctly, and it
needs the publisher's own structure, so it arrives as an input.

Measurement first, threshold after, which is what the order asked for.

P1, the normalisation, derived rather than guessed: stripping all whitespace
and lowercasing reproduces the fasit's own `norm` from its own `title` on
2 761 of 2 761 rows (alphanumerics-only scores 58). P1's own bar is 99 % on the
known-good arm and the literal reading of it reaches 22 of 2 761 -- not because
the normalisation is wrong but because okf's default route moves the numbering
token a publisher glues into a heading over into the concept id. The pair form
(concept's own directory, residual title) reaches 2 737, either reaches 2 759
(99.9 %). Both forms ship and neither is a fallback: `r761-2025-d1` is the
control in the opposite direction at 2 727 literal, 0 paired.

P2, the single corpus, is in the OUTPUT and not only in the document: the bar
is declared `corpora = 1`, every boundary row prints `N = 1 corpus`, and the
line states that `--fasit` is the caller's ASSERTION that this bundle is a
build of the document the fasit describes -- the posture `okf consume --ref`
has. Measured: the K2 reference and `n100-2023` score 0 of 2 761 and read FAIL,
which is the assertion being wrong rather than the bundle being bad.

One bar, at the pinned artifact's own value, 2 759/2 761. It is tight and the
cost is published rather than tuned away: 2 of 4 R761 builds fall under it
(2 752 and 2 727), while any bar between 41.6 % and 98.8 % separates the
known-bad arm from every R761 build measured. The known-bad arm
(`860019-mdb-100`) is 1 148 of 2 761 -- FAIL and exit 1, where the bundle-only
gate gave exit 3.

A fasit is validated at the door: not a list, a row missing `title` or `norm`,
or anything that is not JSON exits 2 with the reason, never a quiet UNMEASURED.
A fasit under five rows is UNMEASURED -- the document floor in the fasit's own
unit.

Without `--fasit` the command is byte-for-byte what it was, held by a test.
`okf check` is untouched; no version bump and no tag. 17 tests red on
assertions before the implementation, the two new doc pins each driven red and
back. Suite 1 869 passed / 1 skipped / 1 870 collected (base 5e5d01c: 1 851).

docs/2026-09-12-g37-terskler.md SS 7 carries the premises re-measured, the
seven bundles, the interval any bar could sit in, and the honesty limits --
including the correction of SS 2's own grep claim, which went false in the
commit that wrote it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 07:27:33 +02:00