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:
parent
5d1060d467
commit
d27ca503c8
4 changed files with 51 additions and 6 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue