Files
kunail0804-ComfyUI-Scene-Co…/scripts/coverage_benchmark.py
T
50e7b0a8a4 feat(kb): automatic KB builder, ingestion & validation tooling (#35) (#139)
* feat(kb): automatic KB builder, ingestion & validation tooling (#35)

Closes #118, #119, #120, #121, #122.

- Ingest committed Danbooru alias/implication snapshots during generation:
  synonyms reach canonicals (long black stockings -> thighhighs), implications
  become expand relations. Curated-wins preserved.
- Automatic candidate validator (structural rules reused from kb_validation +
  confidence heuristic, no approval gate; SC0022).
- Duplicate/conflict detection tool (scripts/detect_kb_conflicts.py) + reusable
  detect_conflicts; clean reference KB reports zero.
- KB coverage benchmark harness (scripts/coverage_benchmark.py) + JSON summary.
- Single fully-automated, CI-runnable build pipeline (scripts/build_knowledge_base.py)
  that fails fast on rejections/conflicts and never prompts. Docs added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(kb): use pytest.approx for confidence float assertions (SonarCloud reliability)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: kunail0804 <kunail0804@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:10:17 +02:00

141 lines
4.7 KiB
Python

"""Knowledge Base coverage benchmark (issue #121, epic #35).
Runs a fixed corpus of representative concept phrases through the deterministic
Resolver and measures how well the Knowledge Base covers them:
- **hit rate** by resolution path — ``exact`` (direct id/alias hit), ``head_noun``
(recovered via the head-noun reduction, ``SC0019``), ``fallback`` (semantic
nearest-neighbour, ``SC0020``, when that feature is enabled), and ``dropped``
(no entry, ``SC0001``).
- **per-category coverage** — the category of the first resolved tag for each
covered phrase.
The report is informational (no hard threshold gate — one can be added later). It
is deterministic: a fixed corpus and no live model. Prints a human summary and
writes a JSON summary.
Usage:
python scripts/coverage_benchmark.py
"""
from __future__ import annotations
import json
import sys
from collections import Counter
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from compiler.common.config import Config # noqa: E402
from compiler.common.knowledge_base import KnowledgeBase, load_knowledge_base # noqa: E402
from compiler.resolver.illustrious_resolver import resolve_scene # noqa: E402
from schemas.models import Scene # noqa: E402
_REPO_ROOT = Path(__file__).resolve().parent.parent
_KB_DIR = _REPO_ROOT / "knowledge_base"
_CORPUS = _REPO_ROOT / "data" / "coverage_corpus.txt"
_REPORT = _REPO_ROOT / "docs" / "kb_coverage.json"
_PATHS = ("exact", "head_noun", "fallback", "dropped")
def load_corpus(path: Path = _CORPUS) -> list[str]:
"""Read the corpus file: one phrase per line, ignoring blanks and comments."""
phrases = []
for line in path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if stripped and not stripped.startswith("#"):
phrases.append(stripped)
return phrases
def _single_concept_scene(phrase: str) -> Scene:
return Scene.from_json(
{
"characters": [
{
"id": 0,
"identity": [phrase],
"appearance": [],
"clothing": [],
"accessories": [],
"pose": [],
"expression": [],
"actions": [],
}
],
"interactions": [],
"objects": [],
"environment": [],
"camera": [],
"lighting": [],
"metadata": {"schema_version": "1.0"},
}
)
def _classify(phrase: str, kb: KnowledgeBase, config: Config) -> tuple[str, str | None]:
"""Return (resolution path, first resolved tag category) for one phrase."""
result = resolve_scene(_single_concept_scene(phrase), kb, config)
codes = {warning.code for warning in result.warnings}
tags = result.data or ()
category = tags[0].category if tags else None
if "SC0001" in codes and not tags:
return "dropped", None
if "SC0020" in codes:
return "fallback", category
if "SC0019" in codes:
return "head_noun", category
return "exact", category
def measure_coverage(kb: KnowledgeBase, phrases: list[str], config: Config | None = None) -> dict:
"""Measure resolution hit-rate and per-category coverage over a phrase corpus."""
config = config or Config()
hits: Counter[str] = Counter({path: 0 for path in _PATHS})
per_category: Counter[str] = Counter()
for phrase in phrases:
path, category = _classify(phrase, kb, config)
hits[path] += 1
if category is not None:
per_category[category] += 1
total = len(phrases)
covered = total - hits["dropped"]
return {
"total": total,
"covered": covered,
"coverage_rate": round(covered / total, 4) if total else 0.0,
"hit_rate": {path: hits[path] for path in _PATHS},
"per_category": dict(sorted(per_category.items())),
}
def run() -> dict:
kb = load_knowledge_base(_KB_DIR)
return measure_coverage(kb, load_corpus())
def _format_human(summary: dict) -> str:
lines = [
f"KB coverage: {summary['covered']}/{summary['total']} "
f"({summary['coverage_rate'] * 100:.1f}%)",
" by path: " + ", ".join(f"{path}={count}" for path, count in summary["hit_rate"].items()),
" by category: "
+ ", ".join(f"{cat}={count}" for cat, count in summary["per_category"].items()),
]
return "\n".join(lines)
def main() -> int:
summary = run()
_REPORT.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(_format_human(summary))
print(f"wrote JSON summary to {_REPORT.relative_to(_REPO_ROOT)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())