llm-ingestion-okf/src/llm_ingestion_okf/quality.py
Kjell Tore Guttormsen bf697bfcad
fix(consume): every read path into a bundle is contained, not just okf_fetch
`okf_fetch` resolved a concept through `connectors.safe_resolve` from the day
the server was written. The other three ways into the same bytes did not.
`okf consume` and `okf_ask` reach `consume.build_payload`, `okf_describe`
reaches `mcp_server.card`, and both built the concept path by joining the
index's own name onto the bundle root. `consume._join` refuses a `..` segment
and an absolute target, but it is a STRING rule over the index text, and a
symlink is a fact about the filesystem that reading that text cannot see: the
index could name `lekkasje.md`, that name could be a link to a file outside the
bundle, and the file came back in the answer.

Measured before the fix, on a bundle carrying one honest concept and one
escaping link: 8 of 11 new rows red, the 3 green ones being `okf_fetch` on the
same two links and the known-positive that the clean bundle still answers. So
the suite was not red for an unrelated reason, and the fix is not "refuse every
bundle holding a link".

One place, not three copies: `consume.resolve_in_bundle` makes the check and
`consume.read_path_in_bundle` adds the file's presence. Every reader here goes
through them -- the index walk, the ref, the document prior, the payload, the
card, `okf_fetch`, and the three outside `consume` (`skill`, `quality`,
`project`) that joined the same way.

Two more failure modes in the same check, because they are the same question:

* A NAMED PIPE is not a regular file. `read_text` on one blocks for as long as
  nobody writes to it, which on a server is the whole process; the red row for
  it ran 60 s to a subprocess deadline and now returns in under a second.
* A DEAD INDEX LINK raised `FileNotFoundError`, and the broad handler in
  `handle` wrote `{error}` into the refusal -- the SERVER's absolute path,
  handed to whoever asked, over one index entry naming a file nobody wrote.
  It is `concept_unreadable` now, naming the concept and not the machine.

The returned path is the JOINED one, never the resolved one: `read_concept`
derives a concept id by taking the read path relative to the bundle root, and
once containment holds the two are the same bytes.

2334 passed, 2 skipped (was 2323 + 2). `mypy --strict src/` clean over 25 files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 15:20:38 +02:00

708 lines
28 KiB
Python

"""`okf quality` -- a per-file-type verdict on one bundle, with the denominator.
**This is not `okf check`, and the separation is the point.** `okf check` reads
a consumption skill and one payload against `docs/consumption-contract.md`: it
answers whether a payload carries what a claim must rest on. Measured
2026-09-10 by `vegnormal-okf` on three arms over one corpus, it returned 0
findings and exit 0 on all three while their hit@k ranged from 6 of 6 to 0 of 6
-- a green contract check says nothing about whether the cut found anything
worth reading. This module asks that second question, and it is a SEPARATE
command rather than a `--quality` flag on the first for exactly that reason: the
two answer different questions and a caller must not be able to read one as the
other.
Three verdicts and no fourth: `PASS`, `FAIL`, `UNMEASURED`. A type with no
measured threshold is never `PASS` -- an unmeasured row that reads as a passing
one is the failure this gate exists to prevent, and it is the same failure
`extract._EVIDENCE` was built to prevent one layer down.
**What this gate can and cannot see.** Every metric here is computed from the
bundle alone: no fasit, no model call, no clock, no network. That bounds it
sharply, and the bound is measured rather than assumed.
`docs/2026-09-12-g37-terskler.md` SS 4 records three candidates measured over
the same four bundles and what became of each: duplicate titles WITHIN a
document (0 of 3 206 on the known-bad arm against 349 of 2 761 on the known-good
one -- the wrong direction) and the share of very short concepts (5.6 % against
14.6 % -- also the wrong direction) are not shipped; duplicate titles across the
WHOLE bundle order the four bundles correctly (37.8 / 16.3 / 12.6 / 5.7 %) and
are still not shipped, because a bar separating them would have to be placed
between the two bundles that define it, which is fitting the bar to the number.
The defect that started this work -- 1 148 of 2 761 declared boundaries
recovered -- needs a fasit and no bundle-only metric reaches it.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from .consume import (
ConsumeError,
enumerate_concepts,
read_concept,
read_path_in_bundle,
root_bundle_id_of,
)
from .corpus import LOG_NAME
from .profiles import SEGMENTED_OKF_V0_2, BundleProfile
CLI_ID = "okf quality"
#: The row a concept lands in when it declares no `source_file`. Not a file
#: type and never treated as one: measured 2026-09-12, three of the four
#: evidence corpora (`n100-2023`, `n200-2024`, `n500-2024`) carry the key on 0
#: of 446, 0 of 1 133 and 0 of 270 concepts, because their producer is not this
#: library's Door B. A per-file-type gate has nothing to say about them, and
#: says that.
NO_SOURCE_FILE = "(no source_file)"
#: A threshold needs a denominator big enough that a single document cannot be
#: the rate. FIVE, and the number is this repository's own honesty limit rather
#: than a statistical claim: `docs/2026-09-08-k3-runde2-per-filtype.md` states
#: "Per file type the denominators are 8, 3 and 1. A `1/1` is not a rate", and
#: `docs/2026-09-04-k3-arm-c.md` says of the three office types with no corpus
#: file at all: "Unmeasured, not passing." Below this floor the row is
#: `UNMEASURED` and its numbers are still printed.
#:
#: It binds BOTH denominators -- the threshold's and the bundle's. Found by
#: running the gate rather than by reading it: one PDF cut into 2 182 concepts
#: scored 0 of 1 against the 32-document reference and read as PASS.
MIN_DOCUMENTS_FOR_A_THRESHOLD = 5
@dataclass(frozen=True)
class Threshold:
"""One measured bar, carrying the measurement it was read off.
The bar is held as the measured PAIR (`limit_null` of `limit_documents`)
rather than a float, so the comparison is exact integer arithmetic and a
bundle sitting exactly at the reference cannot fall to a rounding step.
"""
metric: str
limit_null: int
limit_documents: int
#: Documents behind the measurement. Equal to `limit_documents` today and
#: kept separate because a threshold ratified over a wider corpus than the
#: one it is expressed as would need both numbers.
documents: int
source: str
def exceeded_by(self, null: int, documents: int) -> bool:
"""`null/documents` strictly worse than the reference, without floats."""
return null * self.limit_documents > self.limit_null * documents
def as_share(self) -> str:
return f"{self.limit_null}/{self.limit_documents}"
#: The bars, per extension, and there are two of them. Read off the pinned
#: reference bundle `K2-bundle-default-20260912` (the 43-document corpus
#: `~/corpora/okf-telling-20260829/K2/trinn1`, N = 43, 39 merged) on
#: 2026-09-12, and set at the value measured there rather than at a rounder
#: number nearby: this is a REGRESSION bar against a pinned artifact, not a
#: claim that a bundle at the bar is good. `docs/2026-09-12-g37-terskler.md`
#: carries the table, the corpora and what each number does not prove.
#:
#: Every other type is absent on purpose. `.xlsx` (2 documents) and `.xml`
#: (1 document) are below the floor above; `.html` has no bundle measured in
#: this repository; `.md`, `.txt`, `.csv`, `.json`, `.htm`, `.pptx`, `.odt`
#: and `.rtf` have no corpus class in `extract._EVIDENCE` at all.
THRESHOLDS: dict[str, Threshold] = {
".pdf": Threshold(
metric="structure_null_share",
limit_null=8,
limit_documents=32,
documents=32,
source="K2-bundle-default-20260912 (43-document corpus, 32 pdf documents)",
),
".docx": Threshold(
metric="structure_null_share",
limit_null=2,
limit_documents=5,
documents=5,
source="K2-bundle-default-20260912 (43-document corpus, 5 docx documents)",
),
}
#: The one bar that needs no corpus: a concept whose body holds no
#: non-whitespace character. Taken from the harness's own definition of a
#: degenerate merge (`corpus.CorpusReport.render`: "a merge is degenerate when
#: the extracted text is zero characters after stripping whitespace -- a
#: definition, not a threshold"), so it applies to every type INCLUDING one with
#: no threshold. FAIL is reachable for every row; PASS is not.
EMPTY_BODY_LIMIT = 0
#: The floor again, in the fasit's own unit. A share over four declared
#: boundaries is not a rate any more than a share over four documents is, and
#: the number is the same honesty limit rather than a second one: it exists to
#: refuse a degenerate fasit, not to rate a corpus.
MIN_DECLARED_FOR_A_THRESHOLD = MIN_DOCUMENTS_FOR_A_THRESHOLD
class FasitError(ValueError):
"""An unreadable or malformed fasit. A run that did not happen, never a verdict.
A `ValueError`, so `main`'s existing handler turns it into exit 2: the one
thing this must never become is a quiet `UNMEASURED` row, which reads as
"no threshold for this" when the truth is "the input was broken".
"""
_WHITESPACE = re.compile(r"\s+")
#: The numbering token STS glues onto the front of a `<title>` ("11.1Fastmerker").
_NUMBERING_TOKEN = re.compile(r"^\s*(\d+(?:\.\d+)*)\s*")
def normalise_title(value: str) -> str:
"""Strip ALL whitespace, then lowercase -- the key the fasit is written on.
Not a guess and not this module's invention: measured over the shipped
2 761-row fasit before any of this was written, the rule reproduces every
row's own `norm` from its own `title`, **2 761 of 2 761**. It is also the
normalisation `vegnormal-okf`'s measuring script applies, so a number
produced here and a number produced there are the same number.
"""
return _WHITESPACE.sub("", value).lower()
def _split_numbering(title: str) -> tuple[str, str]:
"""`("11.1", "Fastmerker")`, or `("", title)` when there is no token."""
match = _NUMBERING_TOKEN.match(title)
return (match.group(1), title[match.end() :].strip()) if match else ("", title.strip())
@dataclass(frozen=True)
class DeclaredBoundary:
"""One boundary the source itself declares, in the two forms it can be met in."""
title: str
norm: str
@property
def pair_key(self) -> tuple[str, str]:
"""`(numbering token as a directory segment, normalised residual title)`."""
number, rest = _split_numbering(self.title)
return (number.replace(".", "-"), normalise_title(rest))
def load_fasit(path: Path) -> tuple[DeclaredBoundary, ...]:
"""The declared boundaries, or a refusal naming what the file is instead.
Validated at the door rather than trusted: a list, every element a mapping,
every mapping carrying `title` and `norm` as strings. Anything else raises,
and `main` turns that into exit 2 with the reason on stderr.
"""
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise FasitError(f"{path} is not JSON: {exc}") from exc
if not isinstance(raw, list):
raise FasitError(f"{path} is a {type(raw).__name__}, not a list of declared boundaries")
rows: list[DeclaredBoundary] = []
for index, entry in enumerate(raw):
if not isinstance(entry, dict):
raise FasitError(f"{path} row {index} is a {type(entry).__name__}, not an object")
title, norm = entry.get("title"), entry.get("norm")
if not isinstance(title, str) or not isinstance(norm, str):
raise FasitError(
f"{path} row {index} carries no `title` and `norm` pair of strings; "
"every row must name the boundary and the key it is matched on"
)
rows.append(DeclaredBoundary(title=title, norm=norm))
if not rows:
raise FasitError(f"{path} declares no boundaries at all")
return tuple(rows)
@dataclass(frozen=True)
class BoundaryThreshold:
"""The one bar that needs a fasit, and the only one pinned to a single product.
Held as the measured PAIR, like every other bar here, so the comparison is
exact integer arithmetic.
"""
metric: str
limit_recovered: int
limit_declared: int
#: Products behind the measurement. **One**, and it is printed on the row
#: rather than only recorded here: the fasit is R761's own NISO-STS
#: structure, so a bar read off it is pinned to one corpus and says nothing
#: about a document nobody has a declared structure for.
corpora: int
source: str
def undercut_by(self, recovered: int, declared: int) -> bool:
"""`recovered/declared` strictly below the reference, without floats."""
return recovered * self.limit_declared < self.limit_recovered * declared
def as_share(self) -> str:
return f"{self.limit_recovered}/{self.limit_declared}"
#: Measured 2026-09-13 on `~/repos/vegnormal-okf/build/ferdig/r761-2025-generisk`,
#: the declared-structure (`.xml`) arm of R761 Prosesskoden:2025, against that
#: publisher's own 2 761 titled `<sec>` elements. Set at the value measured
#: there rather than at a rounder number nearby, exactly like the two bars
#: above -- a REGRESSION bar against a pinned artifact, and a tight one:
#: `docs/2026-09-12-g37-terskler.md` SS 7 records that an older build of the
#: same product (2 752 of 2 761) reads FAIL under it, and says so rather than
#: moving the bar to admit it.
BOUNDARY_THRESHOLD = BoundaryThreshold(
metric="boundary_share",
limit_recovered=2759,
limit_declared=2761,
corpora=1,
source=(
"r761-2025-generisk against sk2-fasit-2761.json (R761 Prosesskoden:2025, "
"2 761 declared STS sections) -- ONE product, N = 1 corpus"
),
)
#: Printed on every boundary row, because P2 of the order that asked for this
#: is a property of the number and not a footnote to it.
SINGLE_CORPUS_CAVEAT = (
"the bar rests on one product, N = 1 corpus, and --fasit is the caller's "
"ASSERTION that this bundle is a build of the document the fasit describes"
)
PASS = "PASS"
FAIL = "FAIL"
UNMEASURED = "UNMEASURED"
_LOG_LINE = re.compile(
r"N = (\d+).*?merged = (\d+).*?coded rejections = (\d+)",
re.DOTALL,
)
@dataclass(frozen=True)
class TypeReport:
"""One file type's numbers and its verdict. Every count carries its own N."""
extension: str
documents: int
concepts: int
empty: int
structure_null: int
verdict: str
threshold: Threshold | None
reason: str
def render(self) -> str:
"""One line, and every count on it carries its own denominator.
The `NO_SOURCE_FILE` row prints neither a document count nor a
one-concept share: those concepts all share the same empty
`source_file`, so grouping by it yields `documents 1` for a bundle of
446 -- a number that looks measured and means nothing.
"""
head = f"{self.extension:<18} {self.verdict:<11} "
if self.extension == NO_SOURCE_FILE:
return (
f"{head}concepts {self.concepts:>5} empty {self.empty}/{self.concepts} "
f"-- {self.reason}"
)
share = f"{self.structure_null}/{self.documents}"
bar = f"limit {self.threshold.as_share()}" if self.threshold else "no threshold"
return (
f"{head}documents {self.documents:>5} "
f"concepts {self.concepts:>5} empty {self.empty}/{self.concepts} "
f"one-concept documents {share} ({bar}) -- {self.reason}"
)
@dataclass(frozen=True)
class BoundaryReport:
"""How many boundaries the source declares became a concept, and by which form.
**Whole bundle, never per file type.** The fasit names the sections of ONE
document; in a bundle those can be spread over 828 source files (they are,
on the arm this metric was built to fell), so attributing the share to a
file type would put a product's number in a type's row.
Both match forms are counted separately and printed, because the
decomposition is the finding: on the known-good arm the literal form alone
reaches 22 of 2 761 and the pair form 2 737, so a gate scoring only the
first would report a 99.9 % arm as 0.8 % and call it a segmentation defect.
"""
declared: int
recovered: int
#: Declared boundaries met by a concept whose normalised title equals the
#: fasit's `norm` -- the form the declared-structure route produces.
literal: int
#: Declared boundaries met by the `(directory, residual title)` pair -- the
#: form okf's default route produces, having moved the numbering token into
#: the concept id.
paired: int
verdict: str
threshold: BoundaryThreshold | None
reason: str
def render(self) -> str:
bar = f"limit {self.threshold.as_share()}" if self.threshold else "no threshold"
return (
f"{'boundary_share':<18} {self.verdict:<11} "
f"recovered {self.recovered}/{self.declared} ({bar}) "
f"literal {self.literal}/{self.declared} paired {self.paired}/{self.declared}"
f" -- {self.reason}"
)
@dataclass(frozen=True)
class BundleQuality:
"""One bundle's rows, its run log if it has one, and the exit code they imply."""
bundle_root: Path
bundle_id: str
rows: tuple[TypeReport, ...]
#: The `N`, merged and coded-rejection counts from the bundle's own section
#: 9 log, or `None` when the bundle carries no log. Never defaulted to zero:
#: a rejected document leaves NO concept in the bundle, so without the log
#: the gate cannot know whether a type failed to extract entirely.
run_log: str | None
#: The whole-bundle boundary row, or `None` when no `--fasit` was given.
#: `None` is the untouched gate: without a fasit this command is exactly
#: what it was, and a test holds that.
boundaries: BoundaryReport | None = None
def row(self, extension: str) -> TypeReport:
for row in self.rows:
if row.extension == extension:
return row
raise KeyError(
f"{extension} is not a row of this bundle: {[r.extension for r in self.rows]}"
)
@property
def exit_code(self) -> int:
"""0 judged and clean, 1 at least one FAIL, 3 nothing could be judged.
`2` is reserved for "did not run" and is returned by `main` alone. The
third code exists because exit 0 over a table of `UNMEASURED` rows would
be exactly the silent pass this gate was built to stop.
"""
verdicts = [row.verdict for row in self.rows]
if self.boundaries is not None:
verdicts.append(self.boundaries.verdict)
if FAIL in verdicts:
return 1
if PASS in verdicts:
return 0
return 3
def render(self) -> str:
lines = [
f"# {CLI_ID}: {self.bundle_id}",
"",
f"bundle: {self.bundle_root}",
(
f"run log: {self.run_log}"
if self.run_log is not None
else f"run log: no run log in the bundle ({LOG_NAME} absent) -- the "
"denominators below are the bundle's own, and a document rejected "
"at extraction leaves no row here at all"
),
"",
"## Per file type",
"",
]
lines.extend(row.render() for row in self.rows)
if self.boundaries is not None:
lines.extend(
[
"",
"## Boundary recall against the fasit",
"",
self.boundaries.render(),
"",
f"({SINGLE_CORPUS_CAVEAT})",
]
)
lines.extend(
[
"",
"## What this verdict is not",
"",
"A regression bar against a pinned reference bundle, per file type.",
"PASS means no worse than that reference on the metrics below; it is",
"not a claim that the cut found the document's own structure.",
(
"Boundary recall is measured above, against ONE product's declared"
if self.boundaries is not None
else "Boundary recall needs a fasit (--fasit) and hit@k needs a"
),
(
"structure; hit@k still needs a question set and is not asked here."
if self.boundaries is not None
else "question set as well; neither is asked by a bundle-only run."
),
"docs/2026-09-12-g37-terskler.md carries the measurements that say so.",
]
)
return "\n".join(lines) + "\n"
def _extension_of(source_file: str) -> str:
if not source_file.strip():
return NO_SOURCE_FILE
suffix = Path(source_file).suffix.lower()
return suffix if suffix else NO_SOURCE_FILE
def read_run_log(bundle_root: Path) -> str | None:
"""The bundle's own `N`, merged and coded-rejection counts, or `None`."""
log = bundle_root / LOG_NAME
if not log.is_file():
return None
matches = _LOG_LINE.findall(log.read_text(encoding="utf-8"))
if not matches:
return None
total, merged, rejected = matches[-1]
return f"N = {total}, merged = {merged}, coded rejections = {rejected}"
def measure_bundle(
bundle_root: Path,
*,
profile: BundleProfile = SEGMENTED_OKF_V0_2,
fasit: tuple[DeclaredBoundary, ...] | None = None,
) -> BundleQuality:
"""Every concept the index declares, grouped by the extension it came from.
Reached through the index tree and never `rglob`: the index is the bundle's
own statement of what it contains, and `consume.enumerate_concepts` is the
one walker in this library that reads it. Controlled 2026-09-12 against the
directory listing on four bundles -- 453, 2 761, 3 206 and 446 concepts
either way.
"""
root_bundle_id = root_bundle_id_of(bundle_root, profile=profile)
concepts_per_extension: Counter[str] = Counter()
empty_per_extension: Counter[str] = Counter()
documents: dict[str, Counter[str]] = {}
titles: set[str] = set()
pairs: set[tuple[str, str]] = set()
for concept_id in enumerate_concepts(bundle_root, profile=profile):
concept = read_concept(
read_path_in_bundle(bundle_root, f"{concept_id}{profile.paths.concept_suffix}"),
bundle_root=bundle_root,
root_bundle_id=root_bundle_id,
)
extension = _extension_of(concept.source_file)
concepts_per_extension[extension] += 1
documents.setdefault(extension, Counter())[concept.source_file] += 1
if not "".join(concept.body.split()):
empty_per_extension[extension] += 1
if fasit is not None:
normalised = normalise_title(concept.title)
titles.add(normalised)
pairs.add((_enclosing_directory(concept_id), normalised))
rows = tuple(
_verdict(
extension,
documents=documents[extension],
concepts=concepts_per_extension[extension],
empty=empty_per_extension[extension],
)
for extension in sorted(concepts_per_extension)
)
return BundleQuality(
bundle_root=bundle_root,
bundle_id=root_bundle_id,
rows=rows,
run_log=read_run_log(bundle_root),
boundaries=None if fasit is None else _boundary_verdict(fasit, titles=titles, pairs=pairs),
)
def _enclosing_directory(concept_id: str) -> str:
"""The concept's own immediate directory, or `""` at the bundle root.
The segment okf's default route writes the numbering token into
(`11-1/p3`), which is the half of the pair key the bundle side supplies.
"""
if "/" not in concept_id:
return ""
return concept_id.rsplit("/", 1)[0].rsplit("/", 1)[-1]
def _boundary_verdict(
fasit: tuple[DeclaredBoundary, ...],
*,
titles: set[str],
pairs: set[tuple[str, str]],
) -> BoundaryReport:
"""One declared boundary is recovered when EITHER match form meets it.
Both forms are needed and neither is a fallback for a defect in the other:
the literal form wants the declared title WITH its numbering token, the pair
form wants it WITHOUT, and no bundle can offer both. Scoring one alone
reports the other route's segmentation as near zero -- measured, 22 of 2 761
against 2 737 of 2 761 on the same arm.
"""
declared = len(fasit)
literal = sum(1 for row in fasit if row.norm in titles)
paired = sum(1 for row in fasit if row.pair_key in pairs)
recovered = sum(1 for row in fasit if row.norm in titles or row.pair_key in pairs)
bar = BOUNDARY_THRESHOLD
if declared < MIN_DECLARED_FOR_A_THRESHOLD:
return BoundaryReport(
declared=declared,
recovered=recovered,
literal=literal,
paired=paired,
verdict=UNMEASURED,
threshold=None,
reason=(
f"{declared} declared boundary/boundaries, below the floor of "
f"{MIN_DECLARED_FOR_A_THRESHOLD}: a share over that few is not a rate"
),
)
if bar.undercut_by(recovered, declared):
return BoundaryReport(
declared=declared,
recovered=recovered,
literal=literal,
paired=paired,
verdict=FAIL,
threshold=bar,
reason=(
f"{recovered} of {declared} declared boundaries became a concept, "
f"below the reference {bar.as_share()} ({bar.source})"
),
)
return BoundaryReport(
declared=declared,
recovered=recovered,
literal=literal,
paired=paired,
verdict=PASS,
threshold=bar,
reason=f"no worse than the reference {bar.as_share()} ({bar.source})",
)
def _verdict(extension: str, *, documents: Counter[str], concepts: int, empty: int) -> TypeReport:
document_count = len(documents)
structure_null = sum(1 for count in documents.values() if count == 1)
threshold = THRESHOLDS.get(extension)
if extension == NO_SOURCE_FILE and empty <= EMPTY_BODY_LIMIT:
return TypeReport(
extension=extension,
documents=0,
concepts=concepts,
empty=empty,
structure_null=0,
verdict=UNMEASURED,
threshold=None,
reason=(
f"no source_file on {concepts} of {concepts} concepts, so this "
"bundle names no file type at all -- the shape three of the four "
"evidence corpora arrive in, and nothing per file type can be said"
),
)
if empty > EMPTY_BODY_LIMIT:
verdict, reason = (
FAIL,
(
f"{empty} of {concepts} concepts carry no non-whitespace body; the "
"harness calls a zero-character merge degenerate by definition"
),
)
elif threshold is None:
verdict, reason = (
UNMEASURED,
(
"no measured threshold for this type; see "
"docs/2026-09-12-g37-terskler.md, and never read this row as PASS"
),
)
elif document_count < MIN_DOCUMENTS_FOR_A_THRESHOLD:
verdict, reason = (
UNMEASURED,
(
f"{document_count} document(s) of this type in the bundle, below the "
f"floor of {MIN_DOCUMENTS_FOR_A_THRESHOLD}: a share over that few "
"documents is not a rate, whatever the threshold says"
),
)
elif threshold.exceeded_by(structure_null, document_count):
verdict, reason = (
FAIL,
(
f"{structure_null} of {document_count} documents yielded one concept, "
f"worse than the reference {threshold.as_share()} ({threshold.source})"
),
)
else:
verdict, reason = (
PASS,
(f"no worse than the reference {threshold.as_share()} ({threshold.source})"),
)
return TypeReport(
extension=extension,
documents=document_count,
concepts=concepts,
empty=empty,
structure_null=structure_null,
verdict=verdict,
threshold=threshold,
reason=reason,
)
def parse_args(argv: list[str] | None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog=CLI_ID,
description=(
"Judge one bundle per file type, with the denominator. Three verdicts: "
"PASS (no worse than the pinned reference), FAIL, and UNMEASURED -- "
"which is never PASS. With --fasit, one further whole-bundle row: the "
"share of the boundaries the source declares that became a concept. "
"Exit 0 judged and clean, 1 at least one FAIL, 2 did not run, "
"3 nothing could be judged."
),
)
parser.add_argument("bundle", type=Path, help="the OKF bundle to judge")
parser.add_argument(
"--fasit",
type=Path,
default=None,
help=(
"a JSON list of the boundaries the source itself declares, each row "
"carrying `title` and `norm`. Adds one whole-bundle `boundary_share` "
"row and changes nothing else. It is an ASSERTION that this bundle is "
"a build of the document the fasit describes: a bundle of another "
"product scores near zero, which is the assertion being wrong. An "
"unreadable fasit exits 2, never UNMEASURED."
),
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
if not args.bundle.is_dir():
print(f"{CLI_ID}: FAILED - no such bundle: {args.bundle}", file=sys.stderr)
return 2
try:
fasit = None if args.fasit is None else load_fasit(args.fasit)
report = measure_bundle(args.bundle, fasit=fasit)
except (ConsumeError, OSError, ValueError) as exc:
print(f"{CLI_ID}: FAILED - {exc}", file=sys.stderr)
return 2
print(report.render(), end="")
return report.exit_code
if __name__ == "__main__":
raise SystemExit(main())