From 2c559a79a5977c19876cee58d336b7983f67fa4c Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 29 Sep 2026 03:45:58 +0800 Subject: m3r: 缩尺批验收口(50×15 流式 + systemd --user 单元 + 独立验收脚本) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 缩尺口径:M3 phase2 50×100 实测 11.4h 仅完成 6/50(见 artifacts/m3-acceptance/final-candidate-20260921T095500Z/logs/*.diagnosis.txt), 改为 50 世界 × 15 模拟日、逐世界流式打印并 flush。标定据 docs/overnight-status.md (Hermes 单 worker 亲测):1×2=34.6s、1×5=87.7s、1×10=173.6s,外推 15 天 ≈260s。 注意:派单所引用 artifacts/m3-independent/20260921T105500Z 全盘查找不存在, 故改用上述仓内标定,未编造任何数据。 内容: - Headless 边缘:runSimulation 拆出 runSimulationCore 增加逐模拟日回调; evalWorld 传入回调;runBatch 每 (days/5) 天打印 `[world k] progress day=d/D elapsed=..s` 并 flush,长世界不再零进度(BatchOutput.progressLine 纯函数 + 单测)。 - 新增 deploy/living-village-m3r.service 模板:wrapper 作 MasterProcess、 StandardOutput/StandardError=append: acceptance.log、Restart=no; systemd-analyze --user verify 通过。本机 ~/.config/systemd/user 属外部目录受限, 实跑以等价属性的 systemd-run --user 瞬态单元 living-village-m3r.service 承载。 - scripts/m3r-master.sh:写 state.json(pid/unit/log/commit/workers/worlds/days/ 起止/exit),exec 2>&1 运行 batch 并回传真实退出码。 - scripts/verify-m3r.py:独立可复跑,校验 state 字段、50 条 world 行(索引/seed/ ok 与 check_a/b/c 自洽)、summary passed+failed=50 与极值一致、抽查 3 行、 [done] 50 条、progress 行、M3_ACCEPTANCE=PASS 且 ratio_min>2.0。 本次实跑(commit 895d792):12 worker,wall=4316.0s,50/50 OK, ratio_min=3.346 ratio_max=12.321 gini∈[0.412,0.548],relconn=30/18, check_a/b/c 全 PASS,M3_ACCEPTANCE=PASS,exit=0;独立脚本复跑 M3R_VERIFY=PASS。 证据:artifacts/m3r-acceptance/acceptance.log + 20260928T183215Z/state.json。 回归:Desktop 303/303 + Kernel 119/119;--performance-baseline ×3 digest=953775FAEB2F… 不变,performance_determinism=PASS。 未改 Kernel/Sim;未 push。 --- scripts/m3r-master.sh | 78 ++++++++++++++++++++ scripts/verify-m3r.py | 196 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 274 insertions(+) create mode 100755 scripts/m3r-master.sh create mode 100755 scripts/verify-m3r.py (limited to 'scripts') diff --git a/scripts/m3r-master.sh b/scripts/m3r-master.sh new file mode 100755 index 0000000..d4f18fa --- /dev/null +++ b/scripts/m3r-master.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# M3R 缩尺批验 MasterProcess 包装器(systemd --user 单元 ExecStart 指向本脚本)。 +# +# 职责: +# * 在 落 state.json(pid/unit/log/commit/workers/worlds/days/起止/exit); +# * 以 2>&1 运行 Headless Release 批处理 `--batch `,逐世界流式; +# * 进程退出即写回 ended_at 与退出码,并把真实退出码回传给 systemd。 +# +# 环境变量(由 unit 注入): +# M3R_REPO 仓库根(绝对路径) +# M3R_DIR 本次运行证据目录(绝对路径,state.json 落此) +# M3R_COMMIT 期望 commit 短哈希(写入 state.json) +# M3R_WORLDS 世界数(默认 50) +# M3R_DAYS 模拟天数(默认 15) +# LV_BATCH_WORKERS 并行 worker 数(默认 12) +# M3R_LOG 验收日志路径(只写入 state.json 作为记录,实际重定向由 unit 负责) +set -u + +REPO="${M3R_REPO:?M3R_REPO required}" +DIR="${M3R_DIR:?M3R_DIR required}" +COMMIT="${M3R_COMMIT:?M3R_COMMIT required}" +WORLDS="${M3R_WORLDS:-50}" +DAYS="${M3R_DAYS:-15}" +WORKERS="${LV_BATCH_WORKERS:-12}" +LOG="${M3R_LOG:-$REPO/artifacts/m3r-acceptance/acceptance.log}" +UNIT="living-village-m3r.service" +DLL="$REPO/src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll" + +mkdir -p "$DIR" +if [ ! -f "$DLL" ]; then + echo "[m3r] FATAL missing build: $DLL" >&2 + exit 90 +fi + +STARTED="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +echo "$$" > "$DIR/pid" +echo "[m3r] start unit=$UNIT pid=$$ commit=$COMMIT workers=$WORKERS worlds=$WORLDS days=$DAYS started_at=$STARTED" + +python3 - "$DIR/state.json" "$UNIT" "$COMMIT" "$WORKERS" "$WORLDS" "$DAYS" "$LOG" "$STARTED" "$$" <<'PY' +import json, sys +out, unit, commit, workers, worlds, days, log, started, pid = sys.argv[1:10] +state = { + "pid": int(pid), + "unit": unit, + "log": log, + "commit": commit, + "workers": int(workers), + "worlds": int(worlds), + "days": int(days), + "started_at": started, + "ended_at": None, + "exit": None, +} +with open(out, "w", encoding="utf-8") as fh: + json.dump(state, fh, ensure_ascii=False, indent=2, sort_keys=True) + fh.write("\n") +PY + +export LV_BATCH_WORKERS="$WORKERS" +( exec dotnet "$DLL" --batch "$WORLDS" "$DAYS" 2>&1 ) +CODE=$? + +ENDED="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +echo "[m3r] end pid=$$ exit=$CODE ended_at=$ENDED" + +python3 - "$DIR/state.json" "$CODE" "$ENDED" <<'PY' +import json, sys +path, code, ended = sys.argv[1:4] +with open(path, encoding="utf-8") as fh: + state = json.load(fh) +state["ended_at"] = ended +state["exit"] = int(code) +with open(path, "w", encoding="utf-8") as fh: + json.dump(state, fh, ensure_ascii=False, indent=2, sort_keys=True) + fh.write("\n") +PY + +exit "$CODE" diff --git a/scripts/verify-m3r.py b/scripts/verify-m3r.py new file mode 100755 index 0000000..05c58b9 --- /dev/null +++ b/scripts/verify-m3r.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""M3R 缩尺批验独立验收脚本(不依赖 writer 会话,可独立复跑)。 + +用法: + python3 scripts/verify-m3r.py [--commit 895d792] + +只读判定,不改任何产物。校验: + 1) state.json 字段齐全且自洽(pid/unit/log/commit/workers/worlds/days/起止/exit); + 2) 日志恰有 50 条 `world=` 行,索引 0..49 无重无缺,seed==42+i; + 3) 逐世界 ok 字段 == (check_a and check_b and check_c);check_a==(ratio>2.0); + check_b==(relconn 连通数 >= 达标数);check_c==(nonfinite==0); + 4) batch_summary worlds=50 且 passed+failed=50,passed/failed 与逐世界统计一致, + ratio_min/max、gini_min/max 与逐世界极值一致,gini∈(0,1); + 5) M3_ACCEPTANCE=PASS 且 ratio_min>2.0; + 6) 抽查 3 个 world 行(排序后首/中/尾)判据字段与 summary 一致; + 7) [done k/50] 恰 50 条且末条为 50/50,存在步进 `[world k] progress` 行。 +""" +import json +import re +import sys + +WORLD_RE = re.compile( + r"^world=(?P\d+) seed=(?P\d+) chats_max=(?P\d+) " + r"chats_avg=(?P[0-9.]+) ratio=(?P[0-9.]+) gini=(?P[0-9.]+) " + r"relcnt_max=(?P\d+) relcnt_min=(?P\d+) relconn=(?P\d+)/(?P\d+) " + r"nonfinite=(?P\d+) check_a=(?PPASS|FAIL) check_b=(?PPASS|FAIL) " + r"check_c=(?PPASS|FAIL) old_crit=(?PPASS|FAIL) (?POK|BAD)" + r"(?: elapsed_s=(?P[0-9.]+))?$" +) +SUMMARY_RE = re.compile( + r"^batch_summary worlds=(?P\d+) passed=(?P\d+) failed=(?P\d+) " + r"old_passed=(?P\d+)/(?P\d+) ratio_min=(?P[0-9.]+) ratio_max=(?P[0-9.]+) " + r"gini_min=(?P[0-9.]+) gini_max=(?P[0-9.]+)" +) +DONE_RE = re.compile(r"^\[done (?P\d+)/(?P\d+)\] ") +PROGRESS_RE = re.compile(r"^\[world \d+\] progress day=\d+/\d+ elapsed=[0-9.]+s$") + +REQUIRED_STATE = [ + "pid", "unit", "log", "commit", "workers", "worlds", + "days", "started_at", "ended_at", "exit", +] + + +def main(argv): + if len(argv) < 3: + print("usage: verify-m3r.py [--commit X]") + return 2 + log_path, state_path = argv[1], argv[2] + commit_expect = None + if len(argv) >= 5 and argv[3] == "--commit": + commit_expect = argv[4] + + problems = [] + with open(log_path, encoding="utf-8", errors="replace") as fh: + lines = [line.rstrip("\n") for line in fh] + with open(state_path, encoding="utf-8") as fh: + state = json.load(fh) + + # 1) state.json + for key in REQUIRED_STATE: + if key not in state: + problems.append(f"state.json 缺字段 {key}") + if state.get("unit") != "living-village-m3r.service": + problems.append(f"unit 非法: {state.get('unit')!r}") + if not isinstance(state.get("pid"), int) or state.get("pid", 0) <= 0: + problems.append(f"pid 非法: {state.get('pid')!r}") + if state.get("exit") != 0: + problems.append(f"exit 非 0: {state.get('exit')!r}") + for key in ("started_at", "ended_at"): + if not state.get(key): + problems.append(f"{key} 为空") + if state.get("started_at") and state.get("ended_at") and state["ended_at"] < state["started_at"]: + problems.append("ended_at 早于 started_at") + if commit_expect and state.get("commit") != commit_expect: + problems.append(f"commit 不匹配: {state.get('commit')!r} != {commit_expect!r}") + for key, want in (("worlds", 50), ("days", 15)): + if state.get(key) != want: + problems.append(f"{key} != {want}: {state.get(key)!r}") + if not isinstance(state.get("workers"), int) or state.get("workers", 0) <= 0: + problems.append(f"workers 非法: {state.get('workers')!r}") + + # 2) world= 行 + worlds = {} + for line in lines: + m = WORLD_RE.match(line) + if not m: + continue + d = m.groupdict() + idx = int(d["world"]) + if idx in worlds: + problems.append(f"world={idx} 重复") + worlds[idx] = d + if sorted(worlds) != list(range(50)): + problems.append(f"world 索引非 0..49(实际 {len(worlds)} 条,缺失 {sorted(set(range(50)) - set(worlds))})") + for idx, d in worlds.items(): + if int(d["seed"]) != 42 + idx: + problems.append(f"world={idx} seed={d['seed']} != {42 + idx}") + + # 3) 逐世界判据自洽 + ok_count = 0 + checks_a = checks_b = checks_c = 0 + ratios, ginis = [], [] + for idx in sorted(worlds): + d = worlds[idx] + ratio, gini = float(d["ratio"]), float(d["gini"]) + conn, need = int(d["conn"]), int(d["need"]) + nf = int(d["nf"]) + a, b, c = d["a"] == "PASS", d["b"] == "PASS", d["c"] == "PASS" + derived = a and b and c + if (d["ok"] == "OK") != derived: + problems.append(f"world={idx} ok={d['ok']} 与 check_a/b/c 不一致") + if a != (ratio > 2.0): + problems.append(f"world={idx} check_a={d['a']} 与 ratio={ratio} 不一致") + if b != (conn >= need): + problems.append(f"world={idx} check_b={d['b']} 与 relconn={conn}/{need} 不一致") + if c != (nf == 0): + problems.append(f"world={idx} check_c={d['c']} 与 nonfinite={nf} 不一致") + if gini <= 0.0 or gini >= 1.0: + problems.append(f"world={idx} gini={gini} 不在 (0,1)") + ratios.append(ratio) + ginis.append(gini) + ok_count += 1 if d["ok"] == "OK" else 0 + checks_a += 1 if a else 0 + checks_b += 1 if b else 0 + checks_c += 1 if c else 0 + + # 4) batch_summary + summary = None + for line in lines: + m = SUMMARY_RE.match(line) + if m: + summary = m.groupdict() + if summary is None: + problems.append("缺 batch_summary 行") + else: + sw, sp, sf = int(summary["worlds"]), int(summary["passed"]), int(summary["failed"]) + if sw != 50: + problems.append(f"batch_summary worlds={sw} != 50") + if sp + sf != 50: + problems.append(f"batch_summary passed+failed={sp + sf} != 50") + if sp != ok_count or sf != 50 - ok_count: + problems.append(f"batch_summary passed={sp}/failed={sf} != 逐世界 ok={ok_count}/bad={50 - ok_count}") + if abs(float(summary["rmin"]) - min(ratios)) > 5e-4 or abs(float(summary["rmax"]) - max(ratios)) > 5e-4: + problems.append(f"batch_summary ratio 极值 [{summary['rmin']},{summary['rmax']}] != 逐世界 [{min(ratios)},{max(ratios)}]") + if abs(float(summary["gmin"]) - min(ginis)) > 5e-4 or abs(float(summary["gmax"]) - max(ginis)) > 5e-4: + problems.append(f"batch_summary gini 极值 [{summary['gmin']},{summary['gmax']}] != 逐世界 [{min(ginis)},{max(ginis)}]") + for key, val in (("gmin", float(summary["gmin"])), ("gmax", float(summary["gmax"]))): + if not 0.0 < val < 1.0: + problems.append(f"batch_summary {key}={val} 不在 (0,1)") + + # 5) 总判据 + acceptance = [line for line in lines if line.startswith("M3_ACCEPTANCE=")] + if not acceptance: + problems.append("缺 M3_ACCEPTANCE 行") + elif acceptance[-1] != "M3_ACCEPTANCE=PASS": + problems.append(f"末条 {acceptance[-1]} != M3_ACCEPTANCE=PASS") + ratio_min = min(ratios) if ratios else 0.0 + if not ratio_min > 2.0: + problems.append(f"ratio_min={ratio_min} 不满足 >2.0") + + # 6) 抽查 3 个 world 行(排序后首/中/尾) + if len(worlds) == 50: + sample = [0, 24, 49] + print("sample worlds: " + " ".join(f"{i}(ok={worlds[i]['ok']},ratio={worlds[i]['ratio']},gini={worlds[i]['gini']},relconn={worlds[i]['conn']}/{worlds[i]['need']})" for i in sample)) + if summary: + for i in sample: + r, g = float(worlds[i]["ratio"]), float(worlds[i]["gini"]) + if not (float(summary["rmin"]) <= r <= float(summary["rmax"])): + problems.append(f"sample world={i} ratio={r} 不在 summary 区间") + if not (float(summary["gmin"]) <= g <= float(summary["gmax"])): + problems.append(f"sample world={i} gini={g} 不在 summary 区间") + + # 7) done / progress + done = [DONE_RE.match(line) for line in lines] + done = [m for m in done if m] + if len(done) != 50: + problems.append(f"[done] 行数 {len(done)} != 50") + elif int(done[-1].group("done")) != 50 or int(done[-1].group("total")) != 50: + problems.append(f"末条 [done] 非 50/50: {done[-1].group(0)!r}") + progress = sum(1 for line in lines if PROGRESS_RE.match(line)) + if progress < 50: + problems.append(f"步进 progress 行数 {progress} < 50(逐世界至少一条)") + + print(f"worlds={len(worlds)} ok={ok_count} bad={50 - ok_count} check_a_pass={checks_a} check_b_pass={checks_b} check_c_pass={checks_c}") + print(f"ratio_min={ratio_min:.3f} ratio_max={max(ratios) if ratios else 0:.3f} gini_min={min(ginis) if ginis else 0:.3f} gini_max={max(ginis) if ginis else 0:.3f} progress_lines={progress}") + if problems: + print(f"M3R_VERIFY=FAIL problems={len(problems)}") + for p in problems: + print(f" - {p}") + return 1 + print("M3R_VERIFY=PASS") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) -- cgit v1.2.3