Files
yao-meta-skill/scripts/build_confusion_matrix.py
2026-04-01 07:06:16 +08:00

250 lines
10 KiB
Python

#!/usr/bin/env python3
import argparse
import json
from pathlib import Path
from trigger_eval import extract_description, load_json, load_semantic_config, score_prompt_semantic
ROOT = Path(__file__).resolve().parent.parent
def load_routes(config_path: Path) -> tuple[list[dict], dict]:
payload = load_json(config_path)
routes = []
for item in payload["routes"]:
description = extract_description((ROOT / item["description_file"]).read_text(encoding="utf-8")).strip()
routes.append(
{
"name": item["name"],
"description": description,
"semantic_config": load_semantic_config(ROOT / item["semantic_config"]),
"threshold": item["threshold"],
}
)
return routes, payload
def display_value(value: object) -> str:
return "-" if value is None else str(value)
def build_scorecard(routes: list[dict], payload: dict) -> dict:
margin_warning_threshold = payload.get("margin_warning_threshold", 0.08)
cases = []
route_names = [route["name"] for route in routes] + ["no_route"]
confusion = {expected: {predicted: 0 for predicted in route_names} for expected in route_names}
route_stats = {
name: {
"expected_count": 0,
"predicted_count": 0,
"correct_count": 0,
"precision": None,
"recall": None,
"average_margin": None,
}
for name in route_names
}
margins_by_route: dict[str, list[float]] = {name: [] for name in route_names}
for case in payload["cases"]:
prompt = case["text"]
scored = []
for route in routes:
score, detail = score_prompt_semantic(route["description"], prompt, route["semantic_config"])
passed = score >= route["threshold"]
scored.append(
{
"name": route["name"],
"score": round(score, 3),
"passed_threshold": passed,
"threshold": route["threshold"],
"matched_desired_concepts": detail["matched_desired_concepts"],
"matched_negative_concepts": detail["matched_negative_concepts"],
}
)
ranked = sorted(scored, key=lambda item: item["score"], reverse=True)
passed = [item for item in ranked if item["passed_threshold"]]
predicted = passed[0]["name"] if passed else "no_route"
winning_score = passed[0]["score"] if passed else 0.0
second_score = passed[1]["score"] if len(passed) > 1 else 0.0
margin = round(winning_score - second_score, 3) if predicted != "no_route" else None
ambiguous = predicted != "no_route" and margin is not None and margin < margin_warning_threshold
correct = predicted == case["expected_route"]
case_record = {
"family": case["family"],
"prompt": prompt,
"expected_route": case["expected_route"],
"predicted_route": predicted,
"correct": correct,
"margin": margin,
"ambiguous": ambiguous,
"ranked_routes": ranked,
}
cases.append(case_record)
confusion[case["expected_route"]][predicted] += 1
route_stats[case["expected_route"]]["expected_count"] += 1
route_stats[predicted]["predicted_count"] += 1
if correct:
route_stats[predicted]["correct_count"] += 1
if margin is not None:
margins_by_route[predicted].append(margin)
for route_name, stats in route_stats.items():
predicted_count = stats["predicted_count"]
expected_count = stats["expected_count"]
correct_count = stats["correct_count"]
stats["precision"] = round(correct_count / predicted_count, 3) if predicted_count else None
stats["recall"] = round(correct_count / expected_count, 3) if expected_count else None
stats["average_margin"] = (
round(sum(margins_by_route[route_name]) / len(margins_by_route[route_name]), 3)
if margins_by_route[route_name]
else None
)
correct_cases = sum(1 for case in cases if case["correct"])
ambiguous_cases = [case for case in cases if case["ambiguous"]]
misroutes = [case for case in cases if not case["correct"]]
return {
"summary": {
"total_cases": len(cases),
"correct_cases": correct_cases,
"accuracy": round(correct_cases / len(cases), 3) if cases else None,
"ambiguous_case_count": len(ambiguous_cases),
"margin_warning_threshold": margin_warning_threshold,
"misroute_count": len(misroutes),
"no_route_accuracy": route_stats["no_route"]["recall"],
},
"route_stats": route_stats,
"confusion_matrix": confusion,
"ambiguous_cases": ambiguous_cases,
"misroutes": misroutes,
"cases": cases,
}
def build_history_snapshot(scorecard: dict, args: argparse.Namespace) -> dict:
return {
"snapshot_id": args.snapshot_id,
"date": args.snapshot_date,
"commit": args.snapshot_commit,
"label": args.snapshot_label,
"trigger_summary": {
"total_cases": scorecard["summary"]["total_cases"],
"family_count": len({case["family"] for case in scorecard["cases"]}),
"false_positives": 0,
"false_negatives": scorecard["summary"]["misroute_count"],
"average_precision": scorecard["summary"]["accuracy"],
"average_recall": scorecard["summary"]["accuracy"],
},
"route_summary": {
"accuracy": scorecard["summary"]["accuracy"],
"ambiguous_case_count": scorecard["summary"]["ambiguous_case_count"],
"misroute_count": scorecard["summary"]["misroute_count"],
"no_route_accuracy": scorecard["summary"]["no_route_accuracy"],
},
"quality_gates": {
"route_confusion_checks": True,
"promotion_policy_present": True,
},
"governance_score": None,
"governed_examples": 1,
"notes": [
"added route confusion matrix for sibling skills and no-route cases",
"published route scorecard and iteration ledger foundation",
],
}
def render_markdown(scorecard: dict) -> str:
lines = [
"# Route Scorecard",
"",
f"- total cases: `{scorecard['summary']['total_cases']}`",
f"- accuracy: `{scorecard['summary']['accuracy']}`",
f"- ambiguous cases: `{scorecard['summary']['ambiguous_case_count']}`",
f"- no-route accuracy: `{display_value(scorecard['summary']['no_route_accuracy'])}`",
"",
"## Route Metrics",
"",
"| Route | Expected | Predicted | Precision | Recall | Avg Margin |",
"| --- | ---: | ---: | ---: | ---: | ---: |",
]
for route_name, stats in scorecard["route_stats"].items():
lines.append(
f"| `{route_name}` | {stats['expected_count']} | {stats['predicted_count']} | {display_value(stats['precision'])} | {display_value(stats['recall'])} | {display_value(stats['average_margin'])} |"
)
route_names = list(scorecard["confusion_matrix"].keys())
lines.extend(
[
"",
"## Confusion Matrix",
"",
"| Expected \\ Predicted | " + " | ".join(f"`{name}`" for name in route_names) + " |",
"| --- | " + " | ".join("---:" for _ in route_names) + " |",
]
)
for expected in route_names:
row = [str(scorecard["confusion_matrix"][expected][predicted]) for predicted in route_names]
lines.append(f"| `{expected}` | " + " | ".join(row) + " |")
lines.extend(
[
"",
"## Ambiguous Cases",
"",
"| Family | Expected | Predicted | Margin |",
"| --- | --- | --- | ---: |",
]
)
if scorecard["ambiguous_cases"]:
for case in scorecard["ambiguous_cases"]:
lines.append(
f"| `{case['family']}` | `{case['expected_route']}` | `{case['predicted_route']}` | {display_value(case['margin'])} |"
)
else:
lines.append("| - | - | - | - |")
return "\n".join(lines) + "\n"
def main() -> None:
parser = argparse.ArgumentParser(description="Build a route confusion matrix across skills.")
parser.add_argument("--cases", default="evals/confusion/route_cases.json")
parser.add_argument("--output-json", default="reports/route_scorecard.json")
parser.add_argument("--output-md", default="reports/route_scorecard.md")
parser.add_argument("--history-snapshot-output")
parser.add_argument("--snapshot-date")
parser.add_argument("--snapshot-id", default="route-scorecard-foundation")
parser.add_argument("--snapshot-label", default="Route Scorecard Foundation")
parser.add_argument("--snapshot-commit", default="local-snapshot")
args = parser.parse_args()
cases_path = ROOT / args.cases if not Path(args.cases).is_absolute() else Path(args.cases)
routes, payload = load_routes(cases_path)
scorecard = build_scorecard(routes, payload)
output_json = ROOT / args.output_json if not Path(args.output_json).is_absolute() else Path(args.output_json)
output_md = ROOT / args.output_md if not Path(args.output_md).is_absolute() else Path(args.output_md)
output_json.parent.mkdir(parents=True, exist_ok=True)
output_md.parent.mkdir(parents=True, exist_ok=True)
output_json.write_text(json.dumps(scorecard, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
output_md.write_text(render_markdown(scorecard), encoding="utf-8")
if args.history_snapshot_output:
snapshot_path = ROOT / args.history_snapshot_output if not Path(args.history_snapshot_output).is_absolute() else Path(args.history_snapshot_output)
snapshot_path.parent.mkdir(parents=True, exist_ok=True)
snapshot = build_history_snapshot(scorecard, args)
snapshot_path.write_text(json.dumps(snapshot, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(scorecard, ensure_ascii=False, indent=2))
if scorecard["summary"]["misroute_count"]:
raise SystemExit(2)
if __name__ == "__main__":
main()