Files
mcm-26-b/scripts/run_suite.py
T
2026-09-12 14:48:43 +08:00

362 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""冻结基准集的配对 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())