"""Offline Q3 search ablations for docs/algo-3.md; no official socket access. The production planner is unchanged. Only coverage and task scheduling vary. Run: python scripts/benchmark_q3_search.py --seeds 0,1,2,3,4 """ from __future__ import annotations import argparse from dataclasses import asdict import hashlib import json import math from pathlib import Path from statistics import mean, median from time import perf_counter from mcm26b.belief import ChannelStatus from mcm26b.config import ProblemConfig from mcm26b.coverage import CoverageLedger from mcm26b.planner import OmnidirectionalLoop, clear_certificate from mcm26b.protocol.client import SimulatorClient from mcm26b.protocol.rules import NEAR from mcm26b.sim import LocalSimulator, LocalSimTransport, Source from mcm26b.sim.scenes import random_omni_scene RING_RADIUS_M = 1200.0 # Engineering choice; analytic proof in algo-3 section 5. ROOT = Path(__file__).resolve().parents[1] def seven_points(): return [(0.0, 0.0)] + [(RING_RADIUS_M * math.cos(k * math.pi / 3), RING_RADIUS_M * math.sin(k * math.pi / 3)) for k in range(6)] class SevenImmediate(OmnidirectionalLoop): def __init__(self, client, config): super().__init__(client, config) self.ledger = CoverageLedger(config=config, points=seven_points()) class SevenBatch(SevenImmediate): """Finish scanning a station, then serve detected sources in nearest order.""" defer_all = False def _serve_queue(self): # Each detected channel gets at most one localization call in this pass. queue = self.belief.channels_by_status(ChannelStatus.DETECTED) while queue: c = min(queue, key=lambda c: (math.dist(self.client.state.position, self.belief.candidate_set(c).mec()[0]), c)) queue.remove(c) self._localize_and_clear(c) if self._count_limit_reached(): break def _coverage_phase(self): while not self._count_limit_reached(): index = self._next_point_index() if index is None: return point = self.ledger.points[index] pending = self._pending_channels_at(index) # Keeping the current measuring channel first saves a switch when possible. pending.sort(key=lambda c: (c != self.client.state.channel, c)) for c in pending: outcome = self.client.measure(point, c) self.ledger.mark_measured(c, index) self.belief.update_measure(c, point, outcome.result, outcome.svd_deg) if outcome.result == NEAR: cert = clear_certificate(self.belief.candidate_set(c), c, self.config) # Same-station near clear; do not displace the station scan. if cert.certified: self._attempt_clear(c, point, cert) if self._count_limit_reached(): return if not self.defer_all: self._serve_queue() def _resolve_detected(self): self._serve_queue() unresolved = [c for c, b in self.belief.channels.items() if b.status not in (ChannelStatus.CLEARED, ChannelStatus.ABSENT)] return "all_resolved" if not unresolved else "unresolved_channels" class SevenSweep(SevenBatch): """Finish discovery at all necessary stations before servicing the queue.""" defer_all = True VARIANTS = {"grid49_immediate": OmnidirectionalLoop, "seven_immediate": SevenImmediate, "seven_batch": SevenBatch, "seven_sweep": SevenSweep} def run_case(name, sources, error_seed, out): scene_path = out / "scenes" / f"{name}.json" scene_path.parent.mkdir(parents=True, exist_ok=True) scene_path.write_text(json.dumps({"sources": [asdict(s) for s in sources], "error_seed": error_seed}, indent=2), encoding="utf-8") config = ProblemConfig() rows = [] for label, cls in VARIANTS.items(): sim = LocalSimulator(sources, robot_id="Q3-ABLATION", config=config, error_seed=error_seed) client = SimulatorClient("Q3-ABLATION", config=config, transport=LocalSimTransport(sim)) loop = cls(client, config) start = perf_counter() summary = loop.run() elapsed = perf_counter() - start actions = client.log.action_events() log_path = out / "actions" / f"{name}-{label}.jsonl" log_path.parent.mkdir(parents=True, exist_ok=True) with log_path.open("w", encoding="utf-8") as handle: for event in actions: handle.write(json.dumps(event, ensure_ascii=False) + "\n") truth = {s.channel for s in sources} absent = set(range(1, 21)) - truth absence_valid = all(loop.ledger.coverage_complete(c) for c in summary["absent_channels"]) row = {"case": name, "variant": label, "source_count": len(sources), "all_cleared": sim.cleared == truth, "completed": summary["completed"], "correct_absence": set(summary["absent_channels"]) <= absent and absence_valid, "normal_exit": summary["exit_reason"] == "user_exit", "virtual_time_s": sim.virtual_time_s, "measures": summary["measures"], "clears": summary["clears"], "failed_clears": summary["clears"]-len(sim.cleared), "distance_m": summary["move_distance_m"], "compute_wall_s": elapsed, "anomalies": summary["anomalies"], "stop_reason": summary["stop_reason"], "last_clear_s": max((e["response"]["virtual_time_s"] for e in actions if e["path"] == "/clear" and e["response"].get("clear_result") == "success"), default=0), "actions_file": str(log_path.relative_to(out)), "actions_sha256": hashlib.sha256(log_path.read_bytes()).hexdigest()} rows.append(row) print(f"{name} {label}: all_cleared={row['all_cleared']} T={row['virtual_time_s']:.1f}s M={row['measures']}", flush=True) return rows def geometry_check(): points = seven_points() field = ProblemConfig().field # Exact radial-sector argument: switch nearest site at r=a/sqrt(3). crossover = RING_RADIUS_M / math.sqrt(3) boundary = math.sqrt(field.omega_radius_m**2 + RING_RADIUS_M**2 - math.sqrt(3)*field.omega_radius_m*RING_RADIUS_M) analytic = max(crossover, boundary) worst = (0.0, None) for ir in range(181): r = field.omega_radius_m * ir / 180 for ia in range(721): angle = 2 * math.pi * ia / 720 p = (r*math.cos(angle), r*math.sin(angle)) d = min(math.dist(p, s) for s in points) if d > worst[0]: worst = (d, p) return {"ring_radius_m": RING_RADIUS_M, "points": points, "analytic_cover_radius_m": analytic, "reception_margin_m": field.r_eff_min_m-analytic, "sampled_max_distance_m": worst[0], "sampled_worst_point": worst[1], "samples": 181*721, "passed": worst[0] <= analytic+1e-8 and analytic < field.r_eff_min_m, "note": "Grid samples check the derivation; the sector proof establishes continuous coverage."} def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--seeds", default="0,1,2,3,4") parser.add_argument("--out", type=Path, default=ROOT/"output/q3/search-ablation") args = parser.parse_args() args.out.mkdir(parents=True, exist_ok=True) rows = [] for count in (10, 16): for seed in [int(s) for s in args.seeds.split(",")]: rows.extend(run_case(f"random-{count}-seed{seed}", random_omni_scene(seed, count), 20260910+seed, args.out)) # Sources on the exact outer boundary at sector seams; minimum receiving radius. edge = [Source(c+1, 1800*math.cos(math.pi/6+2*math.pi*c/count), 1800*math.sin(math.pi/6+2*math.pi*c/count), 1000) for c in range(count)] rows.extend(run_case(f"boundary-{count}", edge, 20260910, args.out)) aggregates = {} for label in VARIANTS: part = [r for r in rows if r["variant"] == label] baseline = {r["case"]: r for r in rows if r["variant"] == "grid49_immediate"} savings = [1-r["virtual_time_s"]/baseline[r["case"]]["virtual_time_s"] for r in part] aggregates[label] = { "runs": len(part), "all_cleared_runs": sum(r["all_cleared"] for r in part), "valid_runs": sum(r["all_cleared"] and r["completed"] and r["correct_absence"] and r["normal_exit"] and not r["anomalies"] for r in part), "mean_virtual_s": mean(r["virtual_time_s"] for r in part), "median_virtual_s": median(r["virtual_time_s"] for r in part), "max_virtual_s": max(r["virtual_time_s"] for r in part), "mean_measures": mean(r["measures"] for r in part), "mean_distance_m": mean(r["distance_m"] for r in part), "mean_compute_wall_s": mean(r["compute_wall_s"] for r in part), "mean_paired_time_reduction": mean(savings), "min_paired_time_reduction": min(savings), "max_paired_time_reduction": max(savings), "failed_clears": sum(r["failed_clears"] for r in part), } result = {"source": "local_simulator_only", "production_algorithm_modified": False, "seeds": args.seeds, "config": asdict(ProblemConfig()), "geometry": geometry_check(), "aggregates": aggregates, "runs": rows, "limitations": "Same scene and fixed per-position error field for each variant; synthetic data, not an official improvement estimate. Only search/scheduling is changed; no posterior or localizer redesign."} (args.out/"benchmark.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") print(json.dumps(aggregates, ensure_ascii=False, indent=2)) return 0 if result["geometry"]["passed"] and all(a["valid_runs"] == a["runs"] for a in aggregates.values()) else 1 if __name__ == "__main__": raise SystemExit(main())