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>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-19 05:48:57 +02:00
commit d27ca503c8
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
4 changed files with 51 additions and 6 deletions

View file

@ -793,6 +793,19 @@ class Accounting:
def double_booked(self) -> int: def double_booked(self) -> int:
return sum(sum(d.double_booked.values()) for d in self.documents) return sum(sum(d.double_booked.values()) for d in self.documents)
@property
def refused(self) -> int:
"""Documents the build read and persisted NOTHING of.
Their elements are booked honestly, as coded rejections, so neither
`unaccounted` nor `double_booked` moves and the loss is invisible in
the two numbers a reader looks at. A run that persisted at least one
document exits 0 -- the exit code belongs to the whole run, and a
corpus holding one unreadable file is the ordinary case -- so this
count is what keeps a partial refusal from being silent (H1).
"""
return sum(1 for d in self.documents if d.status == REJECTED)
@property @property
def images_found(self) -> int: def images_found(self) -> int:
return sum(d.counts.get("image", 0) for d in self.documents) return sum(d.counts.get("image", 0) for d in self.documents)
@ -806,6 +819,7 @@ class Accounting:
"accounting_version": ACCOUNTING_VERSION, "accounting_version": ACCOUNTING_VERSION,
"unaccounted": self.unaccounted, "unaccounted": self.unaccounted,
"double_booked": self.double_booked, "double_booked": self.double_booked,
"refused": self.refused,
"documents": [d.to_json() for d in self.documents], "documents": [d.to_json() for d in self.documents],
"files": [f.to_json() for f in self.files], "files": [f.to_json() for f in self.files],
} }
@ -814,7 +828,8 @@ class Accounting:
lines = [ lines = [
f"* **Accounting**: {len(self.documents)} document(s) and {len(self.files)} " f"* **Accounting**: {len(self.documents)} document(s) and {len(self.files)} "
f"other file(s); {self.elements} elements found in the sources; " f"other file(s); {self.elements} elements found in the sources; "
f"{self.unaccounted} unaccounted, {self.double_booked} double-booked." f"{self.unaccounted} unaccounted, {self.double_booked} double-booked; "
f"{self.refused} of {len(self.documents)} document(s) refused whole."
] ]
for doc in self.documents: for doc in self.documents:
if doc.status == REJECTED: if doc.status == REJECTED:

View file

@ -864,7 +864,10 @@ def test_a_corpus_refused_whole_under_the_default_gate_is_red(tmp_path: Path) ->
} }
build = _build(accounting=declared, sources=set(), exit_code=1) build = _build(accounting=declared, sources=set(), exit_code=1)
units = gate.account(inventory, build, tmp_path) units = gate.account(inventory, build, tmp_path)
assert all(u.clean for u in units), "every element has a coded fate" # The NUMBERS still balance -- that is what made the row green, and since
# H1 the loss has its own column instead of hiding behind them.
assert all((u.unaccounted, u.double) == (0, 0) for u in units)
assert not any(u.clean for u in units)
assert gate.refused_whole(inventory["documents"], build) is not None assert gate.refused_whole(inventory["documents"], build) is not None

View file

@ -255,7 +255,7 @@ def test_the_door_writes_the_accounting_into_the_log(tmp_path: Path) -> None:
_, bundle, _ = _build(CORPUS, tmp_path, "--accounting", str(tmp_path / "a.json")) _, bundle, _ = _build(CORPUS, tmp_path, "--accounting", str(tmp_path / "a.json"))
log = (bundle / "log.md").read_text(encoding="utf-8") log = (bundle / "log.md").read_text(encoding="utf-8")
assert "* **Accounting**: 20 document(s) and 2 other file(s);" in log assert "* **Accounting**: 20 document(s) and 2 other file(s);" in log
assert "6 unaccounted, 0 double-booked." in log assert "6 unaccounted, 0 double-booked; 0 of 20 document(s) refused whole." in log
def test_a_rejected_document_is_logged_with_what_its_source_held(tmp_path: Path) -> None: def test_a_rejected_document_is_logged_with_what_its_source_held(tmp_path: Path) -> None:

View file

@ -21,6 +21,7 @@ THE DOOR THE CAPABILITY MUST OPEN (the contract this gate reads). `okf build`
accepts `--accounting PATH` and writes one JSON object there: accepts `--accounting PATH` and writes one JSON object there:
{"accounting_version": 1, {"accounting_version": 1,
"refused": <documents the build read and persisted nothing of>,
"documents": [ "documents": [
{"source_file": "<inbox-relative path>", {"source_file": "<inbox-relative path>",
"status": "persisted" | "rejected", "code": "<rejection code>" | null, "status": "persisted" | "rejected", "code": "<rejection code>" | null,
@ -479,6 +480,11 @@ class Unit:
`invalid` are about the bundle and the declaration themselves. A booking `invalid` are about the bundle and the declaration themselves. A booking
the gate could not verify is never clean, and `verified`/`unverifiable` the gate could not verify is never clean, and `verified`/`unverifiable`
carry the denominator behind that word. carry the denominator behind that word.
`refused` is the fifth and the only one that is not a defect in the
REPORT: the elements of a document the build refused whole. Their fate is
honestly declared and their content is not in the bundle, so the numbers
balance and nothing else here can see the loss (H1).
""" """
name: str name: str
@ -489,11 +495,14 @@ class Unit:
invalid: int = 0 invalid: int = 0
verified: int = 0 verified: int = 0
unverifiable: int = 0 unverifiable: int = 0
refused: int = 0
notes: list[str] = field(default_factory=list) notes: list[str] = field(default_factory=list)
@property @property
def clean(self) -> bool: def clean(self) -> bool:
return not (self.unaccounted or self.double or self.unverified or self.invalid) return not (
self.unaccounted or self.double or self.unverified or self.invalid or self.refused
)
def _conservation_held(build: Build) -> bool: def _conservation_held(build: Build) -> bool:
@ -644,6 +653,18 @@ def _document_unit(
if persisted and total > 0 and booked_carried == 0: if persisted and total > 0 and booked_carried == 0:
invalid += 1 invalid += 1
notes.append("the build persisted this document and the report carries nothing from it") notes.append("the build persisted this document and the report carries nothing from it")
# H1: a document refused whole balances by construction -- every element
# is a coded rejection, so u = 0 and d = 0 -- and `refused_whole` below
# asks its question only for a corpus that persisted NOTHING. One refused
# source beside an accepted one is the ordinary case on a heterogeneous
# corpus, and it read CLEAN with the content gone.
refused = 0
if status == "rejected" and not persisted and total > 0:
refused = total
notes.append(
f"refused whole: {total} element(s) declared rejected `{code}`, "
"and the bundle holds nothing from this document"
)
return Unit( return Unit(
name, name,
"document", "document",
@ -653,6 +674,7 @@ def _document_unit(
invalid=invalid, invalid=invalid,
verified=verified, verified=verified,
unverifiable=unverifiable, unverifiable=unverifiable,
refused=refused,
notes=notes, notes=notes,
) )
@ -803,15 +825,19 @@ def row3(units: list[Unit], door: bool) -> Row:
d_total = sum(u.double for u in units) d_total = sum(u.double for u in units)
unverified = sum(u.unverified for u in units) unverified = sum(u.unverified for u in units)
invalid = sum(u.invalid for u in units) invalid = sum(u.invalid for u in units)
refused = sum(u.refused for u in units)
documents = sum(1 for u in units if u.kind == "document")
refused_docs = sum(1 for u in units if u.refused)
reason = ( reason = (
f"u = {u_total} unaccounted, d = {d_total} double-booked, " f"u = {u_total} unaccounted, d = {d_total} double-booked, "
f"{unverified} booked carried and not in the bundle, {invalid} declaration(s) the gate refuses" f"{unverified} booked carried and not in the bundle, {invalid} declaration(s) the gate refuses, "
f"{refused} element(s) lost with {refused_docs} of {documents} document(s) refused whole"
) )
if not door: if not door:
reason += f"; no `{ACCOUNTING_FLAG}` door, so no element has a declared fate" reason += f"; no `{ACCOUNTING_FLAG}` door, so no element has a declared fate"
details = [_tally(units)] + [ details = [_tally(units)] + [
f"{u.kind} {u.name}: u={u.unaccounted} d={u.double} " f"{u.kind} {u.name}: u={u.unaccounted} d={u.double} "
f"unverified={u.unverified} invalid={u.invalid}" f"unverified={u.unverified} invalid={u.invalid} refused={u.refused}"
+ (f" ({'; '.join(u.notes)})" if u.notes else "") + (f" ({'; '.join(u.notes)})" if u.notes else "")
for u in units for u in units
if not u.clean if not u.clean
@ -1060,6 +1086,7 @@ def row6(r761: Path | None, n200: Path | None, ci: bool) -> Row:
details.append( details.append(
f" {unit.name}: u={unit.unaccounted} d={unit.double} " f" {unit.name}: u={unit.unaccounted} d={unit.double} "
f"unverified={unit.unverified} invalid={unit.invalid} " f"unverified={unit.unverified} invalid={unit.invalid} "
f"refused={unit.refused} "
f"({'; '.join(unit.notes)})" f"({'; '.join(unit.notes)})"
) )
doubled = [u for u in units if u.kind == "file" and u.double] doubled = [u for u in units if u.kind == "file" and u.double]