fix(accounting-gate): the contract publishes what the gate reads, and a doorless bundle says why

Green: 117 of 117 in the suite that was 3 red.

The published `--accounting` sketch gains the four keys the door has been
writing and the contract did not name -- `conversions`, with its `from`/`to`
pair, plus `normalised_soft_hyphen`, `unaccounted` and `double_booked` -- and
a paragraph saying what a door built WITHOUT `conversions` costs the
consumer who built it: every converted image claimed-and-not-found, 19 of 50
on R761. The two new tests hold the sketch against both sides, what the gate
looks up and what the door serialises, so neither drifting again is silent.

N5, and the choice: SAY IT OUT LOUD rather than only in a docstring. A
docstring is read by whoever edits this file; the reader who is misled is
reading a REPORT, where a count of claimed-and-not-found images appeared with
no cause beside it. `_tally` now names the missing ledger when, and only
when, something was claimed and not found -- two known-negatives hold that
condition. `asset_holds` gets the sentence as well, since it costs nothing.

mypy --strict on this file: 11 errors before, 11 after (pre-existing, this
file is not in the `src/` gate).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-19 22:53:57 +02:00
commit c4d890987a
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q

View file

@ -21,17 +21,33 @@ THE DOOR THE CAPABILITY MUST OPEN (the contract this gate reads). `okf build`
accepts `--accounting PATH` and writes one JSON object there:
{"accounting_version": 1,
"unaccounted": <elements no fate accounts for, over the whole run>,
"double_booked": <elements two fates account for>,
"refused": <documents the build read and persisted nothing of>,
"normalised_soft_hyphen": <U+00AD removed before the persist gate>,
"documents": [
{"source_file": "<inbox-relative path>",
"status": "persisted" | "rejected", "code": "<rejection code>" | null,
"normalised_soft_hyphen": <this document's share of the above>,
"conversions": [{"from": "<sha256 of the source image>",
"to": "<sha256 of the file the run carried>"}],
"inventory": {"<element>": <count>, ...},
"fates": {"<element>": {"carried": n, "pointer": n,
"rejected": {"<code>": n}}}}],
"rejected": {"<code>": n}}},
"unaccounted": {"<element>": n}, "double_booked": {"<element>": n}}],
"files": [
{"source_file": "<inbox-relative path>",
"fate": "carried" | "merged" | "rejected", "code": "<code>" | null}]}
`conversions` IS LOAD-BEARING AND WAS UNPUBLISHED UNTIL 2026-09-19. It is the
run's own ledger of images it REWROTE -- today a BMP that reaches the bundle
as a PNG -- and `asset_holds` proves such a carry by it, because the same
pair stated in the bundle's prose can be written by a document. A door built
from a contract without it writes a ledger this gate reads as "nothing was
converted", and every converted image comes out claimed-and-not-found: 19 of
50 on R761. `normalised_soft_hyphen`, `unaccounted` and `double_booked` had
been written for a round longer than they were published.
`inventory` is taken BEFORE extraction and before the persist gate, in the
witness's element vocabulary (per file type, defined in `okf_witness.py`), so
a document the gate refuses still has one. `files` covers every inbox file
@ -486,6 +502,15 @@ def asset_holds(build: Build, source: Path) -> bool:
a conversion the run did not book still fails, which is the difference
between reading the artifacts and believing the report.
AND NEITHER ROUTE WORKS WITHOUT THE RUN'S LEDGER. The second one reads the
pair out of the `--accounting` file, so on a bundle built WITHOUT that flag
a converted image cannot be proved at all and this returns False -- the
reading R761 gave 19 times the day conversion landed. The gate itself
always passes the flag (`door_available()`), so the package build is
untouched; any other caller on a doorless bundle gets the weak reading, and
since 2026-09-19 the report says so in as many words rather than printing
the count alone.
WHAT NEITHER ROUTE PROVES IS FIDELITY. Both ask whether a file in
`assets/` holds the bytes the bundle names, and neither decodes a PIXEL:
measured 2026-09-19, a mutated converter that writes a BLANK PNG is
@ -935,15 +960,31 @@ def row2(table: Iterable[str], inventory: Mapping[str, Any], build: Build, door:
return _row(2, name, len(good), len(table), reason, details)
def _tally(units: Iterable[Unit]) -> str:
"""What the gate FOUND, with the denominator beside it."""
def _tally(units: Iterable[Unit], *, ledger: bool = True) -> str:
"""What the gate FOUND, with the denominator beside it.
`ledger` is whether the run that produced these units wrote an
`--accounting` file. Without one there is no record of what the run
CONVERTED, so `asset_holds` falls back to its first route and a picture
that reached the bundle under a new digest is counted claimed-and-not-found
-- exactly the reading the conversion route removed (R761: 19 of 50). That
fallback is honest and it used to be silent, which is a different thing:
the number was printed and its cause was not, so a reader of a doorless
bundle's report could only conclude the bundle had lost the pictures.
"""
units = list(units)
verified = sum(u.verified for u in units)
unverified = sum(u.unverified for u in units)
blind = sum(u.unverifiable for u in units)
unproved = (
f"; this build carries no `{ACCOUNTING_FLAG}` ledger, so an image the run CONVERTED "
"cannot be proved carried and is counted among them"
if unverified and not ledger
else ""
)
return (
f"{verified} carried element(s) found in the bundle, {unverified} claimed and not found, "
f"{blind} carrying no text of their own (the gate cannot check those)"
f"{blind} carrying no text of their own (the gate cannot check those){unproved}"
)
@ -963,7 +1004,7 @@ def row3(units: list[Unit], door: bool) -> Row:
)
if not door:
reason += f"; no `{ACCOUNTING_FLAG}` door, so no element has a declared fate"
details = [_tally(units)] + [
details = [_tally(units, ledger=door)] + [
f"{u.kind} {u.name}: u={u.unaccounted} d={u.double} "
f"unverified={u.unverified} invalid={u.invalid} refused={u.refused}"
+ (f" ({'; '.join(u.notes)})" if u.notes else "")
@ -1236,7 +1277,8 @@ def row6(r761: Path | None, n200: Path | None, ci: bool) -> Row:
)
details.append(
f" gate {label}: exit {build.exit_code}, {persisted} of {len(documents)} "
f"document(s) persisted, {len(build.assets)} asset file(s); {_tally(units)}"
f"document(s) persisted, {len(build.assets)} asset file(s); "
f"{_tally(units, ledger=build.accounting is not None)}"
)
for unit in units:
if unit.kind == "document" and not unit.clean: