强化学习第一版

This commit is contained in:
2026-09-11 16:56:39 +08:00
parent 2bc4c7ed82
commit 4699b0d45f
78 changed files with 27978 additions and 706 deletions
+113
View File
@@ -0,0 +1,113 @@
"""问题3/4 RL 策略与既有策略的配对自建消融。
同一种子、同一场景分别跑基线(Q3 bayes / Q4 directional)与 RL 变体,
比较全部清除成功、虚拟总时间、检测次数与请求总数。结果写入
output/rl/benchmark.json。
用法:
uv sync --group rl --group dev
uv run python scripts/benchmark_rl.py --seeds 5
"""
from __future__ import annotations
import argparse
import json
import statistics
import time
from pathlib import Path
from mcm26b.config import ProblemConfig
from mcm26b.harness import (LocalSimTransport, mixed_scene, random_omni_scene,
referee_spec)
from mcm26b.planner import DirectionalExtension
from mcm26b.planner.bayes_loop import BayesLoop
from mcm26b.planner.rl_loop import RLBayesLoop, RLDirectionalLoop
from mcm26b.protocol.client import SimulatorClient
from referee import LocalSimulator
def run_one(loop_cls, sources, *, config, robot_id: str, **kwargs) -> dict:
simulator = LocalSimulator(sources, robot_id=robot_id,
spec=referee_spec(config, max_real_duration_s=3600.0),
stress_mode=True)
client = SimulatorClient(robot_id, config=config,
transport=LocalSimTransport(simulator))
started = time.monotonic()
summary = loop_cls(client, config, **kwargs).run() \
if kwargs else loop_cls(client, config).run()
return {
"completed": bool(summary["completed"]),
"virtual_time_s": float(summary["virtual_time_s"]),
"measures": int(summary["measures"]),
"total_requests": int(summary["total_requests"]),
"move_distance_m": float(summary["move_distance_m"]),
"wall_s": round(time.monotonic() - started, 3),
}
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--seeds", type=int, default=5)
parser.add_argument("--sources", type=int, default=10)
parser.add_argument("--directional", type=int, default=3)
parser.add_argument("--output", default="output/rl/benchmark.json")
args = parser.parse_args(argv)
config = ProblemConfig()
robot_id = "2026001"
report = {"config": {"seeds": args.seeds, "sources": args.sources,
"directional": args.directional},
"q3": [], "q4": []}
for seed in range(20260910, 20260910 + args.seeds):
omni = random_omni_scene(seed=seed, count=args.sources, config=config)
mixed = mixed_scene(seed=seed, count=args.sources,
directional=args.directional, config=config)
q3_base = run_one(BayesLoop, omni, config=config, robot_id=robot_id)
q3_rl = run_one(RLBayesLoop, omni, config=config, robot_id=robot_id)
q4_base = run_one(DirectionalExtension, mixed, config=config,
robot_id=robot_id)
q4_rl = run_one(RLDirectionalLoop, mixed, config=config,
robot_id=robot_id)
report["q3"].append({"seed": seed, "bayes": q3_base, "rl": q3_rl})
report["q4"].append({"seed": seed, "directional": q4_base, "rl": q4_rl})
print(json.dumps({"event": "seed_done", "seed": seed,
"q3_base_s": q3_base["virtual_time_s"],
"q3_rl_s": q3_rl["virtual_time_s"],
"q4_base_s": q4_base["virtual_time_s"],
"q4_rl_s": q4_rl["virtual_time_s"]},
ensure_ascii=False))
for key, baseline in (("q3", "bayes"), ("q4", "directional")):
rows = report[key]
deltas = [row["rl"]["virtual_time_s"] - row[baseline]["virtual_time_s"]
for row in rows]
cleared = all(row["rl"]["completed"] and row[baseline]["completed"]
for row in rows)
report[key + "_summary"] = {
"mean_virtual_s": {
baseline: statistics.mean(row[baseline]["virtual_time_s"] for row in rows),
"rl": statistics.mean(row["rl"]["virtual_time_s"] for row in rows),
},
"mean_delta_s": statistics.mean(deltas),
"relative_delta_pct": 100.0 * statistics.mean(deltas)
/ statistics.mean(row[baseline]["virtual_time_s"] for row in rows),
"mean_measures": {
baseline: statistics.mean(row[baseline]["measures"] for row in rows),
"rl": statistics.mean(row["rl"]["measures"] for row in rows),
},
"all_cleared": cleared,
}
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, ensure_ascii=False, indent=2),
encoding="utf-8")
print(json.dumps({"event": "benchmark_done", "path": str(output),
"q3": report["q3_summary"],
"q4": report["q4_summary"]},
ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+238
View File
@@ -0,0 +1,238 @@
"""用自建模拟器的既有专家逻辑做行为克隆,初始化 PPO 策略网络。
思路:自建 referee 的观测模型与真值逻辑既快又可复现。先在它上面用
手写专家(Q3: worst-posterior 选点器;Q4: 遮挡梯子选点器)生成
(观测, 专家动作) 数据集,训练一个与 PPO policy_net 同构的 MLP 分类器,
再把权重拷入 PPO 模型作为初始化;随后 scripts/train_rl.py --init 继续
PPO 微调。这样 RL 从强先验出发,而不是从随机策略开始。
产物:
output/rl/bc/q3_expert.npz output/rl/bc/q3_bc.pt output/rl/q3_ppo_bc_init.zip
output/rl/bc/q4_expert.npz output/rl/bc/q4_bc.pt output/rl/q4_ppo_bc_init.zip
"""
from __future__ import annotations
import argparse
import json
import math
import time
from pathlib import Path
import numpy as np
import torch
from stable_baselines3 import PPO
from mcm26b.config import ProblemConfig
from mcm26b.planner import safe_disk_radius, select_measurement_point
from mcm26b.rl._constants import NUM_ACTIONS, OBS_DIM
from mcm26b.rl.env import LocalizationEnv
from mcm26b.rl.features import candidate_actions
HIDDEN = (128, 128)
def expert_action(env: LocalizationEnv) -> int:
"""当前状态的专家动作编号(与训练环境候选同源,就近映射)。"""
config = env.config
channel = env._channel
region = env._belief.candidate_set(channel)
position = env._client.state.position
blocked = env._blocked
candidates = candidate_actions(region, position, config, blocked)
if env.problem == 3:
expert_point = select_measurement_point(
region, position, config).position
else:
center, radius = region.mec()
safe = safe_disk_radius(region, config)
if not blocked:
expert_point = select_measurement_point(
region, position, config).position
elif len(blocked) == 1 and safe >= 0:
first = blocked[0]
dx, dy = center[0] - first[0], center[1] - first[1]
norm = math.hypot(dx, dy)
if norm <= 1e-9:
expert_point = (center[0] + safe, center[1])
else:
scale = min(1.0, safe / norm)
expert_point = (center[0] + dx * scale,
center[1] + dy * scale)
else:
first = blocked[0]
base = math.atan2(first[1] - center[1], first[0] - center[0])
angle = base + math.radians(45.0 * (len(blocked) - 1))
expert_point = (center[0] + radius * math.cos(angle),
center[1] + radius * math.sin(angle))
return min(range(len(candidates)),
key=lambda k: math.dist(candidates[k], expert_point))
class BCMlp(torch.nn.Module):
def __init__(self):
super().__init__()
self.net = torch.nn.Sequential(
torch.nn.Linear(OBS_DIM, HIDDEN[0]), torch.nn.ReLU(),
torch.nn.Linear(HIDDEN[0], HIDDEN[1]), torch.nn.ReLU(),
torch.nn.Linear(HIDDEN[1], NUM_ACTIONS))
def forward(self, x):
return self.net(x)
def collect(problem: int, episodes: int, seed: int, config: ProblemConfig):
env = LocalizationEnv(
config, problem=problem,
directional_probability=1.0 if problem == 4 else 0.0,
max_episode_steps=16)
observations, actions = [], []
for episode in range(episodes):
obs, info = env.reset(seed=seed + episode)
for _step in range(env.max_episode_steps + 2):
action = expert_action(env)
observations.append(obs)
actions.append(action)
obs, _reward, terminated, truncated, _info = env.step(action)
if terminated or truncated:
break
return np.asarray(observations, dtype=np.float32), \
np.asarray(actions, dtype=np.int64)
def train_bc(x, y, *, epochs: int, batch_size: int, lr: float,
device: str, valid_frac: float = 0.1):
model = BCMlp().to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=lr)
count = len(x)
split = max(1, int(count * valid_frac))
perm = torch.randperm(count)
x_t, y_t = torch.as_tensor(x, device=device), torch.as_tensor(y, device=device)
x_v, y_v = x_t[perm[:split]], y_t[perm[:split]]
x_tr, y_tr = x_t[perm[split:]], y_t[perm[split:]]
loss_fn = torch.nn.CrossEntropyLoss()
for epoch in range(epochs):
order = torch.randperm(len(x_tr), device=device)
total = correct = 0.0
for start in range(0, len(order), batch_size):
idx = order[start:start + batch_size]
logits = model(x_tr[idx])
loss = loss_fn(logits, y_tr[idx])
optimizer.zero_grad()
loss.backward()
optimizer.step()
total += float(loss.item()) * len(idx)
correct += int((logits.argmax(1) == y_tr[idx]).sum().item())
train_acc = correct / len(x_tr)
with torch.no_grad():
valid_acc = float((model(x_v).argmax(1) == y_v).float().mean().item())
if epoch == 0 or (epoch + 1) % 5 == 0:
print(json.dumps({"event": "bc_epoch", "epoch": epoch + 1,
"loss": round(total / len(x_tr), 4),
"train_acc": round(train_acc, 4),
"valid_acc": round(valid_acc, 4)},
ensure_ascii=False))
return model, float(valid_acc)
def copy_into_ppo(problem: int, model: BCMlp, device: str) -> Path | None:
"""把 BC 网络权重按 SB3 的 policy_net/action_net 拆分拷入 PPO。"""
ppo_path = Path(f"output/rl/q{problem}_ppo.zip")
if not ppo_path.is_file():
return None
ppo = PPO.load(str(ppo_path), device=device)
state = {key.removeprefix("net."): value
for key, value in model.state_dict().items()}
# SB3 MlpPolicy:policy_net 只含前两个 Linear(Tanh 激活),
# 输出层在 action_net。BC 的 Sequential 键为 0/2/4。
policy_state = {key: value for key, value in state.items()
if key in ("0.weight", "0.bias", "2.weight", "2.bias")}
action_state = {
"weight": state["4.weight"], "bias": state["4.bias"]}
ppo.policy.mlp_extractor.policy_net.load_state_dict(policy_state)
ppo.policy.action_net.load_state_dict(action_state)
init_path = Path(f"output/rl/q{problem}_ppo_bc_init.zip")
ppo.save(str(init_path))
return init_path
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--problem", type=int, choices=(3, 4), required=True)
parser.add_argument("--episodes", type=int, default=3000)
parser.add_argument("--epochs", type=int, default=20)
parser.add_argument("--batch-size", type=int, default=256)
parser.add_argument("--lr", type=float, default=3e-4)
parser.add_argument("--seed", type=int, default=20260911)
parser.add_argument("--device", default="auto")
parser.add_argument("--copy-only", action="store_true",
help="跳过采集/训练,只把已有 q<problem>_bc.pt 拷入 PPO 初始化")
args = parser.parse_args(argv)
if args.device == "auto":
device = "cuda" if torch.cuda.is_available() else "cpu"
else:
device = args.device
print(json.dumps({"event": "bc_start", "problem": args.problem,
"episodes": args.episodes, "device": device,
"cuda": torch.cuda.is_available()},
ensure_ascii=False))
if args.copy_only:
bc_dir = Path("output/rl/bc")
checkpoint = torch.load(bc_dir / f"q{args.problem}_bc.pt",
map_location="cpu")
model = BCMlp()
model.load_state_dict(checkpoint["state_dict"])
init_path = copy_into_ppo(args.problem, model, device)
dataset = np.load(bc_dir / f"q{args.problem}_expert.npz",
allow_pickle=False)
report = {
"problem": args.problem,
"samples": int(len(dataset["obs"])),
"valid_accuracy": checkpoint.get("valid_acc"),
"dataset": str(bc_dir / f"q{args.problem}_expert.npz"),
"bc_model": str(bc_dir / f"q{args.problem}_bc.pt"),
"ppo_init": str(init_path) if init_path is not None else None,
}
(bc_dir / f"q{args.problem}_bc.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps({"event": "bc_copy", "problem": args.problem,
"ppo_init": str(init_path), **report},
ensure_ascii=False, indent=2))
return 0 if init_path is not None else 2
config = ProblemConfig()
started = time.monotonic()
x, y = collect(args.problem, args.episodes, args.seed, config)
collect_s = time.monotonic() - started
bc_dir = Path("output/rl/bc")
bc_dir.mkdir(parents=True, exist_ok=True)
np.savez_compressed(bc_dir / f"q{args.problem}_expert.npz", obs=x, action=y)
model, valid_acc = train_bc(x, y, epochs=args.epochs,
batch_size=args.batch_size, lr=args.lr,
device=device)
torch.save({"state_dict": model.state_dict(), "obs_dim": OBS_DIM,
"actions": NUM_ACTIONS, "valid_acc": valid_acc},
bc_dir / f"q{args.problem}_bc.pt")
# 拷入现有 PPO 的 policy_net/action_net 作为初始化(critic 保持原权重)。
init_path = copy_into_ppo(args.problem, model, device)
report = {
"problem": args.problem, "episodes": args.episodes,
"samples": int(len(x)), "valid_accuracy": valid_acc,
"collect_seconds": round(collect_s, 1),
"train_seconds": round(time.monotonic() - started - collect_s, 1),
"dataset": str(bc_dir / f"q{args.problem}_expert.npz"),
"bc_model": str(bc_dir / f"q{args.problem}_bc.pt"),
"ppo_init": str(init_path) if init_path is not None else None,
}
(bc_dir / f"q{args.problem}_bc.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+36
View File
@@ -0,0 +1,36 @@
"""从 Tee 输出的 CLI 文本中提取末尾 JSON,落盘为 summary.json。
官方演练目录由 run 命令产生 cli-output.txt(前面可能有 CUDA 警告),
本脚本只提取最后一个完整 JSON 对象,并核对 adjacent 的官方 result.json。
"""
import argparse
import json
from pathlib import Path
def extract(text: str) -> dict:
start = text.rfind("\n{")
if start < 0:
raise ValueError("未找到 JSON 对象")
return json.loads(text[start + 1:])
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("folder", type=Path)
args = parser.parse_args()
folder = args.folder
summary = extract((folder / "cli-output.txt").read_text(encoding="utf-8"))
(folder / "summary.json").write_text(
json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps({
"folder": str(folder), "completed": summary["completed"],
"virtual_time_s": summary["virtual_time_s"],
"cleared_channels": summary["cleared_channels"],
"total_requests": summary["total_requests"],
}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+171
View File
@@ -0,0 +1,171 @@
"""问题3/4 强化学习选点策略训练(PPO + CUDA)。
训练环境 mcm26b.rl.env.LocalizationEnv 与运行闭环共享 features 观测/候选
布局。模型输出到 output/rl/q3_ppo.zip(问题3)或 q4_ppo.zip(问题4),
由 `mcm26b run --strategy rl` / `--q4-strategy rl` 加载。
示例:
uv sync --group rl --group dev
uv run python scripts/train_rl.py --problem 3 --timesteps 30000
uv run python scripts/train_rl.py --problem 4 --timesteps 30000
uv run mcm26b run --robot-id 2026001 --self-test --strategy rl
uv run mcm26b run --robot-id 2026001 --self-test --problem 4 --q4-strategy rl
"""
from __future__ import annotations
import argparse
import json
import time
from pathlib import Path
from stable_baselines3 import PPO
from stable_baselines3.common.vec_env import DummyVecEnv
from mcm26b.config import ProblemConfig
from mcm26b.rl.env import LocalizationEnv
def _make_env(problem: int, seed: int, config: ProblemConfig,
directional_probability: float):
def _factory():
env = LocalizationEnv(config, problem=problem,
directional_probability=directional_probability,
real_budget_s=3600.0)
env.reset(seed=seed)
return env
return _factory
def evaluate(model, problem: int, *, episodes: int = 24,
directional_probability: float = 0.5,
config: ProblemConfig = ProblemConfig()) -> dict:
"""独立场景评估:确定性策略的成功率、步数与平均回报。"""
env = LocalizationEnv(config, problem=problem,
directional_probability=directional_probability)
total_reward = 0.0
successes = 0
steps = []
for episode in range(episodes):
obs, _info = env.reset(seed=1_000_000 + episode)
episode_reward = 0.0
for step in range(env.max_episode_steps + 2):
action, _state = model.predict(obs, deterministic=True)
obs, reward, terminated, truncated, info = env.step(int(action))
episode_reward += float(reward)
if terminated or truncated:
break
total_reward += episode_reward
cleared = bool(info.get("cleared"))
successes += int(cleared)
steps.append(step + 1)
return {
"episodes": episodes,
"success_rate": successes / episodes,
"mean_steps": sum(steps) / episodes,
"mean_episode_reward": total_reward / episodes,
"successes": successes,
}
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--problem", type=int, choices=(3, 4), default=3,
help="训练问题:3=全向源选点,4=含定向遮挡梯子")
parser.add_argument("--timesteps", type=int, default=30000,
help="PPO 总环境步数")
parser.add_argument("--seed", type=int, default=20260911)
parser.add_argument("--envs", type=int, default=2, help="并行环境份数(CPU 侧)")
parser.add_argument("--directional-probability", type=float, default=None,
help="问题4训练场景中定向源比例(默认 1.0,先学遮挡分支)")
parser.add_argument("--device", default="auto",
help="auto=优先 CUDA;或 cuda/cpu")
parser.add_argument("--model", default=None,
help="模型输出路径(默认 output/rl/q<problem>_ppo.zip)")
parser.add_argument("--init", default=None,
help="初始模型路径(如 output/rl/q3_ppo_bc_init.zip;"
"加载后继续 PPO 微调)")
parser.add_argument("--eval-episodes", type=int, default=24)
parser.add_argument("--verbose", type=int, default=1)
args = parser.parse_args(argv)
directional_probability = (args.directional_probability
if args.directional_probability is not None
else (1.0 if args.problem == 4 else 0.0))
if not 0.0 <= directional_probability <= 1.0:
parser.error("--directional-probability 须在 [0,1]")
import torch
if args.device == "auto":
device = "cuda" if torch.cuda.is_available() else "cpu"
else:
device = args.device
if device == "cuda" and not torch.cuda.is_available():
parser.error("--device cuda 但当前环境检测不到 CUDA GPU")
print(json.dumps({
"event": "train_start", "problem": args.problem,
"timesteps": args.timesteps, "device": device,
"directional_probability": directional_probability,
"torch": torch.__version__,
"gpu": torch.cuda.get_device_name(0) if device == "cuda" else None,
"cuda_available": torch.cuda.is_available(),
}, ensure_ascii=False))
config = ProblemConfig()
vec_env = DummyVecEnv(
[_make_env(args.problem, args.seed + rank, config,
directional_probability)
for rank in range(max(1, args.envs))])
if args.init:
model = PPO.load(args.init, env=vec_env, device=device)
else:
model = PPO(
"MlpPolicy", vec_env,
policy_kwargs={"net_arch": [128, 128]},
n_steps=512,
batch_size=128,
n_epochs=6,
gamma=0.99,
gae_lambda=0.95,
learning_rate=3e-4,
clip_range=0.2,
ent_coef=0.005,
vf_coef=0.5,
max_grad_norm=0.5,
seed=args.seed,
device=device,
verbose=args.verbose,
)
started = time.monotonic()
model.learn(total_timesteps=args.timesteps,
progress_bar=args.verbose > 0)
elapsed_s = time.monotonic() - started
model_path = Path(args.model) if args.model \
else Path("output/rl") / f"q{args.problem}_ppo.zip"
model_path.parent.mkdir(parents=True, exist_ok=True)
model.save(str(model_path))
evaluation = evaluate(model, args.problem, episodes=args.eval_episodes,
directional_probability=directional_probability,
config=config)
report = {
"problem": args.problem,
"timesteps": args.timesteps,
"seed": args.seed,
"device": device,
"directional_probability": directional_probability,
"wall_seconds": round(elapsed_s, 1),
"model": str(model_path),
"init": args.init,
**evaluation,
}
report_path = model_path.with_suffix(".json")
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2),
encoding="utf-8")
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+123
View File
@@ -0,0 +1,123 @@
"""Offline audit of official RL practice runs (Q3/Q4); never connects to simulator.
Checks the copied official .result.json/.jlog, our session.jsonl and summary.json:
package sha256, enter/exit, accepted timestamps inside official window, unique
request ids, clear-success channel count == official jammer_count, summary
consistency, and virtual time recomputation with integer microsecond accumulation.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import math
from datetime import datetime
from pathlib import Path
def save_json(path: Path, value: dict) -> None:
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"]
summary = json.loads((folder / "summary.json").read_text(encoding="utf-8"))
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 = json.loads(official_paths[0].read_text(encoding="utf-8"))
package = official_paths[0].with_name(
official_paths[0].name.replace(".result.json", ".jlog"))
checks: dict = {}
checks["official_problem_matches_summary"] = (
official["problem_no"] in (3, 4)
and summary["strategy"] in ("rl", "rl_q4")
and 10 <= official["jammer_count"] <= 16
and official["omnidirectional_jammer_count"] + official["directional_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))
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["summary_completed_and_consistent"] = (
bool(summary["completed"])
and sorted(summary["cleared_channels"]) == cleared
and summary["unresolved_channels"] == []
and summary["anomalies"] == [])
position, channel, virtual_us = (0.0, 0.0), 1, 0
mismatches = 0
for event in 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
virtual_us += round(distance / 5 * 1_000_000) + (switches + action_s) * 1_000_000
if round(response["virtual_time_s"] * 1_000_000) != virtual_us:
mismatches += 1
checks["all_timestamps_match_microsecond_recomputation"] = mismatches == 0
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"])
report = {
"passed": all(checks.values()),
"source": "official_practice",
"strategy": summary["strategy"],
"problem": official["problem_no"],
"case_code": official["case_code"],
"official_jammer_count": official["jammer_count"],
"directional_jammer_count": official["directional_jammer_count"],
"cleared_channels": cleared,
"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),
"virtual_time_s": virtual_us / 1_000_000,
"real_elapsed_server_s": real_s,
"checks": checks,
"limits": "仅验证本官方演练案例;不触碰正式测试,不解读官方加密日志内容。",
}
save_json(folder / "verification.json", report)
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()
report = audit(args.folder)
print(json.dumps({"passed": report["passed"], "case_code": report["case_code"],
"problem": report["problem"], "checks": report["checks"]},
ensure_ascii=False, indent=2))
raise SystemExit(0 if report["passed"] else 1)