"""冻结基准集的配对 runner:并行执行、逐局留档、有效性判定与配对统计。 取代散落的 benchmark_*.py 口径分叉(docs/第三四问架构优化与运行提速方案.md §P1-3): - 场景来自 :mod:`mcm26b.harness.suites`(版本化清单,真值只在测试端); - 每个 (场景, 变体) 一局,落 manifest / results / aggregate(.json/.csv); - 有效性定义与既有脚本一致且不得放宽: ``全清 ∧ 缺席无误判 ∧ 正常退出 ∧ 零异常 ∧ 逐微秒计时复算一致``; - 任何一局无效即 ``all_valid=false`` 且进程非零退出(失败不静默丢样本); - 变体支持 ``名称@计划名`` 语法,用于同场配对比较两个覆盖计划; - ``--jobs`` 走进程池并行(每进程独立内存回环,不共享端口)。 uv run python scripts/run_suite.py --problem 3 --suite core --jobs 6 uv run python scripts/run_suite.py --problem 4 --suite all --jobs 6 \ --variants directional@square49,directional@q4_mincover_v1 \ --label q4-plan-ab """ from __future__ import annotations import argparse import csv import json import statistics from concurrent.futures import ProcessPoolExecutor from dataclasses import 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.harness.suites import (SUITE_VERSION, SceneSpec, suite, suite_manifest) from mcm26b.planner import DirectionalExtension from mcm26b.planner.bayes_loop import BayesLoop from mcm26b.planner.seven import SevenBatch from mcm26b.protocol.client import SimulatorClient from mcm26b.routing import route_metrics ROOT = Path(__file__).resolve().parents[1] Q3_STRATEGIES = {"bayes": BayesLoop, "seven_batch": SevenBatch, "seven_immediate": SevenBatch} Q4_STRATEGIES = {"directional": DirectionalExtension} UNDER_TARGET_S = 4000.0 def build_config(margin_deg: float, q4_plan: str | None, bayes_overrides: dict | None = None, search_overrides: dict | None = None) -> ProblemConfig: """基准配置:persistent 关闭(对齐"首次证明找全"口径)。""" base = ProblemConfig() search = replace(base.search, bearing_rounding_margin_deg=margin_deg, persistent_search=False) if q4_plan: search = replace(search, q4_coverage_plan=q4_plan) if search_overrides: search = replace(search, **search_overrides) bayes = base.bayes if bayes_overrides: bayes = replace(bayes, **bayes_overrides) return replace(base, search=search, bayes=bayes) def _coerce(text: str): """变体覆盖值的字面量解析(true/false/整数/浮点/元组/字符串)。""" lowered = text.lower() if lowered in ("true", "yes", "on"): return True if lowered in ("false", "no", "off"): return False if text.startswith("(") and text.endswith(")"): inner = text[1:-1].strip() return () if not inner else tuple( _coerce(chunk.strip()) for chunk in inner.split(",")) for cast in (int, float): try: return cast(text) except ValueError: continue return text def parse_variant(spec: str, problem: int) -> tuple[str, str, str | None, dict, dict]: """``名称[@计划][#键=值;键=值]`` → (标签, 策略名, 计划, bayes 覆盖, search 覆盖)。 变体覆盖用于同场配对比较单个开关,例如 ``bayes#fuse_localize_into_tour=false``;``search.`` 前缀写入 SearchConfig。 """ body, _, override_text = spec.partition("#") name, _, plan = body.partition("@") strategies = Q3_STRATEGIES if problem == 3 else Q4_STRATEGIES if name not in strategies: raise ValueError(f"未知策略 {name!r}(可选 {sorted(strategies)})") bayes_overrides: dict = {} search_overrides: dict = {} for item in filter(None, (chunk.strip() for chunk in override_text.split(";"))): key, _, value = item.partition("=") if not value: raise ValueError(f"变体覆盖须为 键=值:{item!r}") if key.startswith("search."): search_overrides[key[len("search."):]] = _coerce(value) else: bayes_overrides[key] = _coerce(value) label = spec.replace(" ", "") return label, name, plan or None, bayes_overrides, search_overrides def run_episode(payload: dict) -> dict: """执行一局并返回结果行(进程池 worker,必须可 pickle)。""" spec = SceneSpec(**payload["scene"]) label = payload["label"] problem = spec.problem config = build_config(payload["margin_deg"], payload["plan"], payload.get("bayes"), payload.get("search")) strategies = Q3_STRATEGIES if problem == 3 else Q4_STRATEGIES loop_cls = strategies[payload["strategy"]] sources = spec.sources(config) simulator = LocalSimulator(sources, robot_id="SUITE", config=config, error_seed=spec.error_seed) client = SimulatorClient("SUITE", config=config, transport=LocalSimTransport(simulator)) setup_start = perf_counter() loop = loop_cls(client, config) setup_wall_s = perf_counter() - setup_start start = perf_counter() 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"]) valid = (cleared == truth and not (set(absent) & set(truth)) and summary["exit_reason"] == "user_exit" and not summary["anomalies"] and not report["mismatches"]) routing = _routing_metrics(summary, config) return { "scene_id": spec.scene_id, "variant": label, "problem": problem, "kind": spec.kind, "sources": spec.count, "directional": spec.directional, "virtual_time_s": summary["virtual_time_s"], "accounted_virtual_time_s": summary["accounted_virtual_time_s"], "measures": summary["measures"], "clears": summary["clears"], "clear_success": summary["clear_success"], "total_requests": summary["total_requests"], "move_distance_m": summary["move_distance_m"], "coverage_points": summary["coverage_points"], "fallback_clear_attempts": summary.get("fallback_clear_attempts", 0), "redundant_rescan_measures": summary["redundant_rescan_measures"], "completion_basis": summary["completion_basis"], "stop_reason": summary["stop_reason"], "exit_reason": summary["exit_reason"], "cleared_matches_truth": cleared == truth, "absent_misjudged": sorted(set(absent) & set(truth)), "anomaly_count": len(summary["anomalies"]), "virtual_recompute_mismatches": len(report["mismatches"]), "compute_wall_s": round(compute_wall_s, 3), "plan_verify_wall_s": round(setup_wall_s, 3), "valid": valid, **routing, } def _routing_metrics(summary: dict, config: ProblemConfig) -> dict: """S1 必报路由列:作业点去重、离线最优开放回路、MST 下界、缺口与探测静默率。 作业点 = 发生过 /measure 或 /clear 的位置(去重到 0.5 m);离线最优是 "同一批作业点"上的开放路径最优值,用来把"排线不够好"与"点选得不对"分开。 """ rows = summary.get("steps") or [] positions = [] for row in rows: position = row.get("position") if isinstance(position, dict): positions.append((float(position["x"]), float(position["y"]))) elif position is not None: positions.append((float(position[0]), float(position[1]))) speed = config.cost.speed_mps metrics = route_metrics(positions, summary["move_distance_m"], speed) measures = [row for row in rows if row.get("action") == "measure"] silent = sum(1 for row in measures if row.get("result") == "no_signal") metrics["probe_no_signal_ratio"] = ( round(silent / len(measures), 4) if measures else 0.0) plan_points = summary.get("coverage_plan_points") or [] if plan_points: visited = 0 for (px, py) in plan_points: if any(abs(px - x) <= 1.0 and abs(py - y) <= 1.0 for x, y in positions): visited += 1 metrics["coverage_actual_visited"] = visited else: metrics["coverage_actual_visited"] = 0 return metrics def _percentile(values: list[float], fraction: float) -> float: if not values: return 0.0 ordered = sorted(values) index = min(len(ordered) - 1, max(0, int(round(fraction * (len(ordered) - 1))))) return ordered[index] def aggregate(rows: list[dict], labels: list[str]) -> dict: """逐变体汇总 + 相对首变体的配对统计。""" summary: dict[str, dict] = {} for label in labels: part = [row for row in rows if row["variant"] == label] if not part: continue times = [row["virtual_time_s"] for row in part] summary[label] = { "episodes": len(part), "valid_episodes": sum(1 for row in part if row["valid"]), "virtual_time_mean_s": round(statistics.fmean(times), 1), "virtual_time_median_s": round(statistics.median(times), 1), "virtual_time_max_s": round(max(times), 1), "virtual_time_p90_s": round(_percentile(times, 0.9), 1), "under_4000_ratio": round( sum(1 for t in times if t < UNDER_TARGET_S) / len(times), 3), "measures_mean": round(statistics.fmean( [row["measures"] for row in part]), 1), "total_requests_mean": round(statistics.fmean( [row["total_requests"] for row in part]), 1), "move_distance_mean_m": round(statistics.fmean( [row["move_distance_m"] for row in part]), 1), "fallback_attempts_mean": round(statistics.fmean( [row["fallback_clear_attempts"] for row in part]), 1), "compute_wall_mean_s": round(statistics.fmean( [row["compute_wall_s"] for row in part]), 3), "plan_verify_wall_mean_s": round(statistics.fmean( [row["plan_verify_wall_s"] for row in part]), 3), "distinct_service_points_mean": round(statistics.fmean( [row["distinct_service_points"] for row in part]), 1), "route_gap_vs_offline_mean": round(statistics.fmean( [row["route_gap_vs_offline"] for row in part]), 4), "route_lower_bound_mean_s": round(statistics.fmean( [row["route_lower_bound_s"] for row in part]), 1), "coverage_actual_visited_mean": round(statistics.fmean( [row["coverage_actual_visited"] for row in part]), 1), "probe_no_signal_ratio_mean": round(statistics.fmean( [row["probe_no_signal_ratio"] for row in part]), 4), } paired: dict[str, dict] = {} if len(labels) >= 2: base = labels[0] base_rows = {row["scene_id"]: row for row in rows if row["variant"] == base} for label in labels[1:]: deltas, faster = [], 0 for row in rows: if row["variant"] != label or row["scene_id"] not in base_rows: continue reference = base_rows[row["scene_id"]]["virtual_time_s"] if reference <= 0: continue deltas.append(row["virtual_time_s"] / reference - 1.0) if row["virtual_time_s"] < reference: faster += 1 if deltas: paired[f"{label}_vs_{base}"] = { "mean_relative_change": round(statistics.fmean(deltas), 4), "median_relative_change": round(statistics.median(deltas), 4), "faster_scenes": faster, "compared": len(deltas)} return {"variants": summary, "paired": paired} def main() -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--problem", type=int, choices=(3, 4), default=3) parser.add_argument("--suite", choices=("core", "boundary", "all"), default="core") parser.add_argument("--variants", default=None, help="逗号分隔;支持 名称@覆盖计划,默认取该问题的标准变体") parser.add_argument("--q4-plan", default=None, help="第四问覆盖计划(所有变体默认值)") parser.add_argument("--bearing-margin-deg", type=float, default=0.005) parser.add_argument("--bayes-json", default=None, help="BayesConfig 覆盖参数(JSON 字符串或文件路径)") parser.add_argument("--jobs", type=int, default=1, help="并行进程数") parser.add_argument("--label", default="run", help="输出子目录名") parser.add_argument("--out", type=Path, default=None, help="输出根目录,默认 output/suite/") args = parser.parse_args() default_variants = "bayes,seven_batch" if args.problem == 3 else "directional" variants = [parse_variant(item.strip(), args.problem) for item in (args.variants or default_variants).split(",") if item.strip()] labels = [label for label, *_ in variants] bayes_overrides = None if args.bayes_json: raw = (Path(args.bayes_json).read_text(encoding="utf-8") if Path(args.bayes_json).exists() else args.bayes_json) bayes_overrides = json.loads(raw) print(f"bayes 覆盖参数:{json.dumps(bayes_overrides, ensure_ascii=False)}") scenes = suite(args.suite, args.problem) out_dir = (args.out or (ROOT / "output/suite")) / f"q{args.problem}-{args.label}" out_dir.mkdir(parents=True, exist_ok=True) manifest = suite_manifest(args.suite, args.problem) manifest.update({"suite_version": SUITE_VERSION, "label": args.label, "variants": list( (args.variants or default_variants).split(",")), "jobs": args.jobs, "bearing_margin_deg": args.bearing_margin_deg, "q4_plan": args.q4_plan}) (out_dir / "manifest.json").write_text( json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") payloads = [ {"scene": scene.as_dict(), "label": label, "strategy": strategy, "plan": plan or (args.q4_plan if args.problem == 4 else None), "bayes": {**(bayes_overrides or {}), **variant_bayes}, "search": variant_search, "margin_deg": args.bearing_margin_deg} for scene in scenes for label, strategy, plan, variant_bayes, variant_search in variants] print(f"基准集 {args.suite}/q{args.problem}:{len(scenes)} 场景 × " f"{len(variants)} 变体 = {len(payloads)} 局,jobs={args.jobs}", flush=True) start = perf_counter() if args.jobs > 1: with ProcessPoolExecutor(max_workers=args.jobs) as pool: rows = list(pool.map(run_episode, payloads)) else: rows = [run_episode(payload) for payload in payloads] wall = perf_counter() - start rows.sort(key=lambda row: (row["scene_id"], row["variant"])) for row in rows: print(f" {row['scene_id']:28s} {row['variant']:34s} " f"T={row['virtual_time_s']:9.1f} M={row['measures']:4d} " f"wall={row['compute_wall_s']:6.2f}s " f"{'OK' if row['valid'] else 'INVALID'}", flush=True) summary = aggregate(rows, labels) summary["all_valid"] = all(row["valid"] for row in rows) summary["wall_seconds"] = round(wall, 2) summary["episodes"] = len(rows) summary["suite"] = args.suite summary["problem"] = args.problem summary["label"] = args.label payload = {"summary": summary, "rows": rows} (out_dir / "results.json").write_text( json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") (out_dir / "aggregate.json").write_text( json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") with (out_dir / "aggregate.csv").open("w", encoding="utf-8", newline="") as handle: writer = csv.writer(handle) header = ["scene_id", "variant", "problem", "kind", "sources", "directional"] header += [key for key in rows[0] if key not in header] writer.writerow(header) for row in rows: writer.writerow([row.get(key, "") for key in header]) print(json.dumps(summary, ensure_ascii=False, indent=2)) print(f"\n结果已写入 {out_dir}") return 0 if summary["all_valid"] else 1 if __name__ == "__main__": raise SystemExit(main())