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>
680 lines
26 KiB
Python
680 lines
26 KiB
Python
"""The content-accounting gate for `okf build` (capability loop, step 3).
|
|
|
|
One command, one exit code. For every supported file type it asks whether the
|
|
bundle accounts for what the SOURCE holds: of M elements the source carries,
|
|
how many does the build account for as carried, as a pointer or as a coded
|
|
rejection -- and how many does it account for NOT AT ALL (u) or TWICE (d).
|
|
|
|
Written RED, before any capability. `okf build`'s conservation identity,
|
|
`merged + coded rejections == N`, counts FILES: a file can be "merged" while
|
|
content inside it is gone, and a file can be "rejected" while its bytes ride
|
|
into the bundle through a document that points at it. Neither is visible to
|
|
the identity, and both are measured here.
|
|
|
|
THE FASIT NEVER COMES FROM THE READER IT JUDGES. Element counts come from
|
|
`tools/okf_witness.py`, which imports no `llm_ingestion_okf` module (a test
|
|
proves it on the live import graph) and is committed as data in
|
|
`tests/fixtures/accounting/*inventory.json`. This module imports the package
|
|
only to RUN the build it judges.
|
|
|
|
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,
|
|
"documents": [
|
|
{"source_file": "<inbox-relative path>",
|
|
"status": "persisted" | "rejected", "code": "<rejection code>" | null,
|
|
"inventory": {"<element>": <count>, ...},
|
|
"fates": {"<element>": {"carried": n, "pointer": n,
|
|
"rejected": {"<code>": n}}}}],
|
|
"files": [
|
|
{"source_file": "<inbox-relative path>",
|
|
"fate": "carried" | "merged" | "rejected", "code": "<code>" | null}]}
|
|
|
|
`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
|
|
that is not a document the build reads; `fate` is exactly one value, so a file
|
|
whose bytes were carried through a document is `carried` and never also
|
|
`rejected`. Until the flag exists, rows 2 and 3 say so and stay red.
|
|
|
|
A rejected document is reported in `log.md` as ONE line, and row 4 reads it:
|
|
|
|
<source_file>: <M> elements found in the source, 0 carried: document rejected `<code>`
|
|
|
|
with M the document's inventory total. The `Images` bullet's "found" count is
|
|
the SOURCE's (every image the documents declare), never what a reader got to.
|
|
|
|
EXCEPTIONS to 100 % are listed in the output and are NOT APPROVED: none of
|
|
them lowers a denominator until the operator approves it by name.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import contextlib
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import tempfile
|
|
import warnings
|
|
import zipfile
|
|
from collections.abc import Iterable, Mapping
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
TOOLS = Path(__file__).resolve().parent
|
|
REPO = TOOLS.parent
|
|
if str(TOOLS) not in sys.path:
|
|
sys.path.insert(0, str(TOOLS))
|
|
|
|
import okf_witness as witness # noqa: E402
|
|
|
|
FIXTURES = REPO / "tests" / "fixtures" / "accounting"
|
|
CORPUS = FIXTURES / "corpus"
|
|
REJECTED = FIXTURES / "rejected"
|
|
INVENTORY = FIXTURES / "inventory.json"
|
|
REJECTED_INVENTORY = FIXTURES / "rejected-inventory.json"
|
|
STS_FIXTURE = CORPUS / "prosess-84-sts.xml"
|
|
STS_TWIN = FIXTURES / "witness" / "prosess-84-sts.twin.json"
|
|
PDF_FIXTURE = CORPUS / "prosess-84-tabell.pdf"
|
|
README = REPO / "README.md"
|
|
|
|
R761_DEFAULT = Path.home() / "repos" / "vegnormal-okf" / "data" / "raw" / "860019"
|
|
R761_ZIP = "14ce59dc-2150-480b-b661-6ea605fe3b24.zip"
|
|
R761_JSON = "R761-2025-860019.json"
|
|
R761_PDF = "R761-prosesskoden-2025.pdf"
|
|
|
|
ACCOUNTING_FLAG = "--accounting"
|
|
ACCOUNTING_VERSION = 1
|
|
BUNDLE_ID = "accounting-gate"
|
|
OKF_VERSION = "0.2"
|
|
CONSUME_QUESTION = "Hvilken toleranseklasse gjelder for konstruksjoner av betong?"
|
|
|
|
GREEN = "GREEN"
|
|
RED = "RED"
|
|
SKIPPED = "SKIPPED"
|
|
DIAGNOSTIC = "DIAGNOSTIC"
|
|
|
|
#: Exceptions the operator has approved, by (suffix, element). Empty: the
|
|
#: first time an exception arises it is the operator's to approve.
|
|
APPROVED_EXCEPTIONS: frozenset[tuple[str, str]] = frozenset()
|
|
|
|
#: Exceptions this gate PROPOSES. Listed in every run; none of them is applied.
|
|
PROPOSED_EXCEPTIONS: tuple[dict[str, str], ...] = (
|
|
{
|
|
"suffix": ".pdf",
|
|
"element": "heading, paragraph, table",
|
|
"reason": "a PDF without a structure tree declares none of them, so no "
|
|
"witness can count them; the witness counts pages and image placements",
|
|
"carried_instead": "the page text, with headings recovered by rule",
|
|
},
|
|
{
|
|
"suffix": ".xlsx",
|
|
"element": "image",
|
|
"reason": "the converter writes one pipe table per sheet and a pointer "
|
|
"block inside it would break source_rows (README, 0.10.0)",
|
|
"carried_instead": "nothing; the image is absent from the bundle",
|
|
},
|
|
{
|
|
"suffix": ".md .txt .csv .json .odt .rtf",
|
|
"element": "image",
|
|
"reason": "no reader for these types carries image bytes (README, 0.10.0)",
|
|
"carried_instead": "the reference text as written, if the format has one",
|
|
},
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class Row:
|
|
number: int
|
|
name: str
|
|
k: int
|
|
m: int
|
|
status: str
|
|
reason: str
|
|
details: list[str] = field(default_factory=list)
|
|
|
|
@property
|
|
def fails(self) -> bool:
|
|
return self.status == RED and self.number <= 6
|
|
|
|
def to_json(self) -> dict[str, Any]:
|
|
return {
|
|
"row": self.number,
|
|
"name": self.name,
|
|
"k": self.k,
|
|
"m": self.m,
|
|
"status": self.status,
|
|
"reason": self.reason,
|
|
"details": self.details,
|
|
}
|
|
|
|
|
|
def _row(number: int, name: str, k: int, m: int, reason: str, details: list[str]) -> Row:
|
|
return Row(number, name, k, m, GREEN if m > 0 and k == m else RED, reason, details)
|
|
|
|
|
|
# --- inputs ------------------------------------------------------------------
|
|
|
|
|
|
def readme_types(readme: Path = README) -> list[str]:
|
|
"""T: the rows of README's supported-file-types table."""
|
|
text = readme.read_text(encoding="utf-8")
|
|
section = text.split("## Supported file types", 1)[1].split("\n## ", 1)[0]
|
|
return sorted(set(re.findall(r"^\| `(\.[a-z0-9]+)` \|", section, re.MULTILINE)))
|
|
|
|
|
|
def load_inventory(path: Path) -> dict[str, Any]:
|
|
data: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
|
|
return data
|
|
|
|
|
|
def door_available() -> bool:
|
|
"""Does `okf build` accept the accounting flag?"""
|
|
from llm_ingestion_okf import cli
|
|
|
|
argv = ["build", "in", "--bundle", "out", "--bundle-id", "x", "--okf-version", "0.2"]
|
|
with contextlib.redirect_stderr(io.StringIO()):
|
|
try:
|
|
cli.parse_args([*argv, ACCOUNTING_FLAG, "accounting.json"])
|
|
except SystemExit:
|
|
return False
|
|
return True
|
|
|
|
|
|
@dataclass
|
|
class Build:
|
|
"""What one `okf build` run left behind, read back from the artifacts."""
|
|
|
|
exit_code: int
|
|
log: str
|
|
accounting: dict[str, Any] | None
|
|
source_files: set[str]
|
|
asset_prefixes: set[str]
|
|
|
|
|
|
def _frontmatter_source_file(text: str) -> str | None:
|
|
if not text.startswith("---\n"):
|
|
return None
|
|
head = text[4:].split("\n---\n", 1)[0]
|
|
match = re.search(r"^source_file:\s*(.+?)\s*$", head, re.MULTILINE)
|
|
if match is None:
|
|
return None
|
|
value = match.group(1)
|
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
value = value[1:-1]
|
|
return value
|
|
|
|
|
|
def read_bundle(bundle: Path, exit_code: int, accounting_path: Path | None) -> Build:
|
|
sources: set[str] = set()
|
|
for path in bundle.rglob("*.md"):
|
|
if "assets" in path.relative_to(bundle).parts:
|
|
continue
|
|
found = _frontmatter_source_file(path.read_text(encoding="utf-8"))
|
|
if found:
|
|
sources.add(found)
|
|
assets = bundle / "assets"
|
|
prefixes = {p.name[:12] for p in assets.iterdir()} if assets.is_dir() else set()
|
|
log_path = bundle / "log.md"
|
|
accounting = None
|
|
if accounting_path is not None and accounting_path.is_file():
|
|
accounting = json.loads(accounting_path.read_text(encoding="utf-8"))
|
|
return Build(
|
|
exit_code=exit_code,
|
|
log=log_path.read_text(encoding="utf-8") if log_path.is_file() else "",
|
|
accounting=accounting,
|
|
source_files=sources,
|
|
asset_prefixes=prefixes,
|
|
)
|
|
|
|
|
|
def run_build(corpus: Path, workdir: Path, *, door: bool, gate: str | None = None) -> Build:
|
|
"""Run the real `okf build` in-process and read back what it wrote."""
|
|
from llm_ingestion_okf import cli
|
|
|
|
bundle = workdir / "bundle"
|
|
accounting_path = workdir / "accounting.json" if door else None
|
|
argv = [
|
|
"build",
|
|
str(corpus),
|
|
"--bundle",
|
|
str(bundle),
|
|
"--bundle-id",
|
|
BUNDLE_ID,
|
|
"--okf-version",
|
|
OKF_VERSION,
|
|
]
|
|
if gate is not None:
|
|
argv += ["--gate", gate]
|
|
if accounting_path is not None:
|
|
argv += [ACCOUNTING_FLAG, str(accounting_path)]
|
|
sink = io.StringIO()
|
|
with (
|
|
contextlib.redirect_stdout(sink),
|
|
contextlib.redirect_stderr(sink),
|
|
warnings.catch_warnings(),
|
|
):
|
|
warnings.simplefilter("ignore")
|
|
try:
|
|
code = cli.main(argv)
|
|
except SystemExit as exc:
|
|
code = exc.code if isinstance(exc.code, int) else 2
|
|
return read_bundle(bundle, code, accounting_path)
|
|
|
|
|
|
def _sha12(path: Path) -> str:
|
|
return hashlib.sha256(path.read_bytes()).hexdigest()[:12]
|
|
|
|
|
|
# --- accounting --------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class Unit:
|
|
"""One inventoried thing: a document, or an inbox file that is not one."""
|
|
|
|
name: str
|
|
kind: str
|
|
unaccounted: int
|
|
double: int
|
|
notes: list[str] = field(default_factory=list)
|
|
|
|
@property
|
|
def clean(self) -> bool:
|
|
return self.unaccounted == 0 and self.double == 0
|
|
|
|
|
|
def _conservation_held(build: Build) -> bool:
|
|
return build.exit_code == 0 and "K1b FAILED" not in build.log
|
|
|
|
|
|
def account(inventory: Mapping[str, Any], build: Build, corpus: Path) -> list[Unit]:
|
|
"""Give every inventoried element and file its fate, or say it has none."""
|
|
declared_docs = {}
|
|
declared_files = {}
|
|
if build.accounting is not None:
|
|
declared_docs = {d["source_file"]: d for d in build.accounting.get("documents", [])}
|
|
declared_files = {f["source_file"]: f for f in build.accounting.get("files", [])}
|
|
units: list[Unit] = []
|
|
for name, entry in sorted(inventory["documents"].items()):
|
|
elements: dict[str, int] = entry["elements"]
|
|
declared = declared_docs.get(name)
|
|
if declared is None:
|
|
units.append(Unit(name, "document", sum(elements.values()), 0, ["no declared fates"]))
|
|
continue
|
|
unaccounted = double = 0
|
|
notes: list[str] = []
|
|
fates: dict[str, Any] = declared.get("fates", {})
|
|
for element in sorted(set(elements) | set(fates)):
|
|
fate = fates.get(element, {})
|
|
booked = (
|
|
int(fate.get("carried", 0))
|
|
+ int(fate.get("pointer", 0))
|
|
+ sum(int(v) for v in fate.get("rejected", {}).values())
|
|
)
|
|
have = elements.get(element, 0)
|
|
if booked < have:
|
|
unaccounted += have - booked
|
|
notes.append(f"{element}: {booked} booked of {have}")
|
|
elif booked > have:
|
|
double += booked - have
|
|
notes.append(f"{element}: {booked} booked, source has {have}")
|
|
units.append(Unit(name, "document", unaccounted, double, notes))
|
|
for name, entry in sorted(inventory["files"].items()):
|
|
pointed_by = entry["pointed_at_by"]
|
|
# Bytes in assets/ prove a carry only for a file a document points at:
|
|
# an unpointed file with the same bytes (R761 ships 8 such duplicates)
|
|
# was not carried through anything.
|
|
carried = bool(pointed_by) and _sha12(corpus / name) in build.asset_prefixes
|
|
merged = name in build.source_files
|
|
declared = declared_files.get(name)
|
|
notes = []
|
|
false_claim = False
|
|
if declared is not None:
|
|
rejected = declared.get("fate") == "rejected"
|
|
false_claim = declared.get("fate") == "carried" and not carried
|
|
if false_claim:
|
|
notes.append("declared carried, bytes absent from assets/")
|
|
else:
|
|
# K1b: every walked file is merged or a coded rejection, so a file
|
|
# that is not merged was booked as a rejection.
|
|
rejected = not merged and _conservation_held(build)
|
|
fates = sum((carried, merged, rejected))
|
|
if carried and rejected:
|
|
notes.append(f"carried via {', '.join(pointed_by) or 'a document'} AND rejected")
|
|
unaccounted = 1 if fates == 0 or false_claim else 0
|
|
units.append(Unit(name, "file", unaccounted, max(0, fates - 1), notes))
|
|
return units
|
|
|
|
|
|
# --- rows --------------------------------------------------------------------
|
|
|
|
|
|
def row1(table: Iterable[str], inventory: Mapping[str, Any], fresh: Mapping[str, Any]) -> Row:
|
|
table = sorted(table)
|
|
covered: set[str] = set()
|
|
stale: list[str] = []
|
|
for name, entry in inventory["documents"].items():
|
|
if fresh["documents"].get(name) == entry:
|
|
covered.add(entry["suffix"])
|
|
else:
|
|
stale.append(name)
|
|
missing = [t for t in table if t not in covered]
|
|
k = len(table) - len(missing)
|
|
reason = "every README type has a fixture with a reproducible witness count"
|
|
if missing:
|
|
reason = f"no fixture fasit for {', '.join(missing)}"
|
|
details = [f"committed fasit differs from a fresh witness count: {n}" for n in stale]
|
|
return _row(1, "file types with a fasit fixture", k, len(table), reason, details)
|
|
|
|
|
|
def row2(table: Iterable[str], inventory: Mapping[str, Any], build: Build, door: bool) -> Row:
|
|
table = sorted(table)
|
|
name = "source inventory before build"
|
|
if not door:
|
|
return _row(
|
|
2, name, 0, len(table), f"`okf build` has no `{ACCOUNTING_FLAG}` door; no inventory", []
|
|
)
|
|
declared = {d["source_file"]: d for d in (build.accounting or {}).get("documents", [])}
|
|
good: list[str] = []
|
|
details: list[str] = []
|
|
for suffix in table:
|
|
docs = [n for n, e in inventory["documents"].items() if e["suffix"] == suffix]
|
|
ok = bool(docs)
|
|
for doc in docs:
|
|
got = declared.get(doc, {}).get("inventory")
|
|
want = inventory["documents"][doc]["elements"]
|
|
if got != want:
|
|
ok = False
|
|
details.append(f"{doc}: declared {got}, witness {want}")
|
|
if ok:
|
|
good.append(suffix)
|
|
missing = [t for t in table if t not in good]
|
|
reason = (
|
|
"every type's inventory equals the witness"
|
|
if not missing
|
|
else (f"inventory absent or wrong for {', '.join(missing)}")
|
|
)
|
|
return _row(2, name, len(good), len(table), reason, details)
|
|
|
|
|
|
def row3(units: list[Unit], door: bool) -> Row:
|
|
clean = sum(1 for u in units if u.clean)
|
|
u_total = sum(u.unaccounted for u in units)
|
|
d_total = sum(u.double for u in units)
|
|
reason = f"u = {u_total} unaccounted, d = {d_total} double-booked"
|
|
if not door:
|
|
reason += f"; no `{ACCOUNTING_FLAG}` door, so no element has a declared fate"
|
|
details = [
|
|
f"{u.kind} {u.name}: u={u.unaccounted} d={u.double}"
|
|
+ (f" ({'; '.join(u.notes)})" if u.notes else "")
|
|
for u in units
|
|
if not u.clean
|
|
]
|
|
return _row(3, "accounting after build (u = 0 and d = 0)", clean, len(units), reason, details)
|
|
|
|
|
|
def row4(inventory: Mapping[str, Any], build: Build) -> Row:
|
|
name = "a rejected document is reported honestly"
|
|
rejected = [n for n in sorted(inventory["documents"]) if n not in build.source_files]
|
|
if not rejected:
|
|
return _row(4, name, 0, 0, "the fixture was not rejected; the row cannot judge", [])
|
|
declared_images = sum(len(e["images"]) for e in inventory["documents"].values())
|
|
found = re.search(r"\*\*Images\*\*: (\d+) carried of (\d+) found", build.log)
|
|
good = 0
|
|
details: list[str] = []
|
|
for doc in rejected:
|
|
total = sum(inventory["documents"][doc]["elements"].values())
|
|
line = re.compile(
|
|
rf"{re.escape(doc)}: {total} elements found in the source, 0 carried: "
|
|
r"document rejected `[a-z_]+`"
|
|
)
|
|
problems = []
|
|
if not line.search(build.log):
|
|
problems.append(f"no line '{doc}: {total} elements found in the source, 0 carried'")
|
|
if found is None:
|
|
problems.append("no Images bullet")
|
|
elif int(found.group(2)) != declared_images:
|
|
problems.append(
|
|
f"log says {found.group(1)} carried of {found.group(2)} found; "
|
|
f"the source declares {declared_images}"
|
|
)
|
|
if problems:
|
|
details.extend(f"{doc}: {p}" for p in problems)
|
|
else:
|
|
good += 1
|
|
reason = (
|
|
"every rejected document names what it held"
|
|
if good == len(rejected)
|
|
else (f"{len(rejected) - good} rejected document(s) reported as if they held less")
|
|
)
|
|
return _row(4, name, good, len(rejected), reason, details)
|
|
|
|
|
|
def compare(left: Mapping[str, int] | None, right: Mapping[str, int] | None) -> list[str]:
|
|
"""Disagreements between two witnesses, both numbers kept."""
|
|
if left is None or right is None:
|
|
return ["a witness is unavailable"]
|
|
return [
|
|
f"{e}: {left[e]} vs {right[e]}"
|
|
for e in sorted(set(left) & set(right))
|
|
if left[e] != right[e]
|
|
]
|
|
|
|
|
|
def row5(pairs: list[tuple[str, list[str]]], notes: list[str]) -> Row:
|
|
good = sum(1 for _, problems in pairs if not problems)
|
|
details = [f"{label}: {'; '.join(p)}" for label, p in pairs if p] + notes
|
|
reason = (
|
|
"both witnesses agree on every element"
|
|
if good == len(pairs)
|
|
else (f"{len(pairs) - good} pair(s) disagree")
|
|
)
|
|
return _row(5, "two witnesses agree", good, len(pairs), reason, details)
|
|
|
|
|
|
def witness_pairs(r761: Path | None) -> tuple[list[tuple[str, list[str]]], list[str]]:
|
|
sts_xml, _, _ = witness.count_sts_xml(STS_FIXTURE.read_bytes())
|
|
pairs = [
|
|
(
|
|
"sts fixture (xml | json)",
|
|
compare(sts_xml, witness.count_sts_json(STS_TWIN.read_bytes())),
|
|
),
|
|
(
|
|
"pdf fixture (pdfplumber | poppler)",
|
|
compare(witness.pdf_objects(PDF_FIXTURE), witness.pdf_poppler(PDF_FIXTURE)),
|
|
),
|
|
]
|
|
notes: list[str] = []
|
|
if r761 is None or not r761.is_dir():
|
|
notes.append("R761 pairs not measured: source missing")
|
|
return pairs, notes
|
|
with zipfile.ZipFile(r761 / R761_ZIP) as archive:
|
|
xml_name = next(n for n in archive.namelist() if n.endswith(".xml"))
|
|
r761_xml, _, _ = witness.count_sts_xml(archive.read(xml_name))
|
|
pairs.append(
|
|
(
|
|
"R761 sts (xml | json)",
|
|
compare(r761_xml, witness.count_sts_json((r761 / R761_JSON).read_bytes())),
|
|
)
|
|
)
|
|
pdf = r761 / R761_PDF
|
|
pairs.append(
|
|
(
|
|
"R761 pdf (pdfplumber | poppler)",
|
|
compare(witness.pdf_objects(pdf), witness.pdf_poppler(pdf)),
|
|
)
|
|
)
|
|
return pairs, notes
|
|
|
|
|
|
#: The two builds row 6 runs: the default gate is what a user gets; `none`
|
|
#: persists the document, which is the only way its pictures are carried and
|
|
#: the double booking of the files beside it becomes visible.
|
|
R761_GATES: tuple[str | None, ...] = (None, "none")
|
|
|
|
|
|
def row6(r761: Path | None, ci: bool) -> Row:
|
|
"""R761 through two builds; a unit is clean only if it is clean in both."""
|
|
name = "real corpus: R761 Prosesskoden:2025"
|
|
if r761 is None or not r761.is_dir():
|
|
status = SKIPPED if ci else RED
|
|
return Row(6, name, 0, 0, status, f"not measured, source missing: {r761}")
|
|
door = door_available()
|
|
runs: list[tuple[str, Build, list[Unit]]] = []
|
|
with zipfile.ZipFile(r761 / R761_ZIP) as archive, tempfile.TemporaryDirectory() as tmp:
|
|
inbox = Path(tmp) / "inbox"
|
|
archive.extractall(inbox)
|
|
inventory = witness.witness_inbox(inbox)
|
|
json_counts = witness.count_sts_json((r761 / R761_JSON).read_bytes())
|
|
for index, gate_name in enumerate(R761_GATES):
|
|
build = run_build(inbox, Path(tmp) / f"work{index}", door=door, gate=gate_name)
|
|
runs.append((gate_name or "default", build, account(inventory, build, inbox)))
|
|
files = inventory["files"]
|
|
pointed = sum(1 for f in files.values() if f["pointed_at_by"])
|
|
documents = inventory["documents"]
|
|
details = [
|
|
f"witness (json): {json.dumps(json_counts, sort_keys=True)}",
|
|
f"zip: {len(documents)} document(s), {len(files)} other files "
|
|
f"({pointed} pointed at, {len(files) - pointed} not)",
|
|
]
|
|
reasons = []
|
|
for label, build, units in runs:
|
|
persisted = sum(1 for d in documents if d in build.source_files)
|
|
u_total = sum(u.unaccounted for u in units)
|
|
d_total = sum(u.double for u in units)
|
|
reasons.append(f"gate {label}: u = {u_total}, d = {d_total}")
|
|
details.append(
|
|
f"gate {label}: exit {build.exit_code}, {persisted} of {len(documents)} "
|
|
f"document(s) persisted, {len(build.asset_prefixes)} asset file(s)"
|
|
)
|
|
for unit in units:
|
|
if unit.kind == "document":
|
|
details.append(
|
|
f" {unit.name}: u={unit.unaccounted} d={unit.double} ({'; '.join(unit.notes)})"
|
|
)
|
|
doubled = [u for u in units if u.kind == "file" and u.double]
|
|
if doubled:
|
|
details.append(
|
|
f" {len(doubled)} file(s) carried through the document AND rejected, "
|
|
f"e.g. {doubled[0].name}"
|
|
)
|
|
clean = sum(1 for parts in zip(*(units for _, _, units in runs)) if all(u.clean for u in parts))
|
|
total = len(runs[0][2])
|
|
return _row(6, name, clean, total, f"{'; '.join(reasons)} over {total} units", details)
|
|
|
|
|
|
def row7(workdir: Path) -> Row:
|
|
"""Diagnostic only: a question through `okf consume` on the fixture bundle."""
|
|
from llm_ingestion_okf import cli
|
|
|
|
bundle = workdir / "bundle"
|
|
out = workdir / "payload.json"
|
|
sink = io.StringIO()
|
|
with contextlib.redirect_stdout(sink), contextlib.redirect_stderr(sink):
|
|
try:
|
|
code = cli.main(
|
|
["consume", str(bundle), "--question", CONSUME_QUESTION, "--out", str(out)]
|
|
)
|
|
except SystemExit as exc:
|
|
code = exc.code if isinstance(exc.code, int) else 2
|
|
excerpts = 0
|
|
if out.is_file():
|
|
excerpts = len(json.loads(out.read_text(encoding="utf-8")).get("excerpts", []))
|
|
return Row(
|
|
7,
|
|
"question set via okf consume (diagnostic)",
|
|
excerpts,
|
|
0,
|
|
DIAGNOSTIC,
|
|
f"exit {code}, {excerpts} excerpt(s) for {CONSUME_QUESTION!r}",
|
|
)
|
|
|
|
|
|
# --- the run -----------------------------------------------------------------
|
|
|
|
|
|
def evaluate(*, r761: Path | None, ci: bool, consume: bool) -> list[Row]:
|
|
table = readme_types()
|
|
inventory = load_inventory(INVENTORY)
|
|
rejected_inventory = load_inventory(REJECTED_INVENTORY)
|
|
fresh = witness.witness_inbox(CORPUS)
|
|
door = door_available()
|
|
rows = [row1(table, inventory, fresh)]
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
work = Path(tmp) / "corpus"
|
|
build = run_build(CORPUS, work, door=door)
|
|
rows.append(row2(table, inventory, build, door))
|
|
rows.append(row3(account(inventory, build, CORPUS), door))
|
|
rejected_build = run_build(REJECTED, Path(tmp) / "rejected", door=door)
|
|
rows.append(row4(rejected_inventory, rejected_build))
|
|
rows.append(row5(*witness_pairs(r761)))
|
|
rows.append(row6(r761, ci))
|
|
if consume:
|
|
rows.append(row7(work))
|
|
return rows
|
|
|
|
|
|
def render(rows: list[Row]) -> str:
|
|
lines = ["row | k of M | status | reason"]
|
|
for row in rows:
|
|
count = "n/a" if row.status == DIAGNOSTIC else f"{row.k} of {row.m}"
|
|
lines.append(f"{row.number} {row.name} | {count} | {row.status} | {row.reason}")
|
|
lines.extend(f" - {d}" for d in row.details)
|
|
lines += ["", "exceptions (PROPOSED, NOT APPROVED -- none lowers a denominator):"]
|
|
for item in PROPOSED_EXCEPTIONS:
|
|
lines.append(
|
|
f" - {item['suffix']} {item['element']}: {item['reason']}; "
|
|
f"carried instead: {item['carried_instead']}"
|
|
)
|
|
lines.append(f"approved exceptions: {len(APPROVED_EXCEPTIONS)}")
|
|
failing = [str(r.number) for r in rows if r.fails]
|
|
lines.append("")
|
|
lines.append(
|
|
f"GATE {'RED' if failing else 'GREEN'}"
|
|
+ (f": rows {', '.join(failing)}" if failing else "")
|
|
)
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0])
|
|
parser.add_argument("--json", action="store_true", help="emit the rows as JSON")
|
|
parser.add_argument(
|
|
"--r761",
|
|
type=Path,
|
|
default=R761_DEFAULT,
|
|
help="directory holding the R761 zip, JSON and PDF (read only)",
|
|
)
|
|
parser.add_argument(
|
|
"--consume", action="store_true", help="also run row 7 (diagnostic, never fails)"
|
|
)
|
|
args = parser.parse_args(argv)
|
|
try:
|
|
rows = evaluate(r761=args.r761, ci=bool(os.environ.get("CI")), consume=args.consume)
|
|
except (OSError, ValueError, KeyError) as exc:
|
|
print(f"okf-accounting-gate: did not run: {exc}", file=sys.stderr)
|
|
return 2
|
|
if args.json:
|
|
payload = {
|
|
"rows": [r.to_json() for r in rows],
|
|
"exceptions": {
|
|
"approved": sorted(APPROVED_EXCEPTIONS),
|
|
"proposed": PROPOSED_EXCEPTIONS,
|
|
},
|
|
"gate": RED if any(r.fails for r in rows) else GREEN,
|
|
}
|
|
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
else:
|
|
print(render(rows), end="")
|
|
return 1 if any(r.fails for r in rows) else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|