"""对比框架公共层:方法描述、指标聚合、结果落盘、离线闭环装配。 设计口径 -------- - **最优实现** = 复用 ``src/mcm26b`` 中已由测试固化的生产实现; - **基线实现** = 同题下更朴素但可独立运行的参照实现(不得共享最优实现的 关键步骤),用于量化"生产实现好在哪里、差多少"; - **参考解** = 与分析算法相互独立的数值/枚举标准,用来判定谁对谁错。 第三、四问是闭环问题,统一走 ``LocalSimulator`` 内存回环(与官方 HTTP 协议同一边界),真值只在评测机侧,策略端只看响应。 """ from __future__ import annotations import json import statistics from dataclasses import dataclass, replace from pathlib import Path from time import perf_counter from mcm26b.config import ProblemConfig from mcm26b.evaluation import recompute_virtual_time from mcm26b.harness import LocalSimTransport, LocalSimulator from mcm26b.protocol.client import SimulatorClient ROOT = Path(__file__).resolve().parents[1] RESULTS_DIR = Path(__file__).resolve().parent / "results" @dataclass(frozen=True) class Method: """一个可对比方法:key 用作图表列名,kind 用于区分最优/基线/参考。""" key: str name: str kind: str # optimal | baseline | reference note: str = "" def problem_config(*, persistent_search: bool = False, margin_deg: float | None = None) -> ProblemConfig: """对比口径配置:默认关闭"扫到限时"的持续复扫(对齐首次找全证明)。""" base = ProblemConfig() kwargs: dict = {"persistent_search": persistent_search} if margin_deg is not None: kwargs["bearing_rounding_margin_deg"] = margin_deg return replace(base, search=replace(base.search, **kwargs)) # ---------------------------------------------------------------- 结果落盘 def write_json(path: Path, payload) -> Path: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, allow_nan=False) + "\n", encoding="utf-8") return path def write_text(path: Path, text: str) -> Path: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text, encoding="utf-8") return path # ---------------------------------------------------------------- 指标工具 def mean(values) -> float: values = list(values) return round(statistics.fmean(values), 4) if values else 0.0 def median(values) -> float: values = list(values) return round(statistics.median(values), 4) if values else 0.0 def ratio(hit: int, total: int) -> float: return round(hit / total, 4) if total else 0.0 def relative_change(value: float, reference: float) -> float | None: if reference == 0: return None return round(value / reference - 1.0, 4) def markdown_table(headers: list[str], rows: list[list[str]]) -> str: lines = ["| " + " | ".join(headers) + " |", "| " + " | ".join("---" for _ in headers) + " |"] for row in rows: lines.append("| " + " | ".join(str(cell) for cell in row) + " |") return "\n".join(lines) def _num(value, digits: int = 2) -> str: if value is None: return "—" if isinstance(value, bool): return "✔" if value else "✘" if isinstance(value, float): return f"{value:.{digits}f}" return str(value) # ---------------------------------------------------------------- 第三/四问闭环 def run_loop_episode(loop_cls, scene_spec, config: ProblemConfig) -> dict: """在冻结场景上跑一局闭环,返回逐局指标行(与 ``scripts/run_suite.py`` 同口径)。""" sources = scene_spec.sources(config) # 真值:仅测试端可见 simulator = LocalSimulator(sources, robot_id="COMPARE", config=config, error_seed=scene_spec.error_seed) client = SimulatorClient("COMPARE", config=config, transport=LocalSimTransport(simulator)) start = perf_counter() loop = loop_cls(client, config) summary = loop.run() compute_wall_s = perf_counter() - start report = recompute_virtual_time(client.log.action_events()) truth = sorted(source.channel for source in sources) cleared = sorted(summary["cleared_channels"]) absent = sorted(summary["absent_channels"]) misjudged = sorted(set(absent) & set(truth)) valid = (cleared == truth and not misjudged and summary["exit_reason"] == "user_exit" and not summary["anomalies"] and not report["mismatches"]) return { "scene_id": scene_spec.scene_id, "problem": scene_spec.problem, "kind": scene_spec.kind, "sources": len(truth), "directional": scene_spec.directional, "cleared": len(cleared), "cleared_ratio": ratio(len(cleared), len(truth)), "cleared_matches_truth": cleared == truth, "absent_misjudged": len(misjudged), "virtual_time_s": round(summary["virtual_time_s"], 3), "accounted_virtual_time_s": ( round(summary["accounted_virtual_time_s"], 3) if summary.get("accounted_virtual_time_s") is not None else None), "measures": summary["measures"], "clears": summary["clears"], "clear_success": summary["clear_success"], "total_requests": summary["total_requests"], "move_distance_m": round(summary["move_distance_m"], 2), "fallback_clear_attempts": summary.get("fallback_clear_attempts", 0), "coverage_points": summary.get("coverage_points", 0), "stop_reason": summary["stop_reason"], "exit_reason": summary["exit_reason"], "anomaly_count": len(summary["anomalies"]), "virtual_recompute_mismatches": len(report["mismatches"]), "compute_wall_s": round(compute_wall_s, 3), "valid": valid, } # 第三/四问统一汇报列(顺序即 Markdown 列序) EPISODE_COLUMNS = ( "cleared_ratio", "cleared_matches_truth", "absent_misjudged", "virtual_time_s", "measures", "total_requests", "move_distance_m", "fallback_clear_attempts", "compute_wall_s", "valid", ) def aggregate_episodes(rows: list[dict], labels: list[str], base_label: str | None = None) -> dict: """逐方法汇总 + 相对基线的配对变化(按 scene_id 对齐)。""" summary: dict[str, dict] = {} for label in labels: part = [row for row in rows if row["variant"] == label] if not part: continue summary[label] = { "episodes": len(part), "valid_episodes": sum(1 for row in part if row["valid"]), "cleared_ratio_mean": mean(row["cleared_ratio"] for row in part), "cleared_matches_truth": sum(1 for row in part if row["cleared_matches_truth"]), "absent_misjudged_total": sum(row["absent_misjudged"] for row in part), "virtual_time_mean_s": mean(row["virtual_time_s"] for row in part), "virtual_time_median_s": median(row["virtual_time_s"] for row in part), "virtual_time_max_s": round(max(row["virtual_time_s"] for row in part), 3), "measures_mean": mean(row["measures"] for row in part), "total_requests_mean": mean(row["total_requests"] for row in part), "move_distance_mean_m": mean(row["move_distance_m"] for row in part), "fallback_clear_attempts_mean": mean( row["fallback_clear_attempts"] for row in part), "compute_wall_mean_s": mean(row["compute_wall_s"] for row in part), } paired: dict[str, dict] = {} if base_label and len(labels) > 1: base_rows = {row["scene_id"]: row for row in rows if row["variant"] == base_label} for label in labels: if label == base_label: continue deltas, faster, valid_pairs = [], 0, 0 for row in rows: if row["variant"] != label or row["scene_id"] not in base_rows: continue base_row = base_rows[row["scene_id"]] reference = base_row["virtual_time_s"] deltas.append(relative_change(row["virtual_time_s"], reference)) if not (row["valid"] and base_row["valid"]): continue valid_pairs += 1 if row["virtual_time_s"] < reference: faster += 1 paired[f"{label}_vs_{base_label}"] = { "mean_relative_change": mean(d for d in deltas if d is not None), "median_relative_change": median(d for d in deltas if d is not None), "faster_scenes": faster, "compared": len(deltas), "both_valid_scenes": valid_pairs, } return {"variants": summary, "paired": paired} def episodes_markdown(title: str, rows: list[dict], labels: list[str], base_label: str | None = None) -> str: """逐场景明细 + 汇总表。""" headers = ["scene", "sources", "variant", "cleared", "T(s)", "检测", "请求", "位移(m)", "后备清除", "墙钟(s)", "有效"] body = [] for row in sorted(rows, key=lambda r: (r["scene_id"], labels.index(r["variant"]))): body.append([ row["scene_id"], row["sources"], row["variant"], f"{row['cleared']}/{row['sources']}", _num(row["virtual_time_s"], 1), row["measures"], row["total_requests"], _num(row["move_distance_m"], 1), row["fallback_clear_attempts"], _num(row["compute_wall_s"], 2), _num(row["valid"]), ]) text = [f"# {title}", "", "## 逐场景明细", "", markdown_table(headers, body), "", "## 逐方法汇总", ""] agg = aggregate_episodes(rows, labels, base_label) agg_headers = ["方法", "有效局", "全清率", "全清对数", "缺席误判", "T 均值(s)", "T 中位(s)", "T 最大(s)", "检测均值", "请求均值", "位移均值(m)", "后备清除均值", "墙钟均值(s)"] agg_rows = [] for label in labels: s = agg["variants"].get(label) if not s: continue agg_rows.append([ label, f"{s['valid_episodes']}/{s['episodes']}", _num(s["cleared_ratio_mean"]), s["cleared_matches_truth"], s["absent_misjudged_total"], _num(s["virtual_time_mean_s"], 1), _num(s["virtual_time_median_s"], 1), _num(s["virtual_time_max_s"], 1), _num(s["measures_mean"], 1), _num(s["total_requests_mean"], 1), _num(s["move_distance_mean_m"], 1), _num(s["fallback_clear_attempts_mean"], 2), _num(s["compute_wall_mean_s"], 2), ]) text.append(markdown_table(agg_headers, agg_rows)) if agg["paired"]: text += ["", "## 相对基线的配对变化(虚拟时间)", ""] pair_rows = [[name, f"{v['mean_relative_change']*100:+.1f}%", f"{v['median_relative_change']*100:+.1f}%", f"{v['faster_scenes']}/{v['compared']}", v["both_valid_scenes"]] for name, v in agg["paired"].items()] text.append(markdown_table( ["配对", "均值变化", "中位变化", "更快场景", "双方均有效"], pair_rows)) invalid = [name for name, value in agg["paired"].items() if value["both_valid_scenes"] < value["compared"]] if invalid: text += ["", "> 注:" + "、".join(invalid) + " 存在未通过有效性判定的配对;其虚拟时间仅作参考," "基线未完成全清任务时不构成「更快」证据" "(以「双方均有效」列为准)。"] return "\n".join(text) + "\n" def json_safe(payload): """把 numpy 标量等转成 json 可写类型(防御式,非必需)。""" return json.loads(json.dumps(payload, ensure_ascii=False, default=float)) # ---------------------------------------------------------------- 闭环批量执行 def execute_variants(scenes, variants, config: ProblemConfig) -> list[dict]: """对每个场景跑遍所有变体(串行;对比规模小,进程池收益有限且便于复现)。""" rows = [] for scene in scenes: for label, loop_cls in variants: row = run_loop_episode(loop_cls, scene, config) row["variant"] = label rows.append(row) return rows def closed_loop_payload(question: int, suite_name: str, scenes, variants, rows: list[dict], labels: list[str], base_label: str, config: ProblemConfig) -> dict: return { "question": question, "suite": suite_name, "scenes": [scene.as_dict() for scene in scenes], "variants": [{"label": label, "class": loop_cls.__name__} for label, loop_cls in variants], "config": { "persistent_search": config.search.persistent_search, "bearing_rounding_margin_deg": config.search.bearing_rounding_margin_deg, "q4_coverage_plan": config.search.q4_coverage_plan, }, "rows": rows, "aggregate": aggregate_episodes(rows, labels, base_label), }