Rifqi Hafizuddin
[NOTICKET] eval(knowledge): X20 measures net -1; close X23 on expert ruling; instrument X25
e6ed414
Raw History Blame
14.1 kB
"""Knowledge-pipeline eval runner.
Scores an already-parsed document against `knowledge_gold.yaml` and writes a
timestamped result file. The 2026-08-24 figures were assembled by hand; this is
the reproducible version, so a number can be re-derived instead of trusted.
**Takes a ParsedDocument artifact, never a PDF.** MinerU is not invoked and is
not required — that is the seam doing its job. Point it at the `chunks.json`
written by `python -m src.knowledge_parsing.run`.
# free: E1 recall, E2 compression, the funnel. No API calls, no spend.
./.venv/Scripts/python.exe -m eval.knowledge.run_eval <artifact.json>
# add the paid branches: E3 schema fill and rule recall
./.venv/Scripts/python.exe -m eval.knowledge.run_eval <artifact.json> --extract
# exercise every branch with no spend and no credentials (NOT scoreable)
./.venv/Scripts/python.exe -m eval.knowledge.run_eval <artifact.json> --extract --mock
Module mode only (`-m`). A file-path invocation breaks `sys.path` and the
`src.` imports below fail.
Two things this refuses to do, both for the same reason — a number that cannot
be compared is worse than no number:
* **It will not run on an artifact that does not declare `parser_backend`.**
The same MinerU build emits different text from `pipeline` and `hybrid/high`,
so results measured on different backends are not comparable and nothing
downstream can tell them apart afterwards. `--allow-unlabelled-artifact`
overrides, and stamps the result file so the caveat travels with it.
* **It never overwrites a result file.** Every run writes a new timestamped
file beside the others; the frozen baseline stays frozen.
"""
from __future__ import annotations
import argparse
import json
import sys
from collections import Counter
from datetime import datetime
from pathlib import Path
from typing import Any
from src.knowledge_extraction.adapter import parsed_doc_from_artifact
from src.knowledge_extraction.extract import MockExtractor, cacheable, prefix_tokens
from src.knowledge_extraction.service import (
build_clusters,
estimate_cost,
extract_all,
run_filters,
)
from src.knowledge_extraction.settings import EVIDENCE_K
from .score import GOLD_PATH, load_gold, score_glossary, score_rules, score_term_filter
_HERE = Path(__file__).resolve().parent
RESULTS_DIR = _HERE / "results"
# The kill lines. Each is a decision already taken, recorded in
# KNOWLEDGE_PIPELINE_CALIBRATION.md — not a target invented per run.
KILL_LINES = {"E1": 0.70, "E2": 2.0, "E3": 0.80}
# What the last committed run measured, for the delta column. Rule recall has
# no kill line: it is tracked, not gated, and 3/15 is the post-X22 scoring of
# the 2026-08-24 run — NOT the 1/15 in that file, which predates the scorer fix.
BASELINE = {
"file": "v2_full_document_2026-08-24_093051.json",
"E1": 0.8049,
"E2": 2.81,
"E3": 0.90,
"rule_recall": 0.2,
}
def _azure_extractor():
"""Built lazily: importing it requires credentials to be configured."""
from src.knowledge_extraction.extract import AzureExtractor
try:
return AzureExtractor()
except Exception as exc: # noqa: BLE001 - reported, not raised
print(f"cannot build the Azure extractor: {exc!r}", file=sys.stderr)
return None
def _latex_verification_counts(formulas: list[dict]) -> dict[str, int]:
"""How many stored formulas carry which guarantee.
Cheap and free — the marker is set during validation, not by a model call —
and it is the fastest read on whether an artifact's markup is rich enough
to support the guard at all. A run that is all `unverified_operator` was
parsed with a backend that cannot express multiplication.
"""
counts = Counter(f.get("latex_verification") or "none" for f in formulas)
return dict(sorted(counts.items()))
def _verdict(value: float, kill: float | None) -> str:
if kill is None:
return "—"
return "PASS" if value >= kill else "FAIL"
def _delta(value: float, previous: float | None) -> str:
if previous is None:
return "—"
diff = round(value - previous, 4)
return f"{diff:+.4f}"
def build_report(
doc,
filtered,
clustered,
scores: dict[str, Any],
result,
args,
started: datetime,
seconds: float,
) -> dict:
funnel = {
"pages": doc.n_pages,
"chunks": len(doc.chunks),
"mentions_raw": len(filtered.mentions),
"clusters": clustered.n_clusters,
"compression_ratio": clustered.compression_ratio,
"abbrev_pairs": len(filtered.abbrev_pairs),
"rule_candidates": len(filtered.rule_candidates),
}
report: dict[str, Any] = {
"run": {
"created_at": started.strftime("%Y-%m-%d_%H%M%S"),
"doc_id": doc.doc_id,
"artifact": str(args.artifact),
"content_hash": doc.content_hash,
"parser": f"{doc.parser_name}/{doc.parser_version}",
"parser_backend": doc.parser_backend or "UNLABELLED",
"extracted": bool(args.extract),
"mock": bool(args.mock),
"limit": args.limit,
"seconds": round(seconds, 1),
},
"funnel": funnel,
"scores": scores,
"baseline": BASELINE,
}
if args.allow_unlabelled_artifact and not doc.parser_backend:
report["run"]["comparability"] = (
"NOT COMPARABLE — the artifact does not declare parser_backend and the "
"run was forced. Different MinerU backends emit different text."
)
if args.mock:
report["run"]["comparability"] = (
"NOT SCOREABLE — mock extractor. Branch plumbing only; the content is "
"fabricated, so every quality figure here is meaningless."
)
if result is not None:
# Which model actually served this run. A result file without it is a
# measurement of an unknown: on 2026-08-27 identical input scored 6/7
# one day and 0/12 the next under an unchanged deployment name, and
# nothing recorded said whether the model had moved. See X25.
report["run"]["served_by"] = {
"model_version": sorted({u.model_version for u in result.usages if u.model_version}),
"system_fingerprint": sorted(
{u.system_fingerprint for u in result.usages if u.system_fingerprint}
),
}
prompt, cached, completion = result.total_tokens
report["cost"] = {
"prompt_tokens": prompt,
"cached_tokens": cached,
"completion_tokens": completion,
"cache_hit_rate": round(cached / prompt, 3) if prompt else 0.0,
"calls": len(result.usages),
}
report["quality"] = {
"fields_rejected_by_span_check": len(result.rejected),
"rejections": [r.model_dump(mode="json") for r in result.rejected[:20]],
"glossary_entries": len(result.glossary),
"rule_entries": len(result.rules),
"formula_entries": len(result.formulas),
"abstained_null_definition": sum(
1 for e in result.glossary if not e.get("definition")
),
"latex_verification": _latex_verification_counts(result.formulas),
"links": result.links,
}
return report
def format_summary(report: dict) -> str:
rows = []
for key, score in report["scores"].items():
value = score["value"]
kill = KILL_LINES.get(key)
rows.append(
f" {key:<12} {value:>8.4f} kill {kill if kill else ' — '!s:>5} "
f"{_verdict(value, kill):<5} vs baseline {_delta(value, BASELINE.get(key))}"
f" {score['label']}"
)
funnel = report["funnel"]
out = [
"",
f" doc {report['run']['doc_id']} backend {report['run']['parser_backend']}"
f" {funnel['pages']} pages -> {funnel['chunks']} chunks -> "
f"{funnel['mentions_raw']} mentions -> {funnel['clusters']} clusters "
f"({funnel['compression_ratio']}x)",
"",
*rows,
]
if "cost" in report:
cost = report["cost"]
out += [
"",
f" {cost['calls']} calls {cost['prompt_tokens']} prompt tokens, "
f"{cost['cached_tokens']} cached ({cost['cache_hit_rate']:.0%})",
f" {report['quality']['fields_rejected_by_span_check']} fields rejected"
" by the span check",
f" latex_verification: {report['quality']['latex_verification']}",
]
if "comparability" in report["run"]:
out += ["", f" !! {report['run']['comparability']}"]
return "\n".join(out)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Score the knowledge pipeline against knowledge_gold.yaml"
)
parser.add_argument("artifact", type=Path, help="ParsedDocument JSON (chunks.json)")
parser.add_argument("--doc-id", help="override the artifact's doc_id")
parser.add_argument(
"--extract", action="store_true", help="run the PAID branches (E3, rule recall)"
)
parser.add_argument(
"--mock", action="store_true", help="mock extractor: no network, no spend, NOT scoreable"
)
parser.add_argument("--limit", type=int, help="cap items per branch (pilot)")
parser.add_argument("--active-glossary", type=Path, help="glossary to diff against")
parser.add_argument("--gold", type=Path, default=GOLD_PATH)
parser.add_argument("--label", help="short note recorded in the result file")
parser.add_argument(
"--dry-run", action="store_true", help="token estimate only, zero API calls"
)
parser.add_argument(
"--allow-unlabelled-artifact",
action="store_true",
help="run on an artifact with no parser_backend (results NOT comparable)",
)
args = parser.parse_args(argv)
if not args.artifact.exists():
print(f"artifact not found: {args.artifact}", file=sys.stderr)
return 2
raw = json.loads(args.artifact.read_text(encoding="utf-8"))
doc = parsed_doc_from_artifact(raw, doc_id=args.doc_id, source_ref=str(args.artifact))
if not doc.parser_backend and not args.allow_unlabelled_artifact:
print(
f"artifact does not declare parser_backend: {args.artifact}\n"
" A score measured on one MinerU backend is not comparable to one\n"
" measured on another, and nothing downstream can tell them apart\n"
" afterwards. Re-export from `src.knowledge_parsing.run`, or pass\n"
" --allow-unlabelled-artifact to record the result as uncomparable.",
file=sys.stderr,
)
return 2
started = datetime.now()
gold = load_gold(args.gold)
filtered = run_filters(doc)
clustered = build_clusters(doc, filtered)
if args.dry_run:
est = estimate_cost(doc, clustered, filtered, args.limit)
print("[dry-run] NO API CALLS MADE")
for key, value in est.items():
print(f" {key}: {value}")
for branch in ("glossary", "rule", "formula", "summary"):
print(
f" prefix[{branch}]: {prefix_tokens(branch)} tokens, "
f"cacheable={cacheable(branch)}"
)
return 0
e1 = score_term_filter(gold, [m.surface for m in filtered.mentions])
scores: dict[str, Any] = {
"E1": {
"label": "term-filter recall",
"value": e1.recall,
"detail": e1.as_dict(),
},
"E2": {
"label": "review-burden compression",
"value": clustered.compression_ratio,
"detail": {
"mentions": clustered.n_mentions,
"clusters": clustered.n_clusters,
},
},
}
result = None
if args.extract:
extractor = MockExtractor() if args.mock else _azure_extractor()
if extractor is None:
return 3
active = (
json.loads(args.active_glossary.read_text(encoding="utf-8"))
if args.active_glossary
else None
)
result = extract_all(
doc, clustered, filtered, extractor, args.limit, active
)
e3 = score_glossary(gold, result.glossary)
rules = score_rules(gold, result.rules)
scores["E3"] = {
"label": "glossary schema fill (precision)",
"value": e3.precision,
"detail": e3.as_dict(),
}
scores["rule_recall"] = {
"label": "rule-branch recall",
"value": rules.recall,
"detail": rules.as_dict(),
}
seconds = (datetime.now() - started).total_seconds()
report = build_report(doc, filtered, clustered, scores, result, args, started, seconds)
if args.label:
report["run"]["label"] = args.label
if not args.extract:
report["run"]["note"] = (
"Free stages only. E3 and rule recall require --extract, which spends."
)
print(format_summary(report))
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
stem = "mock" if args.mock else ("full" if args.extract else "filters")
out_path = RESULTS_DIR / f"v2_{stem}_{started:%Y-%m-%d_%H%M%S}.json"
if out_path.exists(): # never overwrite a committed measurement
print(f"refusing to overwrite {out_path}", file=sys.stderr)
return 4
out_path.write_text(
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
)
print(f"\n-> saved: {out_path.relative_to(_HERE.parent.parent)}")
if EVIDENCE_K and args.extract and not args.mock:
failed = [
k for k, s in scores.items() if k in KILL_LINES and s["value"] < KILL_LINES[k]
]
if failed:
print(f"\nBELOW KILL LINE: {', '.join(failed)}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())