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:
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
def images_found(self) -> int:
return sum(d.counts.get("image", 0) for d in self.documents)
@ -806,6 +819,7 @@ class Accounting:
"accounting_version": ACCOUNTING_VERSION,
"unaccounted": self.unaccounted,
"double_booked": self.double_booked,
"refused": self.refused,
"documents": [d.to_json() for d in self.documents],
"files": [f.to_json() for f in self.files],
}
@ -814,7 +828,8 @@ class Accounting:
lines = [
f"* **Accounting**: {len(self.documents)} document(s) and {len(self.files)} "
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:
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)
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

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"))
log = (bundle / "log.md").read_text(encoding="utf-8")
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:

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:
{"accounting_version": 1,
"refused": <documents the build read and persisted nothing of>,
"documents": [
{"source_file": "<inbox-relative path>",
"status": "persisted" | "rejected", "code": "<rejection code>" | null,
@ -479,6 +480,11 @@ class Unit:
`invalid` are about the bundle and the declaration themselves. A booking
the gate could not verify is never clean, and `verified`/`unverifiable`
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
@ -489,11 +495,14 @@ class Unit:
invalid: int = 0
verified: int = 0
unverifiable: int = 0
refused: int = 0
notes: list[str] = field(default_factory=list)
@property
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:
@ -644,6 +653,18 @@ def _document_unit(
if persisted and total > 0 and booked_carried == 0:
invalid += 1
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(
name,
"document",
@ -653,6 +674,7 @@ def _document_unit(
invalid=invalid,
verified=verified,
unverifiable=unverifiable,
refused=refused,
notes=notes,
)
@ -803,15 +825,19 @@ def row3(units: list[Unit], door: bool) -> Row:
d_total = sum(u.double for u in units)
unverified = sum(u.unverified 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 = (
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:
reason += f"; no `{ACCOUNTING_FLAG}` door, so no element has a declared fate"
details = [_tally(units)] + [
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 "")
for u in units
if not u.clean
@ -1060,6 +1086,7 @@ def row6(r761: Path | None, n200: Path | None, ci: bool) -> Row:
details.append(
f" {unit.name}: u={unit.unaccounted} d={unit.double} "
f"unverified={unit.unverified} invalid={unit.invalid} "
f"refused={unit.refused} "
f"({'; '.join(unit.notes)})"
)
doubled = [u for u in units if u.kind == "file" and u.double]