Files
mcm-26-b/scripts/verify_q3_practice.py
T
2026-09-11 03:26:20 +08:00

238 lines
12 KiB
Python

"""Offline audit of an official Q3 run; never connects to the simulator.
Usage: python scripts/verify_q3_practice.py output/q3/practice/20260911-port2026
Preserves session.jsonl and summary.json; writes derived audit artifacts only.
"""
from __future__ import annotations
import argparse
import csv
from dataclasses import asdict
from datetime import datetime
import hashlib
import json
import math
from pathlib import Path
from urllib.parse import urlsplit
from benchmark_q3_search import SevenBatch, seven_points
from mcm26b.config import ProblemConfig
from mcm26b.coverage import coverage_points
from mcm26b.evaluation import recompute_virtual_time
from mcm26b.planner import OmnidirectionalLoop
from mcm26b.protocol.client import SimulatorClient
from mcm26b.sim.playback import PlaybackTransport
class StrictPlayback(PlaybackTransport):
"""Check complete action content, not merely the sequence of API paths."""
def post(self, url, body, headers, timeout):
if self.index >= len(self.events):
raise AssertionError("Replay attempted an extra action")
expected = self.events[self.index]
actual_request = json.loads(body)
without_id = lambda value: {k: v for k, v in value.items() if k != "request_id"}
if (urlsplit(url).path != expected["path"] or
without_id(actual_request) != without_id(expected["request"])):
raise AssertionError(f"Replay action differs at index {self.index}")
return super().post(url, body, headers, timeout)
class AuditMixin:
def __init__(self, client, config):
super().__init__(client, config)
self.certificates = []
def _attempt_clear(self, channel, target, cert):
vertices = self.belief.candidate_set(channel).polygon
self.certificates.append({**asdict(cert), "submitted_position": target,
"submitted_max_vertex_distance_m":
max(math.dist(target, v) for v in vertices)})
return super()._attempt_clear(channel, target, cert)
class AuditedLoop(AuditMixin, OmnidirectionalLoop):
pass
class AuditedSevenBatch(AuditMixin, SevenBatch):
pass
def save_json(path, value):
path.write_text(json.dumps(value, ensure_ascii=False, indent=2,
allow_nan=False) + "\n", encoding="utf-8")
def audit(folder: Path) -> dict:
records = [json.loads(line) for line in
(folder / "session.jsonl").read_text(encoding="utf-8").splitlines()]
events = [e for e in records if e.get("event") == "accepted"]
original = json.loads((folder / "summary.json").read_text(encoding="utf-8-sig"))
strategy = original.get("strategy", "grid49_immediate")
if strategy not in ("grid49_immediate", "seven_batch"):
raise ValueError(f"Unsupported strategy: {strategy}")
official_paths = list(folder.glob("*.result.json"))
if len(official_paths) != 1 or not events:
raise ValueError("Expected one official result and nonempty session evidence")
official_path = official_paths[0]
official = json.loads(official_path.read_text(encoding="utf-8"))
package = official_path.with_name(official_path.name.replace(".result.json", ".jlog"))
checks = {}
checks["official_problem_3_all_omnidirectional"] = (
official["problem_no"] == 3 and official["directional_jammer_count"] == 0
and 10 <= official["jammer_count"] <= 16
and official["omnidirectional_jammer_count"] == official["jammer_count"])
checks["official_package_sha256_matches"] = (
hashlib.sha256(package.read_bytes()).hexdigest() == official["package_sha256"])
checks["normal_enter_and_exit"] = (
events[0]["path"] == "/enter" and events[-1]["path"] == "/exit"
and events[-1]["response"].get("exit_reason") == "user_exit")
start_ms = datetime.fromisoformat(official["window_started_at_utc"]).timestamp() * 1000
end_ms = datetime.fromisoformat(official["ended_at_utc"]).timestamp() * 1000
checks["responses_within_official_run_window"] = all(
start_ms <= e["response"]["real_timestamp_ms"] <= end_ms + 1 for e in events)
ids = [e["request"]["request_id"] for e in events]
checks["unique_accepted_request_ids"] = len(ids) == len(set(ids))
checks["no_rejections_or_retries"] = (
len(records) == 2 * len(events)
and all(e.get("attempts") == 1 for e in events)
and all(records[2*i].get("event") == "attempt"
and records[2*i]["request"] == e["request"]
and records[2*i+1] == e for i, e in enumerate(events)))
clears = [e for e in events if e["path"] == "/clear"]
successes = [e for e in clears if e["response"].get("clear_result") == "success"]
cleared = sorted({e["request"]["channel"] for e in successes})
checks["all_official_sources_cleared"] = len(cleared) == official["jammer_count"]
checks["every_clear_succeeded_once"] = len(clears) == len(successes) == len(cleared)
checks["original_summary_completed"] = (
original["completed"] and original["cleared_channels"] == cleared
and original["unresolved_channels"] == [] and original["anomalies"] == [])
grid = set(seven_points() if strategy == "seven_batch" else coverage_points())
absent_coverage = {}
for channel in sorted(set(range(1, 21)) - set(cleared)):
channel_events = [e for e in events if e["path"] == "/measure"
and e["request"]["channel"] == channel]
points = {(e["request"]["position"]["x"], e["request"]["position"]["y"])
for e in channel_events if e["response"]["measure_result"] == "no_signal"}
absent_coverage[str(channel)] = {
"grid_points_measured": len(points & grid), "required_grid_points": len(grid),
"all_no_signal": all(e["response"]["measure_result"] == "no_signal"
for e in channel_events),
"complete": grid <= points,
}
checks["absent_channels_fully_covered"] = all(
item["complete"] and item["all_no_signal"] for item in absent_coverage.values())
# Official attachment 2 section 4.1: accumulate virtual time in microseconds.
# Round each move to the nearest microsecond, then add integer action costs.
position, channel, virtual_us = (0.0, 0.0), 1, 0
distance_total, switch_total, move_total_us = 0.0, 0, 0
rows = []
previous_recorded = 0.0
max_local_gap = 0.0
for i, event in enumerate(events):
request, response, path = event["request"], event["response"], event["path"]
distance, switches, action_s = 0.0, 0, 0
if path in ("/measure", "/clear"):
target = (request["position"]["x"], request["position"]["y"])
distance = math.dist(position, target)
position = target
if path == "/measure":
switches = int(channel != request["channel"])
channel = request["channel"]
action_s = 5
else:
action_s = 5 if response["clear_result"] == "success" else 3
move_us = round(distance / 5 * 1_000_000)
virtual_us += move_us + (switches + action_s) * 1_000_000
distance_total += distance
switch_total += switches
move_total_us += move_us
recorded = response["virtual_time_s"]
gap_us = round(recorded * 1_000_000) - virtual_us
max_local_gap = max(max_local_gap,
abs(recorded - previous_recorded - distance/5 - switches - action_s))
previous_recorded = recorded
rows.append({"index": i, "path": path, "request_id": request["request_id"],
"channel": request.get("channel", ""), "x": position[0], "y": position[1],
"result": response.get("measure_result", response.get("clear_result", "")),
"svd_deg": response.get("svd_deg", ""), "distance_m": distance,
"move_us": move_us, "switch_s": switches, "action_s": action_s,
"recomputed_us": virtual_us, "recorded_s": recorded, "gap_us": gap_us})
checks["all_timestamps_match_microsecond_recomputation"] = all(r["gap_us"] == 0 for r in rows)
real_s = (events[-1]["response"]["real_timestamp_ms"] -
events[0]["response"]["real_timestamp_ms"]) / 1000
checks["within_real_and_virtual_limits"] = (
real_s < events[0]["response"]["remaining_real_duration_s"]
and virtual_us / 1_000_000 < events[0]["response"]["max_virtual_duration_s"])
# Exact action replay independently checks decisions using only recorded responses.
transport = StrictPlayback(events)
config = ProblemConfig()
client = SimulatorClient(original["robot_id"], transport=transport, config=config,
clock=lambda: 0.0)
loop = (AuditedSevenBatch if strategy == "seven_batch" else AuditedLoop)(client, config)
replay_summary = loop.run()
checks["deterministic_action_replay"] = (
transport.index == len(events) and replay_summary["completed"]
and all(replay_summary[k] == original[k] for k in
("stop_reason", "cleared_channels", "absent_channels", "measures", "clears")))
checks["all_clear_certificates_valid"] = (
len(loop.certificates) == len(clears)
and all(c["certified"] and c["radius_m"] <= c["limit_m"] < 20
and c["submitted_max_vertex_distance_m"] <= c["limit_m"]
for c in loop.certificates))
raw_timing = recompute_virtual_time(events)
report = {
"passed": all(checks.values()), "source": "official_practice", "strategy": strategy,
"case_code": official["case_code"], "checks": checks,
"official_jammer_count": official["jammer_count"], "cleared_channels": cleared,
"absent_channel_coverage": absent_coverage,
"accepted_requests_including_enter_exit": len(events),
"measures": len([e for e in events if e["path"] == "/measure"]),
"clear_attempts": len(clears), "clear_successes": len(successes),
"real_elapsed_server_s": real_s,
"real_elapsed_local_s": events[-1]["logged_at_unix_s"] - events[0]["logged_at_unix_s"],
"virtual_time_s": virtual_us / 1_000_000,
"last_clear_virtual_time_s": successes[-1]["response"]["virtual_time_s"],
"move_distance_m": distance_total, "move_time_s": move_total_us / 1_000_000,
"switch_time_s": switch_total,
"measurement_time_s": sum(r["action_s"] for r in rows if r["path"] == "/measure"),
"clear_time_s": sum(r["action_s"] for r in rows if r["path"] == "/clear"),
"timing_audit": {
"original_unquantized_mismatch_count": len(raw_timing["mismatches"]),
"original_unquantized_final_gap_s": previous_recorded - raw_timing["final_virtual_time_s"],
"max_single_action_unquantized_gap_s": max_local_gap,
"microsecond_mismatch_count": sum(r["gap_us"] != 0 for r in rows),
"explanation": "原复算累计未量化浮点耗时;按动作移动耗时取最近整数微秒后,所有官方时刻完全一致。",
},
"clear_certificates": loop.certificates,
"limits": "仅验证当前官方演练案例;不证明所有案例或时间最优性。官方加密日志保持原样,未解密。",
}
with (folder / "actions.csv").open("w", encoding="utf-8-sig", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
writer.writeheader()
writer.writerows(rows)
save_json(folder / "verification.json", report)
save_json(folder / "replay_summary.json", replay_summary)
manifest = {p.name: {"bytes": p.stat().st_size,
"sha256": hashlib.sha256(p.read_bytes()).hexdigest()}
for p in sorted(folder.iterdir()) if p.is_file() and p.name != "manifest.json"}
save_json(folder / "manifest.json", manifest)
return report
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("folder", type=Path)
args = parser.parse_args()
result = audit(args.folder)
print(json.dumps({"passed": result["passed"], "case_code": result["case_code"],
"checks": result["checks"]}, ensure_ascii=False, indent=2))
raise SystemExit(0 if result["passed"] else 1)