#!/usr/bin/env python3 """run_trigger_evals.py — 触发评测的确定性环节(Phase 3,路线 C)。 路线 C 下 CLI 不能替宿主跑模型;本脚本负责三件确定性的事: split 固定种子做 60/40 train/validation 切分(validation 在选版前保持隐藏) prepare 生成盲测任务包:只含 prompt + 候选 skill 目录清单,隐藏 expected/notes, 由主流程逐条交给干净 sub-agent,结果写 results.jsonl score 对照 suite 判分:precision/recall/F1、兄弟混淆率、逐条配对结果 (不做统计非劣声明——那需要预注册界值与配对检验,见方案 §10.3) results.jsonl 每行: {"case_id": "...", "run": 1, "selected_skill": "|none"} 用法: python3 scripts/run_trigger_evals.py split python3 scripts/run_trigger_evals.py prepare --skills --out [--set train|validation|all] python3 scripts/run_trigger_evals.py score --results --out """ from __future__ import annotations import argparse import json import random import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) from cangjie_common import dump_json, load_json # noqa: E402 def split_cases(suite: dict) -> tuple[list[dict], list[dict]]: cases = list(suite["trigger_cases"]) rng = random.Random(suite.get("split_seed", 42)) rng.shuffle(cases) k = round(len(cases) * suite.get("train_ratio", 0.6)) return cases[:k], cases[k:] def cmd_split(suite_path: Path) -> int: suite = load_json(suite_path) train, val = split_cases(suite) out = {"suite_id": suite["suite_id"], "seed": suite.get("split_seed", 42), "train": [c["case_id"] for c in train], "validation": [c["case_id"] for c in val]} dump_json(suite_path.with_suffix(".split.json"), out) print(f"train {len(train)} / validation {len(val)} → {suite_path.with_suffix('.split.json')}") return 0 def cmd_prepare(suite_path: Path, skills: list[str], out_dir: Path, which: str) -> int: suite = load_json(suite_path) train, val = split_cases(suite) cases = {"train": train, "validation": val, "all": train + val}[which] out_dir.mkdir(parents=True, exist_ok=True) for c in cases: runs = c.get("runs", 3) packet = { "case_id": c["case_id"], "runs": runs, "prompt": c["prompt"], "instruction": ( "你是一个未参与蒸馏的干净 agent。给定用户 prompt 与已安装 skill 清单," "判断该激活哪一个 skill(或 none)。输出 JSON: " '{"selected_skill": "|none", "reason": "..."}。' "每条 prompt 独立判断,重复运行之间不携带记忆。" ), "installed_skills": skills, } dump_json(out_dir / f"{c['case_id']}.json", packet) (out_dir / "README.md").write_text( f"# 盲测任务包({which}, {len(cases)} 条)\n\n" "每个 JSON 是一条盲测任务:把 prompt + installed_skills 交给干净 sub-agent," "按 instruction 输出;结果按行追加到 results.jsonl:\n" '`{"case_id": ..., "run": 1, "selected_skill": ...}`\n\n' "**不要**把 suite 中的 expected/notes 给 sub-agent。\n", encoding="utf-8") print(f"已生成 {len(cases)} 个盲测任务包 → {out_dir}") return 0 def cmd_score(suite_path: Path, results_path: Path, out_path: Path) -> int: suite = load_json(suite_path) target = suite["target"] by_case = {c["case_id"]: c for c in suite["trigger_cases"]} results = [json.loads(l) for l in results_path.read_text(encoding="utf-8").splitlines() if l.strip()] tp = fp = fn = tn = 0 sibling_total = sibling_confused = 0 rows = ["| case | expected | selected | 判定 |", "|---|---|---|---|"] for r in results: c = by_case.get(r["case_id"]) if not c: continue selected = r.get("selected_skill", "none") triggered = selected == target exp = c["expected"] if exp == "should_trigger": verdict = "TP" if triggered else "FN" tp += triggered fn += not triggered elif exp in ("should_not_trigger", "edge_case"): verdict = "FP" if triggered else "TN" fp += triggered tn += not triggered else: # sibling sibling_total += 1 correct = selected == c.get("sibling_target") if triggered: sibling_confused += 1 verdict = "混淆(FP)" fp += 1 else: verdict = "OK" if correct else f"未中兄弟({selected})" tn += 1 rows.append(f"| {r['case_id']}#r{r.get('run', 1)} | {exp} | {selected} | {verdict} |") precision = tp / (tp + fp) if tp + fp else 0.0 recall = tp / (tp + fn) if tp + fn else 0.0 f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0 confusion = sibling_confused / sibling_total if sibling_total else 0.0 report = (f"# 触发评测判分 — {target}\n\n" f"- runs: {len(results)}(TP {tp} / FP {fp} / FN {fn} / TN {tn})\n" f"- precision {precision:.3f} / recall {recall:.3f} / **F1 {f1:.3f}**\n" f"- 兄弟混淆率: {confusion:.3f}({sibling_confused}/{sibling_total})\n\n" + "\n".join(rows) + "\n\n> 本报告只给原始配对计数与比率;统计非劣需预注册界值 + McNemar/配对 Bootstrap(§10.3),不在此自动宣布。\n") out_path.parent.mkdir(parents=True, exist_ok=True) out_path.write_text(report, encoding="utf-8") print(report) return 0 def main() -> int: ap = argparse.ArgumentParser(description=__doc__) sub = ap.add_subparsers(dest="mode", required=True) s = sub.add_parser("split") s.add_argument("suite") p = sub.add_parser("prepare") p.add_argument("suite") p.add_argument("--skills", required=True) p.add_argument("--out", required=True) p.add_argument("--set", dest="which", choices=["train", "validation", "all"], default="train") c = sub.add_parser("score") c.add_argument("suite") c.add_argument("--results", required=True) c.add_argument("--out", required=True) args = ap.parse_args() if args.mode == "split": return cmd_split(Path(args.suite)) if args.mode == "prepare": return cmd_prepare(Path(args.suite), args.skills.split(","), Path(args.out), args.which) return cmd_score(Path(args.suite), Path(args.results), Path(args.out)) if __name__ == "__main__": raise SystemExit(main())