yes fuking shit
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
"""运行历史计时分解:对任意会话日志按官方计时模型独立复算并分相。
|
||||
|
||||
支持两种格式(自动识别):
|
||||
- 参赛队客户端日志:``scripts/capture_practice.py`` / ``SimulatorClient`` 写出的
|
||||
JSONL,逐行 ``attempt`` / ``accepted`` / ``step``;
|
||||
- 评测机日志:``referee-data/sessions/<case>/log.jsonl``,逐行
|
||||
``session_start`` / ``action`` / ``session_end``。
|
||||
|
||||
复算口径与 ``scripts/verify_q3_practice.py::timing_rows`` 完全一致(题面附件2 §4.1):
|
||||
``T = Σ 距离/5 + 5·N_measure + N_switch + 5·N_clear⁺ + 3·N_clear⁻``,
|
||||
起点位置 (0,0)、起始频道 1,``/clear`` 不计换频,移动按整数微秒累计。
|
||||
|
||||
用途:把"虚拟时间花在哪"变成一条可随时重跑的命令,而不是一次性的分析结论。
|
||||
|
||||
uv run python scripts/analyze_run_timing.py session.jsonl
|
||||
uv run python scripts/analyze_run_timing.py referee-data/sessions/*/log.jsonl --json out.json
|
||||
uv run python scripts/analyze_run_timing.py session.jsonl --plan seven # 站点/探点分类
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
SPEED_MPS = 5.0 # [题面] 附件1 表1
|
||||
MEASURE_S = 5 # [题面]
|
||||
SWITCH_S = 1 # [题面]
|
||||
CLEAR_SUCCESS_S = 5 # [题面]
|
||||
CLEAR_FAIL_S = 3 # [题面]
|
||||
START_POSITION = (0.0, 0.0)
|
||||
START_CHANNEL = 1
|
||||
|
||||
|
||||
def load_actions(path: Path) -> list[dict]:
|
||||
"""读取一份 JSONL,归一化为已接受动作序列。"""
|
||||
actions: list[dict] = []
|
||||
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
record = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
event = record.get("event")
|
||||
if event == "accepted":
|
||||
request, response = record.get("request", {}), record.get("response", {})
|
||||
accepted = bool(response.get("accepted", True))
|
||||
elif event == "action":
|
||||
request, response = record.get("request", {}), record.get("response", {})
|
||||
accepted = bool(response.get("accepted", record.get("status") == 200))
|
||||
else:
|
||||
continue
|
||||
if not accepted:
|
||||
continue
|
||||
actions.append({
|
||||
"path": record.get("path") or request.get("path") or "",
|
||||
"position": request.get("position"),
|
||||
"channel": request.get("channel"),
|
||||
"result": response.get("measure_result") or response.get("clear_result") or "",
|
||||
"recorded_s": response.get("virtual_time_s"),
|
||||
"elapsed_s": record.get("elapsed_s"),
|
||||
})
|
||||
return actions
|
||||
|
||||
|
||||
def recompute(actions: list[dict]) -> tuple[list[dict], dict]:
|
||||
"""按官方模型逐动作复算;返回明细与汇总(微秒整数累计)。"""
|
||||
position, channel, virtual_us = START_POSITION, START_CHANNEL, 0
|
||||
rows: list[dict] = []
|
||||
totals = Counter()
|
||||
distance_total = 0.0
|
||||
max_gap_us = 0
|
||||
for index, action in enumerate(actions):
|
||||
path = action["path"]
|
||||
move_s = switches = action_s = 0.0
|
||||
if path in ("/measure", "/clear") and action["position"] is not None:
|
||||
target = (float(action["position"]["x"]), float(action["position"]["y"]))
|
||||
distance_total += math.dist(position, target)
|
||||
move_s = math.dist(position, target) / SPEED_MPS
|
||||
position = target
|
||||
if path == "/measure":
|
||||
switches = int(channel != action["channel"])
|
||||
channel = action["channel"]
|
||||
action_s = MEASURE_S
|
||||
else:
|
||||
action_s = CLEAR_SUCCESS_S if action["result"] == "success" else CLEAR_FAIL_S
|
||||
move_us = round(move_s * 1_000_000)
|
||||
virtual_us += move_us + int((switches + action_s) * 1_000_000)
|
||||
totals["move_us"] += move_us
|
||||
totals["switch_s"] += switches
|
||||
totals["action_us"] += int(action_s * 1_000_000)
|
||||
totals["count"] += 1
|
||||
if action["elapsed_s"]:
|
||||
totals["elapsed_s"] += action["elapsed_s"]
|
||||
recorded = action["recorded_s"]
|
||||
gap_us = ((round(recorded * 1_000_000) - virtual_us)
|
||||
if isinstance(recorded, (int, float)) else 0)
|
||||
max_gap_us = max(max_gap_us, abs(gap_us))
|
||||
rows.append({"index": index, "path": path, "channel": action["channel"],
|
||||
"x": position[0], "y": position[1], "result": action["result"],
|
||||
"move_us": move_us, "switch_s": switches, "action_s": action_s,
|
||||
"recomputed_us": virtual_us, "recorded_s": recorded, "gap_us": gap_us})
|
||||
summary = {
|
||||
"actions": len(actions),
|
||||
"virtual_s": round(virtual_us / 1_000_000, 6),
|
||||
"move_s": round(totals["move_us"] / 1_000_000, 3),
|
||||
"switch_s": float(totals["switch_s"]),
|
||||
"action_s": round(totals["action_us"] / 1_000_000, 3),
|
||||
"distance_m": round(distance_total, 1),
|
||||
"real_elapsed_s": round(totals["elapsed_s"], 3),
|
||||
"max_recorded_gap_s": max_gap_us / 1_000_000,
|
||||
}
|
||||
return rows, summary
|
||||
|
||||
|
||||
def phase_report(rows: list[dict]) -> dict:
|
||||
"""清除前后分相 + 重复复扫检测(定位"时间被重复动作吃掉"的历史问题)。"""
|
||||
clear_indices = [r["index"] for r in rows if r["path"] == "/clear"]
|
||||
last_clear = max(clear_indices) if clear_indices else None
|
||||
measure_keys = defaultdict(int)
|
||||
for row in rows:
|
||||
if row["path"] == "/measure":
|
||||
key = (round(row["x"] * 2), round(row["y"] * 2), row["channel"])
|
||||
measure_keys[key] += 1
|
||||
repeated = sum(count - 1 for count in measure_keys.values() if count > 1)
|
||||
report = {
|
||||
"measures": sum(1 for r in rows if r["path"] == "/measure"),
|
||||
"clears": len(clear_indices),
|
||||
"failed_clears": sum(1 for r in rows
|
||||
if r["path"] == "/clear" and r["result"] != "success"),
|
||||
"distinct_measure_stops": len(measure_keys),
|
||||
"repeated_measure_actions": repeated,
|
||||
"repeat_ratio": round(repeated / max(1, len(measure_keys)), 3),
|
||||
}
|
||||
if last_clear is not None:
|
||||
tail = [r for r in rows if r["index"] > last_clear]
|
||||
report["last_clear_virtual_s"] = round(rows[last_clear]["recomputed_us"] / 1_000_000, 3)
|
||||
report["after_last_clear_virtual_s"] = round(
|
||||
sum(r["move_us"] + (r["switch_s"] + r["action_s"]) * 1_000_000 for r in tail)
|
||||
/ 1_000_000, 3)
|
||||
report["after_last_clear_measures"] = sum(1 for r in tail if r["path"] == "/measure")
|
||||
return report
|
||||
|
||||
|
||||
def classify_by_plan(rows: list[dict], points, tol_m: float = 2.0) -> dict:
|
||||
"""按给定站点集把 /measure 分成"站点扫频"与"专用探点",并给出各自移动耗时。"""
|
||||
buckets: dict[str, Counter] = defaultdict(Counter)
|
||||
for row in rows:
|
||||
if row["path"] != "/measure":
|
||||
continue
|
||||
nearest = min((math.dist((row["x"], row["y"]), p) for p in points), default=math.inf)
|
||||
kind = "station" if nearest <= tol_m else "probe"
|
||||
buckets[kind]["n"] += 1
|
||||
buckets[kind]["move_us"] += row["move_us"]
|
||||
return {kind: {"measures": value["n"],
|
||||
"move_s": round(value["move_us"] / 1_000_000, 3)}
|
||||
for kind, value in buckets.items()}
|
||||
|
||||
|
||||
def _plan_points(name: str):
|
||||
if name == "none":
|
||||
return None
|
||||
if name == "seven":
|
||||
from mcm26b.planner.seven import seven_points
|
||||
return seven_points()
|
||||
if name == "49":
|
||||
from mcm26b.coverage import coverage_points
|
||||
return coverage_points()
|
||||
raise ValueError(f"未知 --plan: {name}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("logs", nargs="+", type=Path, help="会话 JSONL(客户端或评测机格式)")
|
||||
parser.add_argument("--plan", choices=("none", "seven", "49"), default="none",
|
||||
help="站点集,用于把 /measure 分成站点扫频与专用探点")
|
||||
parser.add_argument("--json", type=Path, default=None, help="汇总输出路径")
|
||||
parser.add_argument("--top", type=int, default=6, help="逐频道表格显示前 N 个热点频道")
|
||||
args = parser.parse_args()
|
||||
|
||||
points = _plan_points(args.plan)
|
||||
payload = {}
|
||||
for path in args.logs:
|
||||
actions = load_actions(path)
|
||||
if not actions:
|
||||
print(f"[跳过] {path}:没有已接受动作")
|
||||
continue
|
||||
rows, summary = recompute(actions)
|
||||
report = phase_report(rows)
|
||||
total = summary["virtual_s"] or 1.0
|
||||
print(f"\n===== {path} =====")
|
||||
print(f"虚拟总时间 {summary['virtual_s']:.3f} s "
|
||||
f"移动 {summary['move_s']:.1f} s ({100 * summary['move_s'] / total:.1f}%) "
|
||||
f"检测/清除动作 {summary['action_s']:.1f} s ({100 * summary['action_s'] / total:.1f}%) "
|
||||
f"换频 {summary['switch_s']:.0f} s ({100 * summary['switch_s'] / total:.1f}%)")
|
||||
print(f"移动距离 {summary['distance_m']:.1f} m 请求 {summary['actions']} 次 "
|
||||
f"现实耗时 {summary['real_elapsed_s']:.3f} s "
|
||||
f"与服务器记录最大偏差 {summary['max_recorded_gap_s']:.6f} s")
|
||||
print(f"检测 {report['measures']} 清除 {report['clears']}(失败 {report['failed_clears']}) "
|
||||
f"不同测点 {report['distinct_measure_stops']} "
|
||||
f"重复检测 {report['repeated_measure_actions']}"
|
||||
f"(重复率 {report['repeat_ratio']})")
|
||||
if "last_clear_virtual_s" in report:
|
||||
print(f"末次清除于 {report['last_clear_virtual_s']:.1f} s;其后又花 "
|
||||
f"{report['after_last_clear_virtual_s']:.1f} s 做 "
|
||||
f"{report['after_last_clear_measures']} 次检测")
|
||||
per_channel = Counter(r["channel"] for r in rows if r["path"] == "/measure")
|
||||
hottest = per_channel.most_common(args.top)
|
||||
print("检测次数最多的频道:" + ",".join(f"ch{c}:{n}" for c, n in hottest))
|
||||
if points is not None:
|
||||
print("测点分类:" + json.dumps(classify_by_plan(rows, points),
|
||||
ensure_ascii=False))
|
||||
payload[str(path)] = {"summary": summary, "phases": report,
|
||||
"per_channel_measures": dict(sorted(per_channel.items()))}
|
||||
|
||||
if args.json is not None:
|
||||
args.json.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.json.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8")
|
||||
print(f"\n汇总已写入 {args.json}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,361 @@
|
||||
"""冻结基准集的配对 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())
|
||||
@@ -0,0 +1,241 @@
|
||||
"""第四问覆盖证书与最小命中集搜索(CLI;核心实现见 mcm26b.guarantee.cover)。
|
||||
|
||||
第四问的定向源在"读点位于源的前向半平面内"时才可能被检出,因此
|
||||
"某频道在所有被访点都 no_signal ⟹ 该频道不存在源"的缺席证明,等价于要求
|
||||
被访点集合 S 满足
|
||||
|
||||
∀ p ∈ Ω, ∀ h ∈ [0,360): ∃ c ∈ S,
|
||||
|c − p| ≤ r_eff_min(1000) 且 (c − p)·u(h) ≥ 0
|
||||
|
||||
(题面规定朝向覆盖"两侧各 90 度,含边界",故取 ≥ 0)。现役 49 点方格的
|
||||
"单元对角 ≤ 1000"是**充分**条件但远非必要;本脚本用贪心击中集求等价覆盖的
|
||||
更小点集,并输出可复现证书。
|
||||
|
||||
uv run python scripts/verify_q4_cover.py # 校核 + 搜索 + 打印
|
||||
uv run python scripts/verify_q4_cover.py --spacing 200 --write-plan q4_mincover_v1
|
||||
uv run python scripts/verify_q4_cover.py --check-json my_points.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from mcm26b.config import ProblemConfig
|
||||
from mcm26b.guarantee import (CERTIFY_HEADINGS, CERTIFY_STEP_M, R_EFF_MIN_M,
|
||||
CoverageCertificate, CoveragePlan,
|
||||
candidate_lattice, coverage_masks, forward_targets,
|
||||
greedy_cover, omega_positions, plan_names,
|
||||
square_49_plan, verify_full_disk,
|
||||
verify_forward_half_disk, verify_plan, write_plan)
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_PLAN_DIR = ROOT / "src/mcm26b/guarantee/plans"
|
||||
|
||||
|
||||
def _report(points, kind: str, step_m: float, headings: int,
|
||||
r_eff: float = R_EFF_MIN_M,
|
||||
heading_margin_deg: float = 0.0) -> dict:
|
||||
"""按覆盖语义校核点集,返回证书字典。"""
|
||||
if kind == "forward_half_disk":
|
||||
certificate = verify_forward_half_disk(points, step_m, headings,
|
||||
r_eff=r_eff,
|
||||
heading_margin_deg=heading_margin_deg)
|
||||
else:
|
||||
certificate = verify_full_disk(points, step_m, r_eff)
|
||||
return certificate.as_dict()
|
||||
|
||||
|
||||
def prune_redundant(masks: list[int], chosen: list[int]) -> list[int]:
|
||||
"""贪心结果冗余剪枝:删掉任何"其余点已完全覆盖其目标"的点。"""
|
||||
full = 0
|
||||
for index in chosen:
|
||||
full |= masks[index]
|
||||
pruned = list(chosen)
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for index in list(pruned):
|
||||
rest = 0
|
||||
for other in pruned:
|
||||
if other != index:
|
||||
rest |= masks[other]
|
||||
if rest == full:
|
||||
pruned.remove(index)
|
||||
changed = True
|
||||
return pruned
|
||||
|
||||
|
||||
def fine_prune(points, step_m: float, headings: int, r_eff: float,
|
||||
heading_margin_deg: float):
|
||||
"""证书分辨率上的冗余剪枝:删掉任何点后校核仍 0 缺口才真删。
|
||||
|
||||
粗网格(搜索用)上的冗余判据偏保守,会在证书分辨率下留下可删的点;
|
||||
本步每个候选删除都用**证书同一口径**复核,因此不削弱证书。
|
||||
"""
|
||||
points = list(points)
|
||||
improved = True
|
||||
while improved:
|
||||
improved = False
|
||||
for index in range(len(points)):
|
||||
trial = points[:index] + points[index + 1:]
|
||||
certificate = verify_forward_half_disk(
|
||||
trial, step_m, headings, r_eff=r_eff,
|
||||
heading_margin_deg=heading_margin_deg)
|
||||
if certificate.uncovered == 0:
|
||||
points = trial
|
||||
improved = True
|
||||
print(f" 精细剪枝:删点后仍 0 缺口,剩 {len(points)} 点", flush=True)
|
||||
break
|
||||
return points
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--spacing", type=float, default=200.0,
|
||||
help="贪心候选点网格间距(米)")
|
||||
parser.add_argument("--radius", type=float, default=2850.0,
|
||||
help="候选点所在圆盘半径(米)")
|
||||
parser.add_argument("--target-step", type=float, default=CERTIFY_STEP_M,
|
||||
help="搜索用采样位置步长(米)")
|
||||
parser.add_argument("--headings", type=int, default=CERTIFY_HEADINGS,
|
||||
help="搜索用采样朝向档数")
|
||||
parser.add_argument("--verify-step", type=float, default=None,
|
||||
help="证书校核位置步长(默认等于 --target-step)")
|
||||
parser.add_argument("--verify-headings", type=int, default=None,
|
||||
help="证书校核朝向档数(默认等于 --headings)")
|
||||
parser.add_argument("--design-radius", type=float, default=None,
|
||||
help="搜索时的设计距离(默认 1000;<1000 即留裕量)")
|
||||
parser.add_argument("--heading-margin", type=float, default=0.0,
|
||||
help="搜索时的前向角裕量(度)")
|
||||
parser.add_argument("--verify-radius", type=float, default=None,
|
||||
help="证书校核距离(默认 1000;<1000 即留裕量)")
|
||||
parser.add_argument("--verify-heading-margin", type=float, default=0.0,
|
||||
help="证书校核前向角裕量(度);应 ≥ 180/verify_headings/2")
|
||||
parser.add_argument("--no-prune", action="store_true", help="关闭冗余剪枝")
|
||||
parser.add_argument("--fine-prune", action="store_true",
|
||||
help="在证书分辨率上再做一轮冗余剪枝(删点后仍 0 缺口才删)")
|
||||
parser.add_argument("--coverage", choices=("forward_half_disk", "full_disk"),
|
||||
default="forward_half_disk", help="覆盖语义")
|
||||
parser.add_argument("--check-json", type=Path, default=None,
|
||||
help="只校核给定点集([[x,y],...] 或 {\"points\":[...]})")
|
||||
parser.add_argument("--write-plan", default=None,
|
||||
help="把贪心最小集写成覆盖计划(名称,落 src/mcm26b/guarantee/plans/)")
|
||||
parser.add_argument("--out", type=Path, default=None, help="证书 JSON 输出路径")
|
||||
args = parser.parse_args()
|
||||
|
||||
verify_step = (args.verify_step if args.verify_step is not None
|
||||
else args.target_step)
|
||||
verify_headings = (args.verify_headings if args.verify_headings is not None
|
||||
else args.headings)
|
||||
design_radius = (args.design_radius if args.design_radius is not None
|
||||
else R_EFF_MIN_M)
|
||||
verify_radius = (args.verify_radius if args.verify_radius is not None
|
||||
else R_EFF_MIN_M)
|
||||
|
||||
if args.check_json is not None:
|
||||
payload = json.loads(args.check_json.read_text(encoding="utf-8"))
|
||||
raw = payload.get("points") if isinstance(payload, dict) else payload
|
||||
points = [tuple(map(float, p)) for p in raw]
|
||||
report = _report(points, args.coverage, verify_step, verify_headings,
|
||||
verify_radius, args.verify_heading_margin)
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
return 0 if report["uncovered"] == 0 else 1
|
||||
|
||||
shipped = square_49_plan()
|
||||
shipped_report = _report(shipped.points, "forward_half_disk",
|
||||
verify_step, verify_headings,
|
||||
verify_radius, args.verify_heading_margin)
|
||||
print(f"目标空间:{shipped_report['targets']} 个 (p,h)"
|
||||
f"(校核位置步长 {verify_step} m,朝向 {verify_headings} 档,"
|
||||
f"r_verify={verify_radius:.0f} m,角裕量 "
|
||||
f"{args.verify_heading_margin}°)")
|
||||
print(f"现役 {shipped.name}:{len(shipped.points)} 点,"
|
||||
f"缺口 {shipped_report['uncovered']},"
|
||||
f"NN 巡回 {shipped_report['nn_tour_m'] / 1000:.1f} km"
|
||||
f"({shipped_report['nn_tour_virtual_s']:.0f} s 移动)", flush=True)
|
||||
|
||||
targets = forward_targets(args.target_step, args.headings)
|
||||
candidates = candidate_lattice(args.spacing, args.radius)
|
||||
print(f"候选点 {len(candidates)}({args.spacing} m 网格,半径 {args.radius} m);"
|
||||
f"设计裕量 r_design={design_radius:.0f} m,前向角裕量 "
|
||||
f"{args.heading_margin}°", flush=True)
|
||||
masks = coverage_masks(targets, candidates, r_eff=design_radius,
|
||||
heading_margin_deg=args.heading_margin)
|
||||
chosen = greedy_cover(masks, len(targets))
|
||||
if not args.no_prune:
|
||||
chosen = prune_redundant(masks, chosen)
|
||||
best_points = [candidates[i] for i in chosen]
|
||||
if args.fine_prune:
|
||||
best_points = fine_prune(best_points, verify_step, verify_headings,
|
||||
verify_radius, args.verify_heading_margin)
|
||||
best_report = _report(best_points, "forward_half_disk",
|
||||
verify_step, verify_headings,
|
||||
verify_radius, args.verify_heading_margin)
|
||||
print(f"设计裕量最小命中集:{len(best_points)} 点,缺口 {best_report['uncovered']},"
|
||||
f"NN 巡回 {best_report['nn_tour_m'] / 1000:.1f} km"
|
||||
f"({best_report['nn_tour_virtual_s']:.0f} s 移动)", flush=True)
|
||||
|
||||
if best_report["uncovered"] != 0:
|
||||
print("贪心结果未通过证书校核,拒绝生成计划。")
|
||||
return 1
|
||||
|
||||
generator = {"script": "scripts/verify_q4_cover.py",
|
||||
"spacing_m": args.spacing, "radius_m": args.radius,
|
||||
"target_step_m": args.target_step, "headings": args.headings,
|
||||
"design_r_eff_m": design_radius,
|
||||
"heading_margin_deg": args.heading_margin,
|
||||
"pruned": not args.no_prune,
|
||||
"verify_step_m": verify_step,
|
||||
"verify_headings": verify_headings,
|
||||
"verify_r_eff_m": verify_radius,
|
||||
"verify_heading_margin_deg": args.verify_heading_margin,
|
||||
"code_sha256": hashlib.sha256(
|
||||
Path(__file__).read_bytes()).hexdigest()}
|
||||
certificate = CoverageCertificate(
|
||||
kind=best_report["kind"], target_step_m=verify_step,
|
||||
headings=verify_headings, r_eff_min_m=verify_radius,
|
||||
targets=best_report["targets"], uncovered=best_report["uncovered"],
|
||||
worst_hit_distance_m=best_report["worst_hit_distance_m"],
|
||||
nn_tour_m=best_report["nn_tour_m"],
|
||||
generator_sha256=generator["code_sha256"],
|
||||
verify_heading_margin_deg=args.verify_heading_margin)
|
||||
plan = CoveragePlan(
|
||||
name=args.write_plan or "q4_mincover_draft", problem=4,
|
||||
kind="certified", coverage="forward_half_disk",
|
||||
note=(f"设计裕量最小命中集:{len(best_points)} 点,"
|
||||
f"r_design={design_radius:.0f} m / 前向角裕量 {args.heading_margin}°;"
|
||||
f"证书按 {verify_step:.0f} m/{verify_headings} 档、r_verify="
|
||||
f"{verify_radius:.0f} m、角裕量 {args.verify_heading_margin}° "
|
||||
f"校核缺口 0(同一证书口径复核,含精细剪枝);最坏命中 "
|
||||
f"{best_report['worst_hit_distance_m']:.0f} m,"
|
||||
f"NN 巡回 {best_report['nn_tour_m'] / 1000:.1f} km"),
|
||||
generator=generator, certificate=certificate,
|
||||
points=tuple(best_points))
|
||||
payload = {"model": {"r_omega_m": 1800.0, "r_eff_min_m": 1000.0},
|
||||
"design": {"r_design_m": design_radius,
|
||||
"heading_margin_deg": args.heading_margin},
|
||||
"verify": {"r_eff_m": verify_radius,
|
||||
"heading_margin_deg": args.verify_heading_margin,
|
||||
"step_m": verify_step, "headings": verify_headings},
|
||||
"shipped": shipped_report, "mincover": best_report}
|
||||
if args.out is not None:
|
||||
out = args.out if args.out.is_absolute() else ROOT / args.out
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8")
|
||||
print(f"证书已写入 {out}")
|
||||
if args.write_plan:
|
||||
target = DEFAULT_PLAN_DIR / f"{args.write_plan}.json"
|
||||
write_plan(plan, target)
|
||||
verified = verify_plan(plan)
|
||||
print(f"计划已写入 {target};运行期分辨率复核缺口 {verified.uncovered}")
|
||||
print("已注册计划:" + ",".join(plan_names()))
|
||||
_ = (ProblemConfig(), omega_positions) # 保持公开入口可发现性
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user