summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-29 03:45:58 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-29 03:45:58 +0800
commit2c559a79a5977c19876cee58d336b7983f67fa4c (patch)
tree9607e336fc740bc840a02143f63fd7932aecedf3
parenteb4fca23cabce5762fc256a659228a5d50b5e9a9 (diff)
downloadliving-village-2c559a79a5977c19876cee58d336b7983f67fa4c.tar.gz
m3r: 缩尺批验收口(50×15 流式 + systemd --user 单元 + 独立验收脚本)
缩尺口径: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。
-rw-r--r--artifacts/m3r-acceptance/20260928T183215Z/state.json12
-rw-r--r--artifacts/m3r-acceptance/acceptance.log357
-rw-r--r--deploy/living-village-m3r.service34
-rwxr-xr-xscripts/m3r-master.sh78
-rwxr-xr-xscripts/verify-m3r.py196
-rw-r--r--src/LivingVillage.Desktop.Tests/M3BatchOutputTests.fs5
-rw-r--r--src/LivingVillage.Headless/BatchOutput.fs4
-rw-r--r--src/LivingVillage.Headless/Program.fs19
8 files changed, 701 insertions, 4 deletions
diff --git a/artifacts/m3r-acceptance/20260928T183215Z/state.json b/artifacts/m3r-acceptance/20260928T183215Z/state.json
new file mode 100644
index 0000000..2406711
--- /dev/null
+++ b/artifacts/m3r-acceptance/20260928T183215Z/state.json
@@ -0,0 +1,12 @@
+{
+ "commit": "895d792",
+ "days": 15,
+ "ended_at": "2026-09-28T19:44:11Z",
+ "exit": 0,
+ "log": "/home/somhairle/projects/living-village/artifacts/m3r-acceptance/acceptance.log",
+ "pid": 142112,
+ "started_at": "2026-09-28T18:32:15Z",
+ "unit": "living-village-m3r.service",
+ "workers": 12,
+ "worlds": 50
+}
diff --git a/artifacts/m3r-acceptance/acceptance.log b/artifacts/m3r-acceptance/acceptance.log
new file mode 100644
index 0000000..a0acdbc
--- /dev/null
+++ b/artifacts/m3r-acceptance/acceptance.log
@@ -0,0 +1,357 @@
+[m3r] start unit=living-village-m3r.service pid=142112 commit=895d792 workers=12 worlds=50 days=15 started_at=2026-09-28T18:32:15Z
+batch worlds=50 days=15 half_life_ticks=5184000 rel_threshold=0.50
+checks: (a) chats max/avg > 2.0 (b) npcs_with_relation >= 0.6*npc_count (c) no NaN (old) relcnt_max > 2*relcnt_min (report only)
+workers=12
+[world 24] progress day=3/15 elapsed=182.1s
+[world 20] progress day=3/15 elapsed=182.5s
+[world 16] progress day=3/15 elapsed=187.9s
+[world 32] progress day=3/15 elapsed=188.6s
+[world 0] progress day=3/15 elapsed=189.1s
+[world 12] progress day=3/15 elapsed=190.9s
+[world 4] progress day=3/15 elapsed=192.3s
+[world 44] progress day=3/15 elapsed=194.0s
+[world 8] progress day=3/15 elapsed=194.5s
+[world 40] progress day=3/15 elapsed=194.7s
+[world 28] progress day=3/15 elapsed=199.5s
+[world 36] progress day=3/15 elapsed=205.2s
+[world 24] progress day=6/15 elapsed=364.5s
+[world 20] progress day=6/15 elapsed=368.6s
+[world 16] progress day=6/15 elapsed=378.8s
+[world 32] progress day=6/15 elapsed=379.7s
+[world 0] progress day=6/15 elapsed=381.8s
+[world 12] progress day=6/15 elapsed=387.5s
+[world 4] progress day=6/15 elapsed=388.4s
+[world 44] progress day=6/15 elapsed=389.5s
+[world 8] progress day=6/15 elapsed=391.5s
+[world 40] progress day=6/15 elapsed=395.5s
+[world 28] progress day=6/15 elapsed=404.3s
+[world 36] progress day=6/15 elapsed=418.1s
+[world 24] progress day=9/15 elapsed=549.5s
+[world 20] progress day=9/15 elapsed=557.3s
+[world 16] progress day=9/15 elapsed=570.0s
+[world 32] progress day=9/15 elapsed=571.2s
+[world 0] progress day=9/15 elapsed=573.6s
+[world 12] progress day=9/15 elapsed=582.4s
+[world 4] progress day=9/15 elapsed=583.0s
+[world 44] progress day=9/15 elapsed=583.3s
+[world 8] progress day=9/15 elapsed=586.4s
+[world 40] progress day=9/15 elapsed=593.3s
+[world 28] progress day=9/15 elapsed=604.3s
+[world 36] progress day=9/15 elapsed=624.3s
+[world 24] progress day=12/15 elapsed=733.7s
+[world 20] progress day=12/15 elapsed=745.2s
+[world 16] progress day=12/15 elapsed=759.8s
+[world 32] progress day=12/15 elapsed=760.4s
+[world 0] progress day=12/15 elapsed=765.6s
+[world 4] progress day=12/15 elapsed=775.8s
+[world 44] progress day=12/15 elapsed=776.9s
+[world 12] progress day=12/15 elapsed=777.9s
+[world 8] progress day=12/15 elapsed=782.0s
+[world 40] progress day=12/15 elapsed=789.8s
+[world 28] progress day=12/15 elapsed=802.8s
+[world 36] progress day=12/15 elapsed=826.4s
+[world 24] progress day=15/15 elapsed=918.1s
+world=24 seed=66 chats_max=5395 chats_avg=634.00 ratio=8.509 gini=0.497 relcnt_max=29 relcnt_min=3 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=918.1
+[done 1/50] elapsed=918.1s world=24
+[world 20] progress day=15/15 elapsed=933.7s
+world=20 seed=62 chats_max=16944 chats_avg=2244.60 ratio=7.549 gini=0.490 relcnt_max=29 relcnt_min=4 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=933.7
+[done 2/50] elapsed=933.7s world=20
+[world 16] progress day=15/15 elapsed=950.0s
+world=16 seed=58 chats_max=18056 chats_avg=2782.53 ratio=6.489 gini=0.514 relcnt_max=29 relcnt_min=4 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=950.0
+[done 3/50] elapsed=950.0s world=16
+[world 32] progress day=15/15 elapsed=953.2s
+world=32 seed=74 chats_max=17987 chats_avg=3274.60 ratio=5.493 gini=0.523 relcnt_max=29 relcnt_min=4 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=953.2
+[done 4/50] elapsed=953.2s world=32
+[world 0] progress day=15/15 elapsed=956.7s
+world=0 seed=42 chats_max=17325 chats_avg=2954.67 ratio=5.864 gini=0.458 relcnt_max=29 relcnt_min=6 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=956.7
+[done 5/50] elapsed=956.7s world=0
+[world 44] progress day=15/15 elapsed=971.8s
+world=44 seed=86 chats_max=19413 chats_avg=4467.87 ratio=4.345 gini=0.470 relcnt_max=29 relcnt_min=5 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=971.8
+[done 6/50] elapsed=971.8s world=44
+[world 4] progress day=15/15 elapsed=971.9s
+world=4 seed=46 chats_max=17379 chats_avg=3434.73 ratio=5.060 gini=0.420 relcnt_max=29 relcnt_min=7 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=971.9
+[done 7/50] elapsed=971.9s world=4
+[world 12] progress day=15/15 elapsed=972.7s
+world=12 seed=54 chats_max=18676 chats_avg=3782.60 ratio=4.937 gini=0.451 relcnt_max=29 relcnt_min=6 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=972.7
+[done 8/50] elapsed=972.7s world=12
+[world 8] progress day=15/15 elapsed=978.0s
+world=8 seed=50 chats_max=15410 chats_avg=3652.93 ratio=4.219 gini=0.442 relcnt_max=29 relcnt_min=6 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=978.0
+[done 9/50] elapsed=978.0s world=8
+[world 40] progress day=15/15 elapsed=988.1s
+world=40 seed=82 chats_max=19101 chats_avg=4529.13 ratio=4.217 gini=0.451 relcnt_max=29 relcnt_min=6 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=988.1
+[done 10/50] elapsed=988.1s world=40
+[world 28] progress day=15/15 elapsed=1002.9s
+world=28 seed=70 chats_max=18876 chats_avg=4825.87 ratio=3.911 gini=0.421 relcnt_max=29 relcnt_min=8 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=1002.9
+[done 11/50] elapsed=1002.9s world=28
+[world 36] progress day=15/15 elapsed=1032.3s
+world=36 seed=78 chats_max=20295 chats_avg=6057.27 ratio=3.351 gini=0.436 relcnt_max=29 relcnt_min=7 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=1032.3
+[done 12/50] elapsed=1032.3s world=36
+[world 21] progress day=3/15 elapsed=184.5s
+[world 25] progress day=3/15 elapsed=208.9s
+[world 33] progress day=3/15 elapsed=184.5s
+[world 17] progress day=3/15 elapsed=191.8s
+[world 13] progress day=3/15 elapsed=179.9s
+[world 5] progress day=3/15 elapsed=181.3s
+[world 1] progress day=3/15 elapsed=202.3s
+[world 9] progress day=3/15 elapsed=189.1s
+[world 45] progress day=3/15 elapsed=196.0s
+[world 41] progress day=3/15 elapsed=198.8s
+[world 29] progress day=3/15 elapsed=199.2s
+[world 37] progress day=3/15 elapsed=195.6s
+[world 21] progress day=6/15 elapsed=370.2s
+[world 33] progress day=6/15 elapsed=371.7s
+[world 13] progress day=6/15 elapsed=365.7s
+[world 17] progress day=6/15 elapsed=388.5s
+[world 5] progress day=6/15 elapsed=367.5s
+[world 25] progress day=6/15 elapsed=428.6s
+[world 9] progress day=6/15 elapsed=384.2s
+[world 1] progress day=6/15 elapsed=412.4s
+[world 45] progress day=6/15 elapsed=399.5s
+[world 41] progress day=6/15 elapsed=404.0s
+[world 29] progress day=6/15 elapsed=405.7s
+[world 37] progress day=6/15 elapsed=399.1s
+[world 21] progress day=9/15 elapsed=558.9s
+[world 33] progress day=9/15 elapsed=559.7s
+[world 13] progress day=9/15 elapsed=552.8s
+[world 5] progress day=9/15 elapsed=554.1s
+[world 17] progress day=9/15 elapsed=582.2s
+[world 9] progress day=9/15 elapsed=576.6s
+[world 25] progress day=9/15 elapsed=642.9s
+[world 45] progress day=9/15 elapsed=598.4s
+[world 1] progress day=9/15 elapsed=614.4s
+[world 41] progress day=9/15 elapsed=604.9s
+[world 29] progress day=9/15 elapsed=605.5s
+[world 37] progress day=9/15 elapsed=597.5s
+[world 21] progress day=12/15 elapsed=746.3s
+[world 33] progress day=12/15 elapsed=746.2s
+[world 13] progress day=12/15 elapsed=739.2s
+[world 5] progress day=12/15 elapsed=742.5s
+[world 17] progress day=12/15 elapsed=777.9s
+[world 9] progress day=12/15 elapsed=768.1s
+[world 45] progress day=12/15 elapsed=796.9s
+[world 25] progress day=12/15 elapsed=854.6s
+[world 1] progress day=12/15 elapsed=818.8s
+[world 41] progress day=12/15 elapsed=803.4s
+[world 29] progress day=12/15 elapsed=806.2s
+[world 37] progress day=12/15 elapsed=793.3s
+[world 21] progress day=15/15 elapsed=935.5s
+world=21 seed=63 chats_max=12398 chats_avg=1662.33 ratio=7.458 gini=0.495 relcnt_max=29 relcnt_min=3 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=935.5
+[done 13/50] elapsed=935.5s world=21
+[world 33] progress day=15/15 elapsed=931.2s
+world=33 seed=75 chats_max=10195 chats_avg=1003.73 ratio=10.157 gini=0.465 relcnt_max=29 relcnt_min=2 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=931.2
+[done 14/50] elapsed=931.2s world=33
+[world 13] progress day=15/15 elapsed=923.3s
+world=13 seed=55 chats_max=3340 chats_avg=472.07 ratio=7.075 gini=0.474 relcnt_max=29 relcnt_min=2 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=923.3
+[done 15/50] elapsed=923.3s world=13
+[world 5] progress day=15/15 elapsed=928.6s
+world=5 seed=47 chats_max=6676 chats_avg=844.60 ratio=7.904 gini=0.481 relcnt_max=29 relcnt_min=3 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=928.6
+[done 16/50] elapsed=928.6s world=5
+[world 17] progress day=15/15 elapsed=971.8s
+world=17 seed=59 chats_max=18453 chats_avg=3783.73 ratio=4.877 gini=0.461 relcnt_max=29 relcnt_min=5 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=971.8
+[done 17/50] elapsed=971.8s world=17
+[world 9] progress day=15/15 elapsed=959.7s
+world=9 seed=51 chats_max=10573 chats_avg=2710.33 ratio=3.901 gini=0.416 relcnt_max=29 relcnt_min=6 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=959.7
+[done 18/50] elapsed=959.7s world=9
+[world 45] progress day=15/15 elapsed=992.9s
+world=45 seed=87 chats_max=17088 chats_avg=4193.60 ratio=4.075 gini=0.413 relcnt_max=29 relcnt_min=7 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=992.9
+[done 19/50] elapsed=992.9s world=45
+[world 1] progress day=15/15 elapsed=1021.5s
+world=1 seed=43 chats_max=19732 chats_avg=5519.93 ratio=3.575 gini=0.465 relcnt_max=29 relcnt_min=6 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=1021.5
+[done 20/50] elapsed=1021.5s world=1
+[world 25] progress day=15/15 elapsed=1065.7s
+world=25 seed=67 chats_max=19759 chats_avg=5905.13 ratio=3.346 gini=0.437 relcnt_max=29 relcnt_min=6 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=1065.7
+[done 21/50] elapsed=1065.7s world=25
+[world 41] progress day=15/15 elapsed=1002.3s
+world=41 seed=83 chats_max=19059 chats_avg=4326.80 ratio=4.405 gini=0.436 relcnt_max=29 relcnt_min=6 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=1002.3
+[done 22/50] elapsed=1002.3s world=41
+[world 29] progress day=15/15 elapsed=1006.4s
+world=29 seed=71 chats_max=19246 chats_avg=4607.53 ratio=4.177 gini=0.420 relcnt_max=29 relcnt_min=7 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=1006.4
+[done 23/50] elapsed=1006.4s world=29
+[world 37] progress day=15/15 elapsed=990.0s
+world=37 seed=79 chats_max=17043 chats_avg=3626.33 ratio=4.700 gini=0.412 relcnt_max=29 relcnt_min=8 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=990.0
+[done 24/50] elapsed=990.0s world=37
+[world 22] progress day=3/15 elapsed=188.4s
+[world 34] progress day=3/15 elapsed=187.6s
+[world 14] progress day=3/15 elapsed=192.8s
+[world 6] progress day=3/15 elapsed=190.4s
+[world 18] progress day=3/15 elapsed=200.7s
+[world 10] progress day=3/15 elapsed=189.9s
+[world 46] progress day=3/15 elapsed=184.8s
+[world 26] progress day=3/15 elapsed=186.0s
+[world 42] progress day=3/15 elapsed=182.2s
+[world 2] progress day=3/15 elapsed=195.1s
+[world 30] progress day=3/15 elapsed=184.6s
+[world 38] progress day=3/15 elapsed=192.2s
+[world 22] progress day=6/15 elapsed=370.5s
+[world 34] progress day=6/15 elapsed=369.8s
+[world 14] progress day=6/15 elapsed=384.1s
+[world 6] progress day=6/15 elapsed=379.8s
+[world 10] progress day=6/15 elapsed=380.5s
+[world 18] progress day=6/15 elapsed=398.1s
+[world 46] progress day=6/15 elapsed=368.0s
+[world 42] progress day=6/15 elapsed=364.6s
+[world 26] progress day=6/15 elapsed=373.3s
+[world 2] progress day=6/15 elapsed=389.9s
+[world 30] progress day=6/15 elapsed=370.0s
+[world 38] progress day=6/15 elapsed=386.4s
+[world 22] progress day=9/15 elapsed=553.6s
+[world 34] progress day=9/15 elapsed=554.1s
+[world 6] progress day=9/15 elapsed=566.9s
+[world 14] progress day=9/15 elapsed=574.6s
+[world 10] progress day=9/15 elapsed=569.6s
+[world 18] progress day=9/15 elapsed=591.8s
+[world 46] progress day=9/15 elapsed=551.9s
+[world 42] progress day=9/15 elapsed=548.3s
+[world 26] progress day=9/15 elapsed=559.7s
+[world 2] progress day=9/15 elapsed=584.3s
+[world 30] progress day=9/15 elapsed=556.0s
+[world 38] progress day=9/15 elapsed=578.7s
+[world 22] progress day=12/15 elapsed=736.6s
+[world 34] progress day=12/15 elapsed=737.9s
+[world 6] progress day=12/15 elapsed=756.3s
+[world 14] progress day=12/15 elapsed=766.5s
+[world 10] progress day=12/15 elapsed=758.2s
+[world 46] progress day=12/15 elapsed=736.4s
+[world 18] progress day=12/15 elapsed=785.6s
+[world 42] progress day=12/15 elapsed=731.4s
+[world 26] progress day=12/15 elapsed=745.9s
+[world 30] progress day=12/15 elapsed=740.2s
+[world 2] progress day=12/15 elapsed=777.0s
+[world 22] progress day=15/15 elapsed=919.8s
+world=22 seed=64 chats_max=7283 chats_avg=1007.13 ratio=7.231 gini=0.459 relcnt_max=29 relcnt_min=3 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=919.8
+[done 25/50] elapsed=919.8s world=22
+[world 38] progress day=12/15 elapsed=767.1s
+[world 34] progress day=15/15 elapsed=921.4s
+world=34 seed=76 chats_max=17401 chats_avg=2092.67 ratio=8.315 gini=0.477 relcnt_max=29 relcnt_min=4 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=921.4
+[done 26/50] elapsed=921.4s world=34
+[world 6] progress day=15/15 elapsed=944.1s
+world=6 seed=48 chats_max=13781 chats_avg=2566.60 ratio=5.369 gini=0.460 relcnt_max=29 relcnt_min=5 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=944.1
+[done 27/50] elapsed=944.1s world=6
+[world 14] progress day=15/15 elapsed=956.9s
+world=14 seed=56 chats_max=14112 chats_avg=2678.67 ratio=5.268 gini=0.432 relcnt_max=29 relcnt_min=6 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=956.9
+[done 28/50] elapsed=956.9s world=14
+[world 10] progress day=15/15 elapsed=946.3s
+world=10 seed=52 chats_max=15478 chats_avg=2667.93 ratio=5.801 gini=0.482 relcnt_max=29 relcnt_min=4 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=946.3
+[done 29/50] elapsed=946.3s world=10
+[world 46] progress day=15/15 elapsed=920.3s
+world=46 seed=88 chats_max=8996 chats_avg=1710.73 ratio=5.259 gini=0.445 relcnt_max=29 relcnt_min=5 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=920.3
+[done 30/50] elapsed=920.3s world=46
+[world 18] progress day=15/15 elapsed=982.2s
+world=18 seed=60 chats_max=19263 chats_avg=4668.00 ratio=4.127 gini=0.466 relcnt_max=29 relcnt_min=5 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=982.2
+[done 31/50] elapsed=982.2s world=18
+[world 42] progress day=15/15 elapsed=915.1s
+world=42 seed=84 chats_max=11644 chats_avg=1199.47 ratio=9.708 gini=0.548 relcnt_max=29 relcnt_min=2 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=915.1
+[done 32/50] elapsed=915.1s world=42
+[world 26] progress day=15/15 elapsed=931.1s
+world=26 seed=68 chats_max=15463 chats_avg=2179.67 ratio=7.094 gini=0.465 relcnt_max=29 relcnt_min=5 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=931.1
+[done 33/50] elapsed=931.1s world=26
+[world 30] progress day=15/15 elapsed=927.5s
+world=30 seed=72 chats_max=17952 chats_avg=2702.73 ratio=6.642 gini=0.502 relcnt_max=29 relcnt_min=4 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=927.5
+[done 34/50] elapsed=927.5s world=30
+[world 2] progress day=15/15 elapsed=970.5s
+world=2 seed=44 chats_max=17647 chats_avg=3342.40 ratio=5.280 gini=0.419 relcnt_max=29 relcnt_min=8 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=970.5
+[done 35/50] elapsed=970.5s world=2
+[world 23] progress day=3/15 elapsed=183.6s
+[world 38] progress day=15/15 elapsed=960.8s
+world=38 seed=80 chats_max=19109 chats_avg=4235.13 ratio=4.512 gini=0.467 relcnt_max=29 relcnt_min=5 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=960.8
+[done 36/50] elapsed=960.8s world=38
+[world 35] progress day=3/15 elapsed=183.8s
+[world 7] progress day=3/15 elapsed=189.2s
+[world 15] progress day=3/15 elapsed=182.4s
+[world 11] progress day=3/15 elapsed=186.1s
+[world 47] progress day=3/15 elapsed=188.1s
+[world 19] progress day=3/15 elapsed=181.6s
+[world 43] progress day=3/15 elapsed=189.7s
+[world 27] progress day=3/15 elapsed=184.9s
+[world 3] progress day=3/15 elapsed=181.1s
+[world 31] progress day=3/15 elapsed=198.4s
+[world 23] progress day=6/15 elapsed=366.0s
+[world 39] progress day=3/15 elapsed=180.4s
+[world 35] progress day=6/15 elapsed=367.5s
+[world 15] progress day=6/15 elapsed=363.4s
+[world 7] progress day=6/15 elapsed=377.8s
+[world 11] progress day=6/15 elapsed=369.3s
+[world 47] progress day=6/15 elapsed=373.9s
+[world 19] progress day=6/15 elapsed=362.6s
+[world 43] progress day=6/15 elapsed=378.9s
+[world 27] progress day=6/15 elapsed=370.6s
+[world 3] progress day=6/15 elapsed=361.8s
+[world 31] progress day=6/15 elapsed=395.6s
+[world 23] progress day=9/15 elapsed=546.6s
+[world 39] progress day=6/15 elapsed=361.6s
+[world 35] progress day=9/15 elapsed=551.5s
+[world 15] progress day=9/15 elapsed=543.1s
+[world 7] progress day=9/15 elapsed=564.2s
+[world 11] progress day=9/15 elapsed=553.2s
+[world 47] progress day=9/15 elapsed=558.5s
+[world 19] progress day=9/15 elapsed=543.2s
+[world 27] progress day=9/15 elapsed=554.9s
+[world 43] progress day=9/15 elapsed=565.8s
+[world 3] progress day=9/15 elapsed=541.4s
+[world 23] progress day=12/15 elapsed=729.4s
+[world 31] progress day=9/15 elapsed=589.7s
+[world 39] progress day=9/15 elapsed=543.5s
+[world 35] progress day=12/15 elapsed=734.5s
+[world 15] progress day=12/15 elapsed=723.9s
+[world 7] progress day=12/15 elapsed=751.7s
+[world 11] progress day=12/15 elapsed=736.7s
+[world 19] progress day=12/15 elapsed=723.6s
+[world 47] progress day=12/15 elapsed=743.3s
+[world 27] progress day=12/15 elapsed=737.5s
+[world 43] progress day=12/15 elapsed=751.7s
+[world 3] progress day=12/15 elapsed=723.8s
+[world 23] progress day=15/15 elapsed=910.9s
+world=23 seed=65 chats_max=5691 chats_avg=1167.33 ratio=4.875 gini=0.469 relcnt_max=29 relcnt_min=4 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=910.9
+[done 37/50] elapsed=910.9s world=23
+[world 39] progress day=12/15 elapsed=725.0s
+[world 31] progress day=12/15 elapsed=781.8s
+[world 35] progress day=15/15 elapsed=916.9s
+world=35 seed=77 chats_max=6266 chats_avg=917.93 ratio=6.826 gini=0.480 relcnt_max=29 relcnt_min=3 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=916.9
+[done 38/50] elapsed=916.9s world=35
+[world 15] progress day=15/15 elapsed=901.1s
+world=15 seed=57 chats_max=4300 chats_avg=349.00 ratio=12.321 gini=0.502 relcnt_max=29 relcnt_min=1 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=901.1
+[done 39/50] elapsed=901.1s world=15
+[world 7] progress day=15/15 elapsed=930.5s
+world=7 seed=49 chats_max=9071 chats_avg=2167.33 ratio=4.185 gini=0.414 relcnt_max=29 relcnt_min=7 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=930.5
+[done 40/50] elapsed=930.5s world=7
+[world 11] progress day=15/15 elapsed=909.2s
+world=11 seed=53 chats_max=5819 chats_avg=682.60 ratio=8.525 gini=0.495 relcnt_max=29 relcnt_min=2 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=909.2
+[done 41/50] elapsed=909.2s world=11
+[world 19] progress day=15/15 elapsed=891.1s
+world=19 seed=61 chats_max=5883 chats_avg=754.60 ratio=7.796 gini=0.504 relcnt_max=29 relcnt_min=3 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=891.1
+[done 42/50] elapsed=891.1s world=19
+[world 47] progress day=15/15 elapsed=912.6s
+world=47 seed=89 chats_max=17445 chats_avg=1992.67 ratio=8.755 gini=0.494 relcnt_max=29 relcnt_min=4 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=912.6
+[done 43/50] elapsed=912.6s world=47
+[world 27] progress day=15/15 elapsed=896.1s
+world=27 seed=69 chats_max=9102 chats_avg=2141.07 ratio=4.251 gini=0.419 relcnt_max=29 relcnt_min=7 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=896.1
+[done 44/50] elapsed=896.1s world=27
+[world 43] progress day=15/15 elapsed=909.9s
+world=43 seed=85 chats_max=11701 chats_avg=2730.40 ratio=4.285 gini=0.444 relcnt_max=29 relcnt_min=5 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=909.9
+[done 45/50] elapsed=909.9s world=43
+[world 3] progress day=15/15 elapsed=871.0s
+world=3 seed=45 chats_max=13073 chats_avg=1154.93 ratio=11.319 gini=0.512 relcnt_max=29 relcnt_min=2 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=871.0
+[done 46/50] elapsed=871.0s world=3
+[world 39] progress day=15/15 elapsed=850.5s
+world=39 seed=81 chats_max=5104 chats_avg=977.67 ratio=5.221 gini=0.460 relcnt_max=29 relcnt_min=3 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=850.5
+[done 47/50] elapsed=850.5s world=39
+[world 48] progress day=3/15 elapsed=134.0s
+[world 31] progress day=15/15 elapsed=904.2s
+world=31 seed=73 chats_max=19189 chats_avg=4642.27 ratio=4.134 gini=0.476 relcnt_max=29 relcnt_min=5 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=904.2
+[done 48/50] elapsed=904.2s world=31
+[world 48] progress day=6/15 elapsed=188.2s
+[world 48] progress day=9/15 elapsed=240.4s
+[world 48] progress day=12/15 elapsed=293.9s
+[world 48] progress day=15/15 elapsed=347.1s
+world=48 seed=90 chats_max=13912 chats_avg=3140.00 ratio=4.431 gini=0.436 relcnt_max=29 relcnt_min=7 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=347.1
+[done 49/50] elapsed=347.1s world=48
+[world 49] progress day=3/15 elapsed=53.9s
+[world 49] progress day=6/15 elapsed=108.1s
+[world 49] progress day=9/15 elapsed=161.6s
+[world 49] progress day=12/15 elapsed=215.2s
+[world 49] progress day=15/15 elapsed=269.0s
+world=49 seed=91 chats_max=18760 chats_avg=3892.87 ratio=4.819 gini=0.435 relcnt_max=29 relcnt_min=7 relconn=30/18 nonfinite=0 check_a=PASS check_b=PASS check_c=PASS old_crit=PASS OK elapsed_s=269.0
+[done 50/50] elapsed=269.0s world=49
+batch_summary worlds=50 passed=50 failed=0 old_passed=50/50 ratio_min=3.346 ratio_max=12.321 gini_min=0.412 gini_max=0.548 elapsed_s=4316.0 workers=12 wall_s=4316.0
+M3_ACCEPTANCE=PASS
+[m3r] end pid=142112 exit=0 ended_at=2026-09-28T19:44:11Z
diff --git a/deploy/living-village-m3r.service b/deploy/living-village-m3r.service
new file mode 100644
index 0000000..7b7c9b6
--- /dev/null
+++ b/deploy/living-village-m3r.service
@@ -0,0 +1,34 @@
+# M3R 缩尺批验 systemd --user 单元模板(50 世界 × 15 模拟日,逐世界流式)。
+#
+# 安装(占位符替换后放入用户单元目录):
+# mkdir -p ~/.config/systemd/user
+# sed -e "s|@REPO@|/home/somhairle/projects/living-village|g" \
+# -e "s|@TS@|$(date -u +%Y%m%dT%H%M%SZ)|g" \
+# -e "s|@COMMIT@|895d792|g" \
+# -e "s|@WORKERS@|12|g" \
+# deploy/living-village-m3r.service > ~/.config/systemd/user/living-village-m3r.service
+# systemctl --user daemon-reload
+# systemctl --user start living-village-m3r.service # 前台等待 exit;不自动重启
+# systemctl --user show -p ExecMainStatus living-village-m3r.service
+#
+# 进程退不退,system 均不影响本验收:判定只读 acceptance.log 与 <TS>/state.json。
+[Unit]
+Description=Living Village M3R scaled batch acceptance (50 worlds x 15 days, streaming)
+After=default.target
+
+[Service]
+Type=simple
+WorkingDirectory=@REPO@
+Environment=M3R_REPO=@REPO@
+Environment=M3R_DIR=@REPO@/artifacts/m3r-acceptance/@TS@
+Environment=M3R_COMMIT=@COMMIT@
+Environment=M3R_WORLDS=50
+Environment=M3R_DAYS=15
+Environment=LV_BATCH_WORKERS=@WORKERS@
+Environment=M3R_LOG=@REPO@/artifacts/m3r-acceptance/acceptance.log
+ExecStart=/usr/bin/bash @REPO@/scripts/m3r-master.sh
+StandardOutput=append:@REPO@/artifacts/m3r-acceptance/acceptance.log
+StandardError=append:@REPO@/artifacts/m3r-acceptance/acceptance.log
+Restart=no
+TimeoutStartSec=infinity
+TimeoutStopSec=30s
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 指向本脚本)。
+#
+# 职责:
+# * 在 <M3R_DIR> 落 state.json(pid/unit/log/commit/workers/worlds/days/起止/exit);
+# * 以 2>&1 运行 Headless Release 批处理 `--batch <worlds> <days>`,逐世界流式;
+# * 进程退出即写回 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 <acceptance.log> <state.json> [--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<world>\d+) seed=(?P<seed>\d+) chats_max=(?P<cmax>\d+) "
+ r"chats_avg=(?P<cavg>[0-9.]+) ratio=(?P<ratio>[0-9.]+) gini=(?P<gini>[0-9.]+) "
+ r"relcnt_max=(?P<rmax>\d+) relcnt_min=(?P<rmin>\d+) relconn=(?P<conn>\d+)/(?P<need>\d+) "
+ r"nonfinite=(?P<nf>\d+) check_a=(?P<a>PASS|FAIL) check_b=(?P<b>PASS|FAIL) "
+ r"check_c=(?P<c>PASS|FAIL) old_crit=(?P<old>PASS|FAIL) (?P<ok>OK|BAD)"
+ r"(?: elapsed_s=(?P<elapsed>[0-9.]+))?$"
+)
+SUMMARY_RE = re.compile(
+ r"^batch_summary worlds=(?P<worlds>\d+) passed=(?P<passed>\d+) failed=(?P<failed>\d+) "
+ r"old_passed=(?P<op>\d+)/(?P<ow>\d+) ratio_min=(?P<rmin>[0-9.]+) ratio_max=(?P<rmax>[0-9.]+) "
+ r"gini_min=(?P<gmin>[0-9.]+) gini_max=(?P<gmax>[0-9.]+)"
+)
+DONE_RE = re.compile(r"^\[done (?P<done>\d+)/(?P<total>\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 <acceptance.log> <state.json> [--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))
diff --git a/src/LivingVillage.Desktop.Tests/M3BatchOutputTests.fs b/src/LivingVillage.Desktop.Tests/M3BatchOutputTests.fs
index e962e57..b1822c7 100644
--- a/src/LivingVillage.Desktop.Tests/M3BatchOutputTests.fs
+++ b/src/LivingVillage.Desktop.Tests/M3BatchOutputTests.fs
@@ -27,6 +27,11 @@ type M3BatchOutputTests () =
Assert.AreEqual<string>("[done 4/4] elapsed=600.0s world=3", BatchOutput.doneLine 4 4 600.0 3)
[<TestMethod>]
+ member _.ProgressLineReportsSteppedDayWithinWorld () =
+ Assert.AreEqual<string>("[world 7] progress day=5/15 elapsed=61.2s", BatchOutput.progressLine 7 5L 15L 61.2)
+ Assert.AreEqual<string>("[world 0] progress day=15/15 elapsed=260.0s", BatchOutput.progressLine 0 15L 15L 260.0)
+
+ [<TestMethod>]
member _.SummaryLineKeepsOldFieldsAndAppendsWallClock () =
let line = BatchOutput.summaryLine 4 3 1 4 1.100 9.900 0.010 0.400 4 610.5
// 监督器正则只读前三个字段,前缀必须逐字保持。
diff --git a/src/LivingVillage.Headless/BatchOutput.fs b/src/LivingVillage.Headless/BatchOutput.fs
index aeaef52..971da97 100644
--- a/src/LivingVillage.Headless/BatchOutput.fs
+++ b/src/LivingVillage.Headless/BatchOutput.fs
@@ -22,6 +22,10 @@ module BatchOutput =
let doneLine (completed: int) (total: int) (elapsedSeconds: float) (world: int) : string =
sprintf "[done %d/%d] elapsed=%.1fs world=%d" completed total elapsedSeconds world
+ /// 单世界内部步进进度行:每推进 stepEvery 个模拟日打印一次,长世界不再零进度。
+ let progressLine (world: int) (day: int64) (totalDays: int64) (elapsedSeconds: float) : string =
+ sprintf "[world %d] progress day=%d/%d elapsed=%.1fs" world day totalDays elapsedSeconds
+
/// batch_summary:旧字段原样保留,追加 workers 与 wall_s(总墙钟,append 不删旧字段)。
let summaryLine
(worlds: int)
diff --git a/src/LivingVillage.Headless/Program.fs b/src/LivingVillage.Headless/Program.fs
index 23434d0..4bd6424 100644
--- a/src/LivingVillage.Headless/Program.fs
+++ b/src/LivingVillage.Headless/Program.fs
@@ -71,7 +71,7 @@ type RunStats =
OutOfBounds: int64
World: World }
-let runSimulation (verbose: bool) (days: int64) (seed: uint64) (npcCount: int) : RunStats =
+let runSimulationCore (verbose: bool) (days: int64) (seed: uint64) (npcCount: int) (onSimDay: int64 -> unit) : RunStats =
let totalTicks = days * ticksPerDay
if verbose then
printfn "living-village headless days=%d seed=%d npcs=%d ticks=%d tickrate=%d" days seed npcCount totalTicks ticksPerSecond
@@ -102,6 +102,7 @@ let runSimulation (verbose: bool) (days: int64) (seed: uint64) (npcCount: int) :
driver <- d
world <- Sim.step { Input = input } world
t <- t + 1L
+ if t % ticksPerDay = 0L then onSimDay (t / ticksPerDay)
let nightNow = Sim.isNightTick t
if nightNow then nightTicks <- nightTicks + 1L else dayTicks <- dayTicks + 1L
for npc in world.Npcs do
@@ -152,6 +153,9 @@ let runSimulation (verbose: bool) (days: int64) (seed: uint64) (npcCount: int) :
OutOfBounds = outOfBounds
World = world }
+let runSimulation (verbose: bool) (days: int64) (seed: uint64) (npcCount: int) : RunStats =
+ runSimulationCore verbose days seed npcCount ignore
+
let dumpRelations (world: World) : unit =
let m = Sim.relationMatrix world
let n = Array2D.length1 m
@@ -405,9 +409,9 @@ type WorldVerdict =
Ratio: float
Gini: float }
-let evalWorld (days: int64) (k: int) : WorldVerdict =
+let evalWorld (days: int64) (k: int) (onSimDay: int64 -> unit) : WorldVerdict =
let seed = 42UL + uint64 k
- let stats = runSimulation false days seed Sim.npcCount
+ let stats = runSimulationCore false days seed Sim.npcCount onSimDay
let m = Sim.relationMatrix stats.World
let n = Array2D.length1 m
let mutable hasNonFinite = stats.NonFinite > 0L
@@ -461,11 +465,18 @@ let runBatch (worlds: int) (days: int64) : int =
let wall = System.Diagnostics.Stopwatch.StartNew()
// 逐世界流式:每个世界一完成立刻打印该行(行尾追加 elapsed_s),再打印一行
// [done k/N] 进度,并 flush,使长批次不再整轮零进度。结果仍按 world 序号落数组。
+ let progressGate = obj ()
+ let progressEvery = if days <= 5L then 1L else (days + 4L) / 5L
let results =
LivingVillage.Headless.BatchOutput.runStreaming worlds workers
(fun k ->
let sw = System.Diagnostics.Stopwatch.StartNew()
- let verdict = evalWorld days k
+ let verdict =
+ evalWorld days k (fun day ->
+ if day % progressEvery = 0L then
+ lock progressGate (fun () ->
+ printfn "%s" (LivingVillage.Headless.BatchOutput.progressLine k day days sw.Elapsed.TotalSeconds)
+ Console.Out.Flush()))
sw.Stop()
(verdict, sw.Elapsed.TotalSeconds))
(fun k (verdict, seconds) doneCount ->