Chose the side channel over neutralising pointer-shaped document text,
because the second fix changes what every document SAYS in order to
defend a tool outside the build: a source quoting a bundle listing would
come out altered and existing bundles would move bytes. This reads a
file the run already writes.
`assets.conversion` names the pair, `DocumentAssets.conversions` carries
it out of the run, `DocumentAccount.conversions` books it, and the
accounting JSON states it per document. `_declared_conversions` reads it;
`_conversions` now believes a pair only when the RUN booked it AND a
pointer block confirms it for the asset it names. The confirmation can be
forged and the ledger cannot, which is why the ledger decides.
Measured through the real `okf build`: the three arms PM reproduced
(two `<p>`, one `<p>` with `<br>`, a markdown note beside the carrier)
go forged -> refused, 3 of 3, with the known-positive True in all three.
The text-level regression guard goes 3 arms to 13, the two new ones being
a perfectly written pointer block the run never booked.
R761, rebuilt: 25 BMP sources, 19 held, 19 of 19 conversions confirmed
against 19 declared, 50 assets (29 JPEG + 21 PNG, 0 BMP), SHY 71,
u = 0, d = 0, exit 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PM measured it on `ae441ab` and this reproduces it through the real `okf
build`: a BMP refused `asset_too_large`, absent from `assets/`, read as
CARRIED because the document wrote two paragraphs. `_POINTER` matches a
FORM, and a form is not a signature.
Three arms, each a whole build, each a way to put two markdown lines in a
bundle without the run writing them: two `<p>` elements in one HTML file,
one `<p>` with a `<br>`, and a markdown note beside the HTML file that
carries the real PNG. All three forge on this commit. The known-positive
is in the same builds -- a 2x2 BMP the run really does convert -- so an
arm cannot pass by the route going dark.
Red on an assertion about behaviour: 1 of 1 (the loop stops at the first
arm; the other two are reproduced in the same shape).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One command, one exit code, and no capability: nothing here changes the
ranking, the fusion, the tokenisation or the cut. For a frozen question set
against a bundle it asks how many of N measurement units the payload carries
the fasit for, gives every miss exactly ONE class, and asks the thing the
payload does not do at all -- say so when it does not know.
Today, on the synthetic corpus this repository generates and the five sets it
ships pinned by sha256: rows 1 (9 of 9), 2 (7 of 7) and 6 (9 of 9) green;
3 (2 of 5), 4 (3 of 6), 5 (0 of 1), 7 (11 of 13), 8 (not run) and 9 (0 of 6)
red. GATE RED, exit 1.
- Row 3 is the finding to read first. In a bundle built from ONE source
document, every concept past the first two carries that document's
`source_file`, so a concept the RANK had already lost is withheld as
`source_quota_exceeded`. The truth is decided by the SAME cut run without
the quota -- PM measured the same lie on 13 of 25 real misses 2026-09-17.
- Row 4 is the second. No payload key says "this bundle does not answer
that", so the only honest reading today is `delivered == 0`; three of six
controls come out right and the three that do not get eight excerpts and no
statement. A control the bundle DOES answer must come back unmarked, or the
marking says nothing, so both directions sit in the same row.
- A miss has exactly one class -- (a) not in the bundle, (b) below k, (c) cut
by quota or budget, (d) delivered without the citation, (e) other -- and
each is FORCED by its own fixture. An unplaced miss takes the row to 0 of N.
- The judge opens the bundle. Every concept is read off disk before a payload
is built, a delivered excerpt whose text is not the concept file's bytes is
not a hit, and the two title forms come from `quality` rather than being
written a second time.
- FELLED means a row got WORSE, never merely that something changed: removing
the quota makes row 3 green, and counting that as a kill would credit this
gate with a check it does not have. The two survivors are reported with what
they measured -- the document prior dead and RRF_K flattened each moved 0
ranks and 0 deliveries on these fixtures -- and with a mechanism for each.
- Rows 8 and 9 are never green by leaving something out. The three real sets
are read from path + sha256 and never committed: a gold set names a
consumer's documents and this repository is public. K2 is 0 of 6 because the
gold set exists nowhere while its bundles do.
Denominators re-measured against the sources 2026-09-19, and all four hold:
wiki 20 questions / 29 fasit entries (sha 972d0f57...), R761 8 (S1-S6 + KP +
KN), vegnormal 32 questions / 43 must_cite, K2 6. PM's 45 of 70 is itself a
mixed sum -- wiki at question level, vegnormal at citation level -- so this
gate carries it labelled as recorded and never sums the two granularities.
46 tests, each failing row driven both ways through inputs rather than by
editing the row. No network (the socket guard is fired before its silence
counts), no private corpus, no clock.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
killed 39 of 39, exit 0 (36 before this round). Three new mutants, one per
check that closes PM's measured forgery, each felled by its own arm:
- X3 restores the pre-round `_conversions` verbatim -- the clause read from
anywhere in the bundle text. Felled by the body-text and table-cell arms.
- X4 drops the tie between the clause and the asset its block points at.
Felled by the arm where the clause stands in another asset's block.
- X5 drops `_inline`'s checksum disarming. Felled by the end-to-end arm where
an `<img alt>` carries the clause.
X5 IS WHY THE HARNESS CHANGED, and it is a finding about the harness rather
than about the code: it SURVIVED 112 green tests on the first run, and it had
never been applied. The copy is run with the venv's interpreter, which carries
an editable install pointing at the working tree, so `import llm_ingestion_okf`
in the copy resolved to the original `src/`. The subprocess now gets the copy's
own `src/` on `PYTHONPATH`, which wins over the editable finder, and X5 dies on
the first run under it -- the survivor/killed pair is the measurement. The gate
and the witness were never affected; the suite reaches those through the copy's
own `tools/`.
The docstring's "the twenty-six mutants" now says "twenty-six of them", since
the list has been 39 for a while and the sentence is about their provenance.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
160 passed over the three affected files. Two guards this round introduced are
removed, each because it was measured and found unkillable, and one real cut is
added in their place.
The end-of-line anchor on the judge's clause expression: with the pointer-block
route and the asset-name tie already in place, the only case it covered is a
label forging a clause inside a block that also carries a real one -- which
`assets._inline` now makes unreachable from a document. Removed with `$` gone
the whole suite stays green, which is the measurement: a guard nothing can fell
is not a guard, and it would also have made the mutant below unfaithful.
The length check on a short absolute run in `_bmp_rle8_rows`: unobservable by
construction. A run shorter than its declared count means the stream ran out,
so the loop cannot reach an end-of-bitmap afterwards and the picture is refused
either way -- the partial paint is discarded with the frame.
A FIFTH CUT REPLACES THEM, and it makes an existing bound load-bearing: a
stream cut in the middle of a delta escape, 17 of 32 pixels wrong by this
file's own decoder. Without the two-byte bound in that branch the cut raises
IndexError instead of a coded refusal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
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>
`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>
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>
`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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
G37. `okf check` is a CONTRACT check and a green one is not a quality gate:
measured 2026-09-10 by `vegnormal-okf`, three arms over one corpus all
returned 0 findings and exit 0 while their hit@k ranged from 6 of 6 to 0 of 6.
`okf quality <bundle>` asks the other question, per file type, with the
denominator on every line. A separate command rather than `okf check
--quality`, because the two answer different questions and a caller must not
be able to read one as the other. `okf check` is untouched.
Three verdicts and no fourth -- PASS, FAIL, UNMEASURED -- and a type with no
measured threshold is never PASS. Exit 0 judged and clean, 1 at least one
FAIL, 2 did not run, 3 nothing could be judged: exit 0 over a table of
unmeasured rows would be the silent pass this command exists to stop.
Two bars today, both `structure_null_share` (documents of a type yielding
exactly one concept), read off the pinned 43-document reference bundle: .pdf
8/32, .docx 2/5. Plus one definitional bar for every type, taken from the
harness's own degenerate-merge definition: 0 concepts with an empty body,
measured 0 of 8 602 concepts over four bundles. A bar needs five documents on
BOTH sides -- its own and the judged bundle's -- so .xlsx (2), .xml (1) and
every type with no corpus class in `extract._EVIDENCE` are UNMEASURED and
print their numbers without a verdict.
The floor on the judged bundle was found by RUNNING the gate, not by reading
it: one PDF cut into 2 182 concepts scored 0 of 1 against the 32-document
reference and read as PASS.
The gate walks the index tree and never a directory (SS 9.2; controlled
against the listing on four bundles, 453 / 2 761 / 3 206 / 446 either way),
and prints the bundle's own run log beside its counts -- a document rejected
at extraction leaves no row in the bundle, so the pinned corpus's 33 PDFs
show up as 32 and the two denominators must never be read as one.
Three of the order's five premises moved when re-measured, and they are in the
document rather than glossed: the four evidence corpora carry `source_file` on
0 of 446, 0 of 1 133, 0 of 270 and 0 of 2 756 concepts, so they name no file
type and cannot PASS; "41,6 %" is `vegnormal-okf`'s number and not in this
repository; and the same 828-document bundle carries two published hit@k
figures from two question sets.
Three candidate metrics measured and NOT shipped: duplicate titles within a
document (0 of 3 206 on the known-bad arm against 349 of 2 761 on the
known-good one) and short concepts (5.6 % against 14.6 %) order the two arms
the wrong way round; duplicate titles across the whole bundle order all four
correctly (37.8 / 16.3 / 12.6 / 5.7 %) and still ship without a bar, because
any bar separating them is read off the two bundles it would judge.
19 new tests, each rule exercised in both directions; the three README pins
were each driven red before being kept. Suite 1 850 passed, 1 skipped, 1 851
collected, run after `git add` -- +19 against a base of 1 832 collected,
measured on the stashed tree (STATE's 1 831 is one short of that).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`### Binary extraction` carried its own six-row Format/Reader/Evidence table
over the same rows the pinned table now holds. It was true when written and
reachable by exactly the failure this module exists for: three evidence classes
copied into prose no test reads. That section now points at the pinned table
and keeps its prose about the extra.
A fifth assertion in `tests/test_docs_promises.py` holds the duplicate gone.
Known-positive: the same query finds 8 table lines in that section on the
previous commit, so it can go red.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The extractor registry reads 13 extensions. The README's opening line named
five of them, and the full list existed only in a hidden
`<!-- extract-formats: ... -->` comment, which no reader reads -- so the README
undersold what the code does and stated no evidence class anywhere a consumer
would look.
A `## Supported file types` table now carries one row per extension: reader,
dependency (core or the `[extract]` extra), the evidence class `_EVIDENCE`
records for the row, and one honest note. The three `constructed` office rows
carry their denominators (N = 1, N = 2, N = 1) in the table itself, so a row
that has met no document anyone wrote cannot read as a supported one; `.htm`
does not borrow `.html`'s 828-file class, because the code records none for it.
A `Not read today` section states the absences (`.doc`, `.epub`, `.eml`/`.msg`,
image files, source files, `.one`/`.vsd`) as facts, not as a queue.
Test first, red before the table existed: four assertions in
`tests/test_docs_promises.py` pin the table's row set to
`_CORE_EXTRACTORS | _OPTIONAL_EXTRACTORS`, each evidence cell to `_EVIDENCE`
(and to a fixed `stdlib, no corpus class` where the code records none), the
core/extra split to the registries, and the opening to the table.
No change to `extract.py` and no version bump: nothing about what is read
moved, only what the README says about it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`link_in_signal` defaults to `False` on all three entry points --
`searchable_text`, `concept_scores` and `build_payload` -- through one named
constant, `DEFAULT_LINK_IN_SIGNAL`. This carries out K3-23's recommendation
(alternative c), and its numbers are the whole warrant: of the newcomers the
door's `Enclosing section:` line ever added a question token to, 39 of 39
gained it from the bundle-absolute PATH and 0 of 39 from the link's title, and
every token the path contributed is a segment of the document's own directory
-- the saturation `shared_id_prefix` takes out of the id signal, back in
through the body.
A CONSTANT rather than three naked literals, chosen against the module: the
other eight defaults here are constants (`DEFAULT_PROFILE`, `DEFAULT_LIMIT`,
`DEFAULT_TIE_SHARED_RANK`, `DEFAULT_STEM_PREFIX`, `DEFAULT_TITLE_COVERED`,
`DEFAULT_SOURCE_QUOTA`, `DEFAULT_FOLLOW_PARENT`, `DEFAULT_K`) and this was the
one default spelled out three times. It also lets a test fall on the rule from
the module that owns it.
NO CLI flag is added and none is changed. `grep -n '"--.*link'` over `src/`
gives 0 hits before and after, and `consume.main` never passed the parameter,
so the CLI inherits the function default. A flag was considered and NOT added,
because no caller could be named that needs the older reading: the three call
sites outside `consume.py` (`skill.py:360`, `tools/okf_consume_measure.py:155`
and `:247`) all inherit it deliberately.
Measured before landing: 0 of 5 shipped bundles carry the door's line
(denominators 446, 1 133, 270, 2 756, 2 761 concept files) and 5 of 5 payloads
are byte-identical across the move. The consumer repository was read only and
its tree is unchanged, listing identical including mtimes.
The docstrings move with the code rather than after it. `concept_scores` said
this was "an INSTRUMENT, not a flag and not a default" and `build_payload` that
it "carries no CLI flag"; the first is now false and the second still true, and
each says so. `cli.DEFAULT_SHELL_PARENT` keeps its value and loses its reason:
the cost it cited -- delivered sets moving through that path -- is what this
commit removes, so the comment now states why it stays off NOW, which is N = 1
document and a consumption half nobody has judged. README and CLAUDE.md carry
the same sentence where they already describe the line, measured rather than
written on reflex: neither said anything about the ranking before.
One test changed with the code. The characterisation of what the LINE does
called `concept_scores` with no parameter, because the reading it characterises
used to be the default; it now names that reading. Same fixture, same number.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
K3-23 measured the whole decomposition and landed on one recommendation:
the cost is the PATH, not the link. 39 of 39 newcomers that won a question
token from the door's `Enclosing section:` line won it through the
bundle-absolute path and 0 of 39 through the link's title, and every token
the path ever contributed is a segment of the document's own directory --
the saturation `shared_id_prefix` took OUT of the id signal, back in through
the body.
Ten cases, each red for its own reason on `7faa380`, and each one measuring
BEHAVIOUR rather than a signature: the three entry points are called with no
parameter at all and what came back is read.
- `searchable_text`, `concept_scores` and `build_payload` with no parameter
read a body without the door's line (3 cases).
- The declared default IS the behaviour: the constant is imported from the
module that owns it, INSIDE the test so the red falls on the rule and not
on collection, and then spent on a payload comparison.
- Y = Z in miniature: the flagged bundle read by default delivers the same
list, the same ORDER and the same `spent` as the unflagged build.
- `link_in_signal=True` is still reachable and still does the old thing --
both halves, because either alone would pass on a no-op.
- The two known-negatives restated against the DEFAULT path: a human line
opening with the same two words, and the door's exact form anywhere but
last, keep every byte.
- Rank, never form: the two readings differ in the delivered set while every
concept delivered by both carries identical excerpt bytes.
Two stay green on both sides on purpose. The known-positive -- a bundle where
no body ends in the door's form is byte-identical under both readings -- is
section 4A's exposure gate in miniature and would go red only if the stripper
became eager. The characterisation of what the line does today is HEAD's
behaviour and keeps stating it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`inbox._link_enclosing` appends one line to a heading-only body; `consume._body`
returns everything after the frontmatter, so the line is scored by the body
signal and counted by the lexical gate. Nothing in the code ever decided that:
SPEC SS 6.1's argument for the bundle-absolute form is about the FILE.
One characterisation, green on HEAD, holds the mechanism: a shell whose own
text answers nothing in the question still scores, on the door's line alone.
The eight red ones ask for the instrument K3-23 needs -- one bundle, two
readings, in one process -- and for its two obligatory known-negatives: a human
line opening with the same two words, and the door's exact form anywhere but
last, both keep every character they have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One grammar, four call sites. `read_block_mappings` moves out of
`consume.read_sources` -- where it was written and measured -- into
`profiles`, the module both the flat readers and `consume` already import,
and the three copies of the line-oriented frontmatter grammar now decode a
block sequence for the keys `STRUCTURED_BLOCK_KEYS` names. Two copies of a
block grammar would be two answers to one question.
The value TYPE was the real choice and it was measured, not argued.
`parse_frontmatter` is public API (`okf.parse_frontmatter`) returning
`dict[str, str]`, and a list of mappings is not a `str`. Widening the return
type to `str | list[dict[str, str]]` costs 15 `mypy --strict` errors across
four of the five modules that touch the reader, plus a signature every
caller outside this repository would have to follow. Rendering the entries
back into the flow form those same readers already round-trip costs 0. The
rendering is a READING projection and says so: it is not a claim that the
value is writable -- `yaml_flow_plain` still refuses a `?` and the guard
still refuses a quote inside a flow mapping, which is why the producer
writes block in the first place.
`STRUCTURED_BLOCK_KEYS` is one key wide. `sources` is the key `read_sources`
already knows how to read; a fixture in this tree carries a block
`verified:` that still reads as an empty value, and a test pins that state
so the next widening is a decision rather than a side effect.
Nothing nested reaches the document's namespace: the entries land inside
their own value, and the K3-20 substitution guarantee is asserted per reader
copy.
Three tests that pinned the old behaviour are rewritten to what is now true,
none weakened on its other half: the block round trip in
`test_multi_source_provenance` (the evidence behind `_render_sources`'
reason 1), the v0.2 characterization (whose key-space assertion is the half
that must never weaken), and K3-22's shipped-file known-positive, where the
one difference is counted and pinned at 1.
Suite 1807 passed / 1 skipped, rc 0, 94 s -- 1782/1 before plus 25 new.
ruff clean, `mypy --strict` clean over 21 files, `uv.lock` untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`consume.read_sources` reads both YAML forms. This library's three copies of
the line-oriented frontmatter grammar read only the flow one, and on a block
sequence they return the key with an EMPTY value -- not a `KeyError` a
consumer can catch, an address that disappears.
Measured 2026-09-12 over four bundles a producer ships, denominator = files
carrying a frontmatter block: 2 756 of 2 757, 446 of 447, 1 133 of 1 134 and
270 of 271 concept files lose the address through the flat readers, while
PyYAML 6.0.3 and the pinned guard 1.4.0 both read it on 100 % of the same
files.
The bar is dict EQUALITY against two independent readers rather than "it
parses". The two disagree on one axis and it is named rather than averaged:
the guard keeps a leaf's quotes verbatim, PyYAML decodes them, and this
library follows `read_sources`' K3-22 rule.
Red: 13 failed, 12 passed. The 12 are the known-positive controls and the
known-negatives the fix may not move -- the nested-`title:` substitution trap
above all, plus the flow form, an absent key, and a shape `read_sources`
refuses.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
K3-21 D, red. The NISO-STS reader clips `depth` to 6 once and writes that
one value into BOTH the ATX heading, where markdown has six levels, and the
`OutlineMark` the declared route builds its plan from, where nothing clips.
On one standard 9 of 2 761 titled sections sit at depth 7; the plan read them
at 6, and `--shell-parent` gave its two depth-7 shells the ancestor one level
too high.
`sts-deep.xml` (invented setting) reproduces it: the two depth-7 shells point
at the depth-5 section on e717b1c's code and must point at the depth-6 one.
Held: the mark carries depth 7; the heading stays `######` and the extracted
headings do not move one character; the shells point at their depth-6
ancestor.
2 of 3 red on 5970369; the green one is the text guard.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>