* 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>
141 lines
4.7 KiB
Python
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())
|