diff --git a/.gitignore b/.gitignore index 0b335b7..ad02d59 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ webapp/generated/ # 策略模型产物(由 python -m ml.export_onnx 生成,20 MB 级,不入库) webapp/models/*.onnx webapp/models/*.meta.json + +# 从头重跑时旧产物挪到这里(不入库) +ml/archive/ diff --git a/docs/ML运行手册审查.md b/docs/ML运行手册审查.md index 61fab69..152c4c5 100644 --- a/docs/ML运行手册审查.md +++ b/docs/ML运行手册审查.md @@ -556,3 +556,103 @@ batch 缩放(4 线程,同一会话):B=1 → 6.2/7.2;B=2 → 10.3;B=4 3. **周期账**:部署每 3 步(60 ms)一次推理,安全层单次预算 `safetyMs:30`。4 线程下推理 33 ms 已吃掉一半以上——安全层只要超过 27 ms 就必然丢步;16 线程下 13.7 + ≤30 才谈得上有余量。也就是说 R4 与 R9(贴柱处安全层全程 P99 < 5 ms)是同一个 60 ms 周期的两半,必须一起测。 4. **没拿到的数(如实)**:闭环实际倍率/丢步率没采到——我挂的探针一运行,CDP 连 15 s 都取不到执行槽(连 `list_console_messages` 都超时),标签页要到切走才恢复。这本身就是主线程(投影有限差分 + 认证递归都在主线程)在 4 线程 wasm 下已经很挤的证据。R9 因此仍无数据。 5. 残留:`webapp/models/traj_policy_r4.onnx`(+`.meta.json`,共 19.8 MB) 是未跟踪文件,`meta.random_init=true`;serve.py 已停,浏览器标签已释放。它同时验证了 N-5 的隐患是真的——这个文件与第 10 步 `export` 的默认输出同名同目录,只差文件名后缀。 + +--- +--- + +# 第五轮:对第四轮的处置(实现方回复,2026-10-18) + +处置对象:第四轮 N-1~N-16、第六节「无人值守跑完」四个缺口、第七节 R4 实测。 +结论先说:**第四轮全部认可,按第六节的目标一次改完**(不走 A/B/C 分批)。三道门槛(运行时 5 mm 护栏、M5 ≤ 2%、`collisionFrames = 0`)不动;「跑通 ≠ 达标」原样保留。 + +## 〇、分歧裁决规则(用户 2026-10-18 确定) + +审查方与实现方意见不一致、讨论后仍定不下来时,**以实现方(Hark)的意见为准**,按它落地,不再停下来等第三方拍板。 +审查方的异议照常写进下一轮文档,附上判据;下一轮如果实测数据证明实现方错了,按数据改,不另起争论。 +本轮实际只有两处分歧:N-6 和 R4 的改法(见第二节)。按上述规则,两处均按实现方意见落地。 + +## 一、逐条处置 + +| 条目 | 处置(代码落点) | 自测 | +|---|---|---| +| N-7 并发无锁 | `ml/pstate.py` 新增互斥锁 `ml/runs/pipeline.lock`(`O_EXCL` 原子创建;pid + 每 60 s 心跳;5 分钟无心跳视为失效可接管)。`pipeline.sh` 在**任何**阶段入口先拿锁,`all` 的子阶段继承;监控页判断是否在跑只看锁,不再看日志新鲜度。生成改为先写 `shard_XXXX.jsonl.part`、写完才改名 | 先起 gate1,再起 gen → 立即拒绝(返回码 9,报出持锁 pid);TERM 后阶段记 143、锁释放 | +| N-8 控制口无校验 | `/api/start`、`/api/stop` 要求自定义头 `X-Console: 1`,`Origin` 和 `Sec-Fetch-Site` 有值时必须同源。跨站页面加自定义头会触发 CORS 预检,本服务不应答 OPTIONS,浏览器就不会发出请求 | 不带头 → 403;带头但 `Origin: https://evil.com` → 403;同源 → 200 | +| N-9 刷新全量读 shard | 行数按文件增量统计(记住偏移,只读新增字节),只在 gen 阶段做;其他阶段用 `stats.json` 的 `tasks` | — | +| N-10 启动器 Python | `console.bat` 尊重 `%PYTHON%`,判据改为 `import torch`;`console.sh` 依次试 `PYTHON` / `python` / `python3`,判据相同。monitor 启动后后台自检(训练用 Python、torch 版本、GPU、node、bash),不过则网页「开始训练」不可点、`/api/start` 拒绝;`all` 开头再查一次 `import torch` | 沙箱自检输出正确(CPU torch 给出「看不到 GPU」警告) | +| N-11 生命周期 | Windows 子进程用 `CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP`(自带隐藏控制台,关掉控制台黑窗口不连带结束);锁里记 Windows pid(Git Bash 的 `/proc/$$/winpid`),重开的控制台可按锁停止。APG 新增 `--resume`(`last.pt` 带优化器状态);`loop.sh` 跳过已有 `gate.json` 的轮次、本轮 APG 断了从该轮 `last.pt` 续跑;`train` 自动带 `--resume`,测速 2000 步做过就跳过 | APG 2 步 → `--resume` 续到 4 步,日志「从第 2 步继续」,`last.pt` step=4 含 opt | +| N-16 gen 无节拍 / 死循环 | `run_parallel.sh` 每 60 s 往控制台打一行节拍(已写条数 + 各进程最新进度),逐个 `wait` 子进程查返回码(原来的裸 `wait` 恒为 0),任一种子失败即返回码 6 并打印该种子日志尾部。`sampler.cjs` 所有拒绝采样设次数上限,超限抛错并写明「哪一类位形连续 N 次未命中」 | 4 条任务小跑:重跑时已完成种子跳过;补量第 1 次触发并写对 `target_tasks` | +| N-5 随机模型踩真模型 | `r4` 导出到 `ml/runs/r4/traj_policy_r4.onnx`,不再写 `webapp/models/`;查看器遇到 `random_init` 默认拒绝(`&probe=1` 才放行,徽标标红「随机探针模型」);`policy_bench.cjs` 拒绝;`acceptance` 新增「部署模型是训练产物」一项 | bench 加载随机模型 → 拒绝退出 | +| N-2 R2 与池耦合 | 判据改为 `label_std_norm`:在距离较近的一半近邻对上做「标签差 ~ 近邻距离」线性回归,取距离 → 0 的截距。池变大只会让近邻更近、点更多,截距估的都是同一输入下的固有分歧;旧口径 `label_std_mean` 保留为参考,并记录 `pool`。不通过默认**警告并继续**,`R2_STRICT=1` 才停;验收里 R2 改为参考项 | 小样本可跑通(数值无意义);池无关性要用 1.7 M 样本的 `--pool 50000` 与 `--pool 200000` 两跑验证(差 < 0.02 为成立) | +| N-3 R7 口径 | `closed_loop.py` 新增 `j1_first`:J1 累计偏离首次超过 5° 时的方向,即真正的首次方向;全程不到 5° 的退回净方向。`gate.py` 去掉无用形参 | 语法检查 | +| N-4 r8 不足额静默 | bench 记录 `requested` 与出题耗时 `genS`,不足额时打印警告;acceptance 对 `n < requested` 判不通过 | 见第三节 | +| N-12 双写者 | 停止后的状态改写走 `pstate.mark_stopped()`(原子替换);`pipeline.json` 只有 pstate 一个写者 | 网页停止 → 阶段记 −15、`current` 清空、锁删除 | +| N-13 target 读失败 | 新增 `pstate get`,读不到时非零退出;`set -e` 下直接失败,不再算出 56 这类值 | `get gen target_tasks` 缺失 → rc 1 | +| N-14 权重不同源 | 阶段名、估时、超时上限统一在 `pstate.STAGES`,由 `/api/status` 下发给网页;gen 权重 1.0 → 5.0(共识分支上界) | 示例页 4 个场景无报错 | +| N-1 `MIN_GAP_MM` | `pipeline.sh` 统一 `export MIN_GAP_MM=3`,并作为参数记进 `pipeline.json`(改了会使生成及之后阶段作废);prep 开头比对 `stats.json` 的 `minGapFilterMm`,不一致返回码 8;网页可设 | `MIN_GAP_MM=4` 跑 prep → 报错退出 | +| N-6 分块 | **分歧,见第二节**。改为按空闲显存自动定块(用 1/4,256~8192) | — | +| N-15 file:// demo | 示例模式在页面顶部显示黄色提示条;README 写明不要直接双击 html | — | + +第六节四个缺口的对应: + +| 缺口 | 落地 | +|---|---| +| 1 状态机闭环 | `pstate reset` 默认保留 rc=0 的阶段;`all` 只跑 `pstate todo` 列出的阶段。**参数变了只作废受影响的阶段**(`AFFECTS` 表:生成参数 → gen 起;`CFG` → r4 和 train 起;挖错参数 → mine 起),被作废阶段的产物移到 `ml/archive/<时间>/`,不删除;`FRESH=1` 全部作废。网页不再问起始阶段,只选「续跑 / 从头重跑」 | +| 2 互斥 + 脱离控制台 | 见 N-7、N-11 | +| 3 门槛自动分支 | R2 默认警告继续;M5 base 不过 → 流程内自动训 WTA 再判,两头都不过才停(`AUTO_WTA=0` 关闭);补量上限 2 → 4 次,补完仍不足 90% 目标时警告并继续;R4 自动探针(第二节) | +| 4 心跳与超时 | 每阶段 `timeout_h` = 估时 ×3(m5 按含 WTA 分支计),`all` 用 `timeout -k 120` 包住每个阶段,超时整组进程收到 TERM,阶段记 `timed_out`、放锁;网页显示「本步粗估 / 上限」与「超过时间上限,判定卡死」 | + +自测(沙箱、秒级):用 `TIMEOUT_SCALE=0.002` 把 gate1 上限压到 10 s → 返回码 124,阶段标 `timed_out`,node 子进程全部结束,锁释放。 + +## 二、两处分歧(按第〇节规则以实现方意见落地) + +### N-6:不是自相矛盾 + +`kin.gaps` 按 `d² ≤ (胶囊对下限 + cap)²` 筛候选对。`min_gap_chunked` 用 `cap = 0.5 mm`,每个随机位形幸存约 2800 对(≈ 1.5 MB);`column_gap` 用 `cap = 40 mm`,40 mm 以内幸存的对多一个量级(≈ 20 MB)。两处的峰值差 13 倍是 cap 不同造成的,两个默认块大小在各自的 cap 下都对。 +第四轮说得对的是「24 GB 显存上 256 偏小、拖慢出题」,所以块大小改为按空闲显存自动计算(`tasks.auto_chunk`),两处只需传入各自的每位形峰值。 + +### R4:33 ms 里不全是模型本身的开销 + +第四轮的结论是「必须动结构,二选一:去掉重复 encoder,或 K=8 → 4」。我的处置不同: + +1. **推理移出主线程(第四轮没提,最重要)**:原来 `ik-policy-glue.js` 没开 `ort.env.wasm.proxy`,wasm 推理在主线程同步执行。第七节第 4 条里「CDP 15 s 拿不到执行槽」就是这个原因;50 Hz 的 `tick` 也被一起拖住,所以丢步不只来自推理时长本身。现在默认开代理 Worker(`&proxy=0` 关),创建失败时自动退回主线程并在徽标上写明。 +2. **编码一次**:同意第四轮的方向 ①。ONNX 接口改为 `feat[1,83]`、`z[K,16,6]`(`Policy.sample_shared`,`meta.enc_once`);导出时断言与旧接口逐元素一致(误差 < 1e-4);运行时按 `meta.enc_once` 只送 1 行,旧模型照常可用。按 base.json 粗算,encoder 约占总计算 29%,可省约 25%;**沙箱 CPU 实测**(onnxruntime 2 vCPU,200 次):旧接口 P50 10.03 / P99 10.34 ms → 新接口 8.03 / 8.18 ms,省约 20%。 +3. **线程数**:默认 `min(16, ⌊0.6×逻辑核⌋)`(28 核机 = 16,即第七节实测最优),`&threads=N` 可覆盖。 +4. **K=8 不降**:降 K 损失的是选优质量,要有 M5 / 闭环数字才能判,现在没有依据。 +5. **WebGPU 必须测**:方案 R4 行本来就写了要同测 WebGPU,第七节没测。探针现在 4 种配置都测:默认线程 + Worker(部署口径)、4 线程(对照)、默认线程不开 Worker(看 `tickGapMax`,即推理是否卡住 20 ms 节拍)、WebGPU。 + +**判定顺序**:部署口径 P99 < 15 ms 即通过;wasm 不过而 WebGPU 过,也判通过并记 `deploy_ep: webgpu`;两者都不过,流程在训练前停下(返回码 5),改 `CFG=ml/configs/small.json`(d=128)重跑。 +**预估(未实测,不当已解决)**:16 线程旧接口 P99 19.5 ms × (1 − 0.2~0.25) ≈ 14.6~15.6 ms,正好踩在线上;WebGPU 在 3090 上的数字是这一轮最关键的未知数。 +**R9**:探针只测纯推理。贴柱处安全层耗时(R9)仍以 bench 的 `safetyMsP99` 为准;推理移出主线程后,安全层独占主线程,R4 与 R9 合起来要小于 60 ms 周期这笔账,等真机数据再核。 + +## 三、断点续跑(用户 2026-10-18 追加要求) + +要求:长时间的步骤意外中断(断电、蓝屏、误关、手动停止)后能接着跑,不从头来。阶段级跳过上一版已有,这一版把**阶段内部**也做成可续: + +| 阶段 | 续跑粒度 | 做法 | +|---|---|---| +| gen | 每条任务 | 第 i 条任务的随机数只由(种子, i)决定(`sampler.reseed`);`.part` 里保留完整的行、截掉写了一半的最后一行,从下一条接着写。沙箱验证:截断后续写的结果与一次写完逐条一致(id、q0、keep 全同)。 | +| prep | 每个 shard | `tasks.jsonl` 最后写且先写 `.tmp` 再改名,它存在且比 shard 新就跳过;shard 已不存在的旧打包删掉。 | +| train / WTA | 最多 10 分钟 | 每 10 分钟原子写 `last.pt`(`ckpt_min`,先写 `.tmp` 再 `os.replace`,写到一半断电旧档仍完整),含优化器、步数、最好成绩、早停计数;发散时不再用发散权重覆盖 `last.pt` / `best.pt`。 | +| apg / 挖错 | 最多 10 分钟 | 同上;挖错已过门禁的轮次跳过。 | +| m5 | 半个门禁 | 比检查点新的 `m5_<头>_policy.json` / `_safety.json` 跳过;结果文件改为原子写;检查点不存在按不通过处理(直接进入 WTA 分支)。 | + +**停止也不丢进度**:网页「停止」在训练类阶段先写 `ml/runs/STOP`,训练进程 2 秒内看到后存档、以 143 退出,最多等 90 秒才硬杀(Windows 的 `taskkill /F` 收不到信号,所以靠文件)。终端 Ctrl+C / `kill` 走信号,同样先存档。新一次运行拿到锁时清掉旧的 STOP。 +沙箱验证:STOP 文件 → rc 143、`last.pt` 在第 180 步,续训从 180 到 200 正常收尾;`kill -TERM` → rc 143 并存档;`kill -9`(模拟断电)→ 定时存档的 `last.pt` 完好可读;打包第二次运行「待打包 0 个」。 +不续的只有几分钟级的步骤(kin、gate1、r4、export、bench、accept),整步重跑。 + +## 四、仍未验证(如实) + +- **Windows 侧全部没有实测**:`CREATE_NO_WINDOW` 下关掉黑窗口训练能否继续跑;`/proc/$$/winpid` 与 `taskkill /T` 能否结束整棵树;Git Bash 自带的 `timeout` 能否把 TERM 送到 python.exe / node.exe 子进程(Linux 下已验证会结束整个进程组)。 +- **R4 探针没在真浏览器跑过**(沙箱没有 Chromium):onnxruntime-web 1.20 的 proxy 从 jsDelivr 加载能否建出 Worker(建不出会自动退回并写明 `proxyError`)、无头模式下 WebGPU 是否可用。 +- R2 新判据的池无关性只能在 1.7 M 样本上验证(判据见第一节表)。 +- R8 出题耗时:沙箱 2 vCPU 出 2 题 9 分钟仍未出完,已停掉,改在本机测;`policy_bench.json` 的 r8 段会记录请求题数、实际题数和出题耗时。 +- 训练 / APG / 挖错的机时仍然零实测,第七节之后的结论不变。 + +## 五、请在本机按顺序验证 + +1. `git apply review5_fixes.patch`(基于 `e3bb783`)。 +2. 激活训练环境,双击 `ml/console.bat`:页面「全流程」卡片底部应显示 Python / torch / GPU;用 `set PYTHON=C:\不存在\python.exe` 再双击,应直接提示「不能 import torch」。 +3. 终端单跑 `bash ml/pipeline.sh r4`,把 `ml/runs/r4.json` 发我:4 种配置的 P50/P99、`proxy` 是否为 true、`tickGapMax`、WebGPU 数字。 +4. 网页「开始训练」(续跑),在 gen 节拍出现后:① 另开 Git Bash 跑 `bash ml/pipeline.sh gen`,应被拒绝(返回码 9);② 关掉控制台黑窗口,任务管理器里 node 进程应仍在;③ 重新双击 `console.bat`,页面应显示「由上一个控制台窗口启动」,点「停止」应结束全部 node 进程。 +5. 其余照常跑到 prep,把 `stats.json`(`gapDrop`)与 `r2.json`(`label_std_norm` 和 `label_std_mean` 两个口径)发我。 +6. 断点续跑:gen 跑到一半点「停止」再点「续跑」,`ml/data/log_*.txt` 应出现「续写:已有 N 条」;train 跑过 10 分钟后点「停止」,终端应出现「已存档 last.pt(第 N 步)」,续跑后出现「续训:从第 N 步继续」。 diff --git a/docs/Transformer轨迹拟合训练方案.html b/docs/Transformer轨迹拟合训练方案.html index 8188245..5c81679 100644 --- a/docs/Transformer轨迹拟合训练方案.html +++ b/docs/Transformer轨迹拟合训练方案.html @@ -519,9 +519,11 @@ MeanFlow 推理后端 -onnxruntime-web wasm-SIMD 多线程 +onnxruntime-web wasm-SIMD 多线程,推理放代理 +Worker(不占主线程),线程数 min(16, ⌊0.6×逻辑核⌋);R4 探针同测 +WebGPU,wasm 不达标而 WebGPU 达标则部署用 WebGPU C -小模型下 WebGPU 调度开销占主导 +小模型下 WebGPU 调度开销通常占主导,以 R4 实测为准 @@ -882,7 +884,9 @@ WTA 谁更好;因此 WTA 只作为 M5 部署推理 -浏览器 onnxruntime-web wasm-SIMD 多线程;后续真机控制器 +浏览器 onnxruntime-web wasm-SIMD 多线程(代理 Worker);ONNX 接口 +feat[1,83]、z[K,16,6]:观测编码一次、K=8 +条候选共享编码再解码;后续真机控制器 与现有查看器同页 @@ -1515,10 +1519,15 @@ held-out 闭环仿真(开仿真安全层)测量,由 R4 浏览器推理延迟 -训练前用随机初始化模型导出 ONNX(延迟与权重无关),wasm-SIMD -多线程测 batch=8 P99 < 15 ms -减小模型(d=128,约 1.8M 参数);同时测 WebGPU,更快则换 -M1 前(最大不确定项,训练前结掉) +训练前用随机初始化模型导出 +ONNX(延迟与权重无关),pipeline.sh r4 +无头浏览器自动测部署口径 K=8 P99 < 15 ms(2026-10-18 本机实测旧接口 4 +线程 37.0 ms、16 线程 19.5 ms,均不达标) +先做三项不改模型的改动:编码一次(沙箱 CPU 实测省约 20%)、默认线程 +16、推理移出主线程;仍不达标则 WebGPU 达标即用 WebGPU,否则 +d=128(configs/small.json,约 1.8M 参数)。K=8 不降 +M1 +前(最大不确定项,训练前结掉;不过流程自动停) R5 @@ -1596,20 +1605,23 @@ a_max、插补频率与提交步数。

11. 目录规划

-

运行步骤、命令与耗时见 ml/README.md。

+

运行步骤、命令与耗时见 +ml/README.md。所有长步骤都能断点续跑,意外中断最多损失几分钟:生成按条续写(每条任务的随机数只由种子和序号决定),打包按 +shard 跳过,预训练与 APG 每 10 分钟原子存一次 +last.pt,停止时先存档再退出。

ml/
   pipeline.sh              阶段脚本:setup / kin / gate1 / gen / prep / train / m5 / apg / mine / export / bench
   gen/                     sampler / teacher(确定性 PRM A*)/ timelaw / gen_dataset / consistency / stats / run_parallel.sh
   prep/                    export_kin.cjs → assets/kin.json;build_dataset.cjs → data/samples/*.npy;run_prep.sh
   data/                    (不入库)shard_*.jsonl  stats.json  consistency.json  samples/
-  common/                  kin_torch.py(可微运动学 + 胶囊间隙)  features.py(83 维特征)
+  common/                  kin_torch.py(可微运动学 + 胶囊间隙)  features.py(83 维特征)  ckpt.py(原子存档、定时存档、体面停止)
   model/                   policy.py(MeanFlow 头;WTA K=8 回退头)  meanflow.py
   data.py  train.py        样本加载、预训练
   sim/closed_loop.py       闭环仿真(验证、挖错、门禁共用)
   apg/apg_finetune.py      APG 可微微调
   mining/                  tasks.py  mine_round.py  gate.py  loop.sh
   eval/                    dump_parity.cjs  check_parity.py(JS ↔ PyTorch 对拍)
-  configs/                 base.json  wta.json  apg.json
+  configs/                 base.json  wta.json  small.json  apg.json
   export_onnx.py  serve.py  README.md
 webapp/js/ik-policy.js       运行时:特征、距离下界认证、刹停、投影、精修、Runner(浏览器与 Node 共用)
 webapp/js/ik-policy-glue.js  查看器接入(?policy=1)
diff --git a/docs/Transformer轨迹拟合训练方案.md b/docs/Transformer轨迹拟合训练方案.md
index 8c91cb9..7129e8b 100644
--- a/docs/Transformer轨迹拟合训练方案.md
+++ b/docs/Transformer轨迹拟合训练方案.md
@@ -39,7 +39,7 @@
 | 安全层 | 距离下界认证 + 可刹停不变式 | A | Schwarzer 2005 连续认证;Thumm & Althoff 2022 failsafe |
 | 30/30 手段 | 子目标头、价值头、冷启动/卡死时短前瞻、卡死换向 | B / C | AWE、SHAC、BID |
 | 老师 | PRM 路网 A* + 捷径平滑(确定性);A* 失败才用 `solveDyn` | B | MπNets:老师不一致碰撞率 13.78%,一致老师 0.94% |
-| 推理后端 | onnxruntime-web wasm-SIMD 多线程 | C | 小模型下 WebGPU 调度开销占主导 |
+| 推理后端 | onnxruntime-web wasm-SIMD 多线程,推理放代理 Worker(不占主线程),线程数 min(16, ⌊0.6×逻辑核⌋);R4 探针同测 WebGPU,wasm 不达标而 WebGPU 达标则部署用 WebGPU | C | 小模型下 WebGPU 调度开销通常占主导,以 R4 实测为准 |
 
 ### 1.1 取舍决定(每项只有一个答案)
 
@@ -130,7 +130,7 @@
 |---|---|---|
 | 数据生成 | Hark 沙箱(2 vCPU / 7 GB,Node)或用户机器 | 纯 JS,无需 GPU |
 | 预训练 + 后训练 | 用户 RTX 3090 24 GB | batch 1024–2048 |
-| 部署推理 | 浏览器 onnxruntime-web wasm-SIMD 多线程;后续真机控制器 | 与现有查看器同页 |
+| 部署推理 | 浏览器 onnxruntime-web wasm-SIMD 多线程(代理 Worker);ONNX 接口 feat[1,83]、z[K,16,6]:观测编码一次、K=8 条候选共享编码再解码;后续真机控制器 | 与现有查看器同页 |
 
 ```bash
 conda create -n armtf python=3.11 -y && conda activate armtf
@@ -444,7 +444,7 @@ for 每个 batch(随机采样 q0 和目标,不需要老师):
 | R1 | 老师在相邻任务上左右绕行跳变,标签自相矛盾 | 起终点扰动 ±5 mm 的邻居任务同伦类一致率 ≥ 90% | 对 A* 边代价加左右偏好项,强制同区域选同侧 | M1 |
 | R2 | 时间律仍有不可观测成分,闭环起不了步 | 近邻样本标签方差 < 10% 步长(`ml/eval/check_r2.py`);起步失败率 = 0(闭环 `start_fail`) | 进一步把剩余路程估计加入输入 | M1 / M5 |
 | R3 | 距离认证过于保守,贴柱处拒绝率高,影响 30/30 | near_column 上认证拒绝率统计;离线 10⁵ 段 0.01° 审计漏检 = 0 | 训练余量提高到 4–6 mm,接受行程变长;收紧杠杆界(按位形分区) | M2 / M7 |
-| R4 | 浏览器推理延迟 | 训练前用随机初始化模型导出 ONNX(延迟与权重无关),wasm-SIMD 多线程测 batch=8 P99 < 15 ms | 减小模型(d=128,约 1.8M 参数);同时测 WebGPU,更快则换 | **M1 前(最大不确定项,训练前结掉)** |
+| R4 | 浏览器推理延迟 | 训练前用随机初始化模型导出 ONNX(延迟与权重无关),`pipeline.sh r4` 无头浏览器自动测部署口径 K=8 P99 < 15 ms(2026-10-18 本机实测旧接口 4 线程 37.0 ms、16 线程 19.5 ms,均不达标) | 先做三项不改模型的改动:编码一次(沙箱 CPU 实测省约 20%)、默认线程 16、推理移出主线程;仍不达标则 WebGPU 达标即用 WebGPU,否则 d=128(`configs/small.json`,约 1.8M 参数)。K=8 不降 | **M1 前(最大不确定项,训练前结掉;不过流程自动停)** |
 | R5 | MeanFlow 头在 6 轴、位姿目标下穿柱或训练不稳 | near_column 穿柱率(关安全层)≤ 2%;损失不发散 | 同骨干换 WTA K=8 头 | M5 |
 | R6 | 位姿目标的多 IK 分支带来额外多峰 | 不同肘/腕分支子集到达率 ≥ 主集 − 2 个百分点 | 加目标分支标志 | M5 |
 | R7 | APG 压缩多模态或拉长路径(ELMP 中路径 +23%) | 每轮门禁:B/C 类左右绕行比例变化 ≤ 5 个百分点、行程中位数比 ≤ 1.05(`ml/mining/gate.py`) | 加大 BC 锚定权重;行程项 | M6 |
@@ -469,7 +469,7 @@ for 每个 batch(随机采样 q0 和目标,不需要老师):
 
 ## 11. 目录规划
 
-运行步骤、命令与耗时见 `ml/README.md`。
+运行步骤、命令与耗时见 `ml/README.md`。所有长步骤都能断点续跑,意外中断最多损失几分钟:生成按条续写(每条任务的随机数只由种子和序号决定),打包按 shard 跳过,预训练与 APG 每 10 分钟原子存一次 `last.pt`,停止时先存档再退出。
 
 ```
 ml/
@@ -477,14 +477,14 @@ ml/
   gen/                     sampler / teacher(确定性 PRM A*)/ timelaw / gen_dataset / consistency / stats / run_parallel.sh
   prep/                    export_kin.cjs → assets/kin.json;build_dataset.cjs → data/samples/*.npy;run_prep.sh
   data/                    (不入库)shard_*.jsonl  stats.json  consistency.json  samples/
-  common/                  kin_torch.py(可微运动学 + 胶囊间隙)  features.py(83 维特征)
+  common/                  kin_torch.py(可微运动学 + 胶囊间隙)  features.py(83 维特征)  ckpt.py(原子存档、定时存档、体面停止)
   model/                   policy.py(MeanFlow 头;WTA K=8 回退头)  meanflow.py
   data.py  train.py        样本加载、预训练
   sim/closed_loop.py       闭环仿真(验证、挖错、门禁共用)
   apg/apg_finetune.py      APG 可微微调
   mining/                  tasks.py  mine_round.py  gate.py  loop.sh
   eval/                    dump_parity.cjs  check_parity.py(JS ↔ PyTorch 对拍)
-  configs/                 base.json  wta.json  apg.json
+  configs/                 base.json  wta.json  small.json  apg.json
   export_onnx.py  serve.py  README.md
 webapp/js/ik-policy.js       运行时:特征、距离下界认证、刹停、投影、精修、Runner(浏览器与 Node 共用)
 webapp/js/ik-policy-glue.js  查看器接入(?policy=1)
diff --git a/ml/README.md b/ml/README.md
index bb5c7f3..15bcfec 100644
--- a/ml/README.md
+++ b/ml/README.md
@@ -3,14 +3,24 @@
 目标:在你的机器上从零跑出 Transformer 避障策略,并在查看器里直接用上。
 方案依据见 `docs/Transformer轨迹拟合训练方案.md`。所有命令在**仓库根目录**执行。
 
-**一键运行**:Windows 双击 `ml/console.bat`(Linux/WSL 运行 `bash ml/console.sh`),浏览器自动打开训练控制台 `http://localhost:8100/`,点「开始训练」选好起止阶段即从对拍一路跑到验收,全程进度、曲线、终端输出都在网页上,可随时停止、从停下的那一步继续(见「训练控制台」)。
-也可以在终端按阶段执行 `bash ml/pipeline.sh <阶段>`,或 `bash ml/pipeline.sh all` 一次跑完;每一阶段完成后看本手册对应小节的「检查」。R2(第 5 步)或 M5(第 7 步)不通过时一键流程会停下,按对应小节处理。
+**一键运行(无人值守)**:先激活训练用的 Python 环境,Windows 双击 `ml/console.bat`(Linux/WSL 运行 `bash ml/console.sh`),浏览器自动打开训练控制台 `http://localhost:8100/`,点「开始训练」即从对拍一路跑到验收。等价的终端命令是 `bash ml/pipeline.sh all`。
+- **断了再点一次就是续跑**(断电、蓝屏、误关、手动停止都一样),损失不超过几分钟:
+  - 已成功的阶段自动跳过;
+  - 生成:每条任务的随机数只由(种子, 序号)决定,没写完的 `shard_XXXX.jsonl.part` 保留完整的行、从下一条接着写;
+  - 打包:已打包完的 shard 跳过;
+  - 预训练 / WTA / APG:每 10 分钟原子存一次 `last.pt`(含优化器、步数、最好成绩、早停计数;配置项 `ckpt_min`),续跑时自动 `--resume`;
+  - M5:已评完的那一半(比检查点新的结果文件)跳过;挖错:已过门禁的轮次跳过,断在某轮的 APG 用该轮 `last.pt` 续。
+  - 不续的只有几分钟级的短步骤(对拍、一致性门禁、R4、导出、bench、验收),它们整步重跑。
+- **同一时间只能跑一条**:网页、终端、`all` 共用一把锁 `ml/runs/pipeline.lock`,第二个入口会被直接拒绝。
+- **关窗口不影响训练**:网页和控制台窗口都可以关,重新打开即可接着看、可停止。
+- **门槛在流程内自动处理**:R4 推理延迟自动测(不过就在训练前停下);R2 只警告;M5 不过自动改训 WTA 头,两种头都不过才停;每个阶段有超时上限(估时 ×3),卡死会判失败并放锁。
+也可以按阶段单独执行 `bash ml/pipeline.sh <阶段>`;每一阶段完成后看本手册对应小节的「检查」。
 
 | 阶段 | 命令 | 预计耗时(28 核 CPU + RTX 3090) | 产出 |
 |---|---|---|---|
 | 1 环境 | `bash ml/pipeline.sh setup` | 10 分钟 | 依赖 |
 | 2 对拍 | `bash ml/pipeline.sh kin` | 约 1 分钟(估算:300 条对拍实测 7.6 s,流程跑 2000 条) | `ml/assets/kin.json`、对拍 PASS |
-| 2′ R4 延迟 | `bash ml/pipeline.sh r4` → 浏览器测 | 10 分钟 | 浏览器 `__POLICY.latency()` 输出 |
+| 2′ R4 延迟 | `bash ml/pipeline.sh r4`(无头 Edge/Chrome 自动测) | 约 5 分钟 | `ml/runs/r4.json` |
 | 3 老师门禁 | `bash ml/pipeline.sh gate1` | 25–30 分钟(单核) | `ml/data/consistency.json` |
 | 4 生成数据 | `bash ml/pipeline.sh gen` | 不共识约 1 小时;共识约 4–5 小时 | `ml/data/shard_*.jsonl`、`stats.json` |
 | 5 打包样本 | `bash ml/pipeline.sh prep` | 15–30 分钟 | `ml/data/samples/`、`ml/data/r2.json` |
@@ -24,10 +34,12 @@
 | 13 使用 | `python ml/serve.py` → 浏览器 `?policy=1` | — | — |
 
 第 4/5/6 步耗时为 RTX 3090 + 28 核实测换算;标「估算」「未实测」的不要当预算,以每步首次输出的速度为准。
-第 1 步(安装依赖)和第 2′ 步(R4,需要在浏览器里看延迟)建议先手动做;一键流程默认从第 2 步开始。
+第 1 步(安装依赖)建议先手动做;一键流程默认从第 2 步开始,含 2′。
 
 ---
 
+> 分歧规则:审查方与实现方意见讨论后仍不一致时,以实现方(Hark)的意见为准先落地;下一轮用实测数据说话(详见 docs/ML运行手册审查.md 第五轮第〇节)。
+
 ## 1. 环境
 
 - Linux / WSL2,或 **Windows 原生 + Git Bash**(实测第 2/4/5/6 步与闭环仿真可跑);NVIDIA 驱动 + CUDA(`nvidia-smi` 能看到 3090)
@@ -49,15 +61,18 @@ bash ml/pipeline.sh kin
 检查:最后打印的 JSON 里 `"PASS": true`(fk、姿态、组对间隙、83 维特征、间隙梯度与 JS 一致)。
 `docs/collide.json` 以后只要重新拟合或重生成 `pair2d`/`roadmap`,这一步、数据、模型都要重来(指纹不符时各脚本会拒绝运行)。
 
-**R4 延迟摸底(训练前做)**:推理延迟只取决于模型结构,与权重无关,所以用随机初始化模型先测,不过就在训练前改结构,不必等训完。
+**R4 延迟(训练前做,自动)**:推理延迟只取决于模型结构,与权重无关,所以用随机初始化模型先测,不过就在训练前改结构,不必等训完。
 
 ```bash
-bash ml/pipeline.sh r4              # 随机初始化 4.92 M 模型导出 fp32 ONNX 到 webapp/models/
-python ml/serve.py                  # 浏览器打开 http://localhost:8000/?policy=1 ,来回拖动黄球约 1 分钟
+bash ml/pipeline.sh r4      # 随机初始化模型导出到 ml/runs/r4/traj_policy_r4.onnx(不碰 webapp/models/),无头浏览器自动测 → ml/runs/r4.json
 ```
 
-控制台运行 `__POLICY.latency()`:推理 batch=8 P99 < 15 ms、安全层 P99 < 15 ms 为通过(随机权重的动作是乱的,手臂会乱动或频繁刹停,这一步只看延迟)。
-不通过:把 `ml/configs/base.json` 的 `model.d` 从 192 改为 128(`ff` 改 512)后重跑 `r4`;仍不通过再加 `&ep=webgpu` 对比。把输出发我。
+部署形态:模型每次只编码 1 行观测、解码 K=8 条候选(`meta.enc_once`);wasm 线程默认 `min(16, ⌊0.6×逻辑核⌋)`,推理放在 onnxruntime 的代理 Worker 里不占主线程。
+探针测 4 种配置:默认线程 + Worker(部署口径)、4 线程(对照旧默认)、默认线程不开 Worker(看 `tickGapMax`:推理是否卡住 20 ms 节拍)、WebGPU。
+**通过线:部署口径 batch=8 P99 < 15 ms**;wasm 不过而 WebGPU 过也算通过,`r4.json` 记 `deploy_ep: webgpu`,查看器用 `&ep=webgpu`。
+不通过时流程停在这里(返回码 5):改用 `CFG=ml/configs/small.json`(d=128、ff=512,约 1.8 M 参数)重跑。确认要继续设 `R4_STRICT=0`。
+找不到浏览器时 `pass` 为 null、流程继续、验收标红;可用 `R4_BROWSER=浏览器路径` 指定。离线时设 `ORT_LOCAL=1` 并把 onnxruntime-web 的 dist 文件放到 `webapp/vendor/ort/`。
+手动看:`python ml/serve.py` 后打开 `http://localhost:8000/?policy=../ml/runs/r4/traj_policy_r4.onnx&probe=1`(随机模型默认会被查看器拒绝,`&probe=1` 才放行,徽标标红「随机探针模型」)。
 
 ## 3. 老师一致性门禁
 
@@ -97,7 +112,9 @@ bash ml/pipeline.sh prep
 每条轨迹按 50 Hz 时间律重放,每 3 步取一个样本(特征与部署同一份 JS 代码计算),30% 样本加 σ = 1° 噪声增广(加噪后间隙 < 3 mm 的不用)。
 认证最小间隙 < 3 mm 的老师轨迹不进训练集(`MIN_GAP_MM=3`,与 `stats.json` 的 `gapDrop` 同一口径):模型只学离障碍 ≥ 3 mm 的走法,运行时投影护栏仍保持 5 mm。
 检查 `ml/data/samples/index.json`:`train_samples` 约 170 万、`val_samples` 约 9 万(5% 任务划为 val)。
-随后自动跑 R2 检查(`ml/data/r2.json`):近邻样本前 3 步标签差 `label_std_mean < 0.10`(10% 步长)为通过;不通过时这一步以返回码 4 结束、一键流程停下,先把 `r2.json` 发我。小样本时近邻对不足,`pass` 为 null,不拦。
+`MIN_GAP_MM` 必须与生成阶段相同(`stats.json` 的 `minGapFilterMm`),不一致时这一步直接报错(返回码 8)。
+随后自动跑 R2 检查(`ml/data/r2.json`):判据是 `label_std_norm < 0.10`(近邻距离 → 0 的回归截距,与搜索池大小无关);`label_std_mean` 是旧口径(取最近 5% 近邻,随池大小变),只作参考。
+不通过时默认**只警告、继续训练**(阈值尚未经实测校准),验收汇总列为参考项;`R2_STRICT=1` 才在这里停(返回码 4)。小样本时近邻对不足,`pass` 为 null。
 
 ## 6. 预训练
 
@@ -109,11 +126,7 @@ bash ml/pipeline.sh train                           # MeanFlow 头 → ml/runs/b
 
 - **通过线(R10):`samples_per_s` ≥ 5000,即 60000 步 ≤ 3.5 小时**。参考:3090、batch 1024 实测 6.2 k 样本/s ≈ 2.8 小时。MeanFlow 的 JVP 双前向使训练受算力限制,加大 batch 不会提高样本/s;低于通过线时把 `configs/base.json` 的 `fk_caps` 从 96 降到 48,仍不够再把 `steps` 降到 40000
 - 训练中每 2000 步打印一行 `EVAL {...}`:val 任务闭环(部署节奏、不开安全层)`success`、`collision_rate`、`time_ok_1p3`、`start_fail`。时限 = 老师时长 × 2(至少 3 s)
-- 显存不够把 `batch` 改成 512。中断后续训(日志追加;新训练会覆盖旧日志):
-
-```bash
-python -m ml.train --samples ml/data/samples --out ml/runs/base --resume ml/runs/base/last.pt
-```
+- 显存不够把 `batch` 改成 512。中断后再跑 `bash ml/pipeline.sh train`(或 `all`)会自动从 `ml/runs/<头>/last.pt` 续训(每 10 分钟存一次),测速那 2000 步做过就跳过。训练发散时不会用发散的权重覆盖 `last.pt` / `best.pt`。
 
 ## 7. M5 门禁:选定动作头
 
@@ -128,12 +141,8 @@ bash ml/pipeline.sh m5
 
 三种口径的关系:M5 是纯策略能力;仿真安全层是近似;**部署能不能过只看第 11 步**(与浏览器同一份运行时,含投影、距离认证、刹停、预算、换向锁)。三者互不替代。
 
-不通过(或训练日志出现「训练发散」)则换 WTA K=8 头。WTA 的产物在独立目录,不会覆盖 MeanFlow:
-
-```bash
-CFG=ml/configs/wta.json bash ml/pipeline.sh train    # → ml/runs/bench_wta/、ml/runs/wta/
-CFG=ml/configs/wta.json bash ml/pipeline.sh m5       # → ml/runs/m5_gate_wta.json;通过则选定 wta
-```
+不通过时**流程内自动**改训 WTA K=8 头(约 3 小时,产物在 `ml/runs/bench_wta/`、`ml/runs/wta/`,不覆盖 MeanFlow),再判一次 → `ml/runs/m5_gate_wta.json`;两种头都不过才停(返回码 3)。`AUTO_WTA=0` 关闭自动分支。
+单独跑 WTA:`CFG=ml/configs/wta.json bash ml/pipeline.sh train` 再 `CFG=ml/configs/wta.json bash ml/pipeline.sh m5`。
 
 需要强制指定后续阶段的起点模型时用 `CKPT=路径 bash ml/pipeline.sh apg`。
 
@@ -210,33 +219,40 @@ python ml/serve.py                  # 带跨源隔离头的本地服务,wasm 
 
 | 步骤 | 文件 |
 |---|---|
-| 2 | 对拍输出(终端 JSON)、R4 的 `__POLICY.latency()` 输出 |
+| 2 | 对拍输出(终端 JSON)、`ml/runs/r4.json` |
 | 3–5 | `ml/data/consistency.json`、`ml/data/stats.json`、`ml/data/samples/index.json`、`ml/data/r2.json` |
 | 6 | `ml/runs/bench_base/log.jsonl`(测速)、`ml/runs/base/log.jsonl` |
 | 7 | `ml/runs/m5_gate_*.json` |
 | 8–9 | `ml/runs/apg/log.jsonl`、`ml/runs/mine/r*/report.json` 与 `gate.json` |
-| 11–13 | `ml/runs/policy_bench.json`、`ml/runs/acceptance.json`、浏览器 `__POLICY.latency()` 输出 |
+| 11–13 | `ml/runs/policy_bench.json`、`ml/runs/acceptance.json` |
 | 任一步出错 | `ml/runs/pipeline.json`、`ml/runs/console.log`(控制台启动时)或终端输出 |
 
 ## 训练控制台
 
 ```text
 Windows:双击 ml/console.bat          Linux / WSL:bash ml/console.sh
-(等价于 python -m ml.monitor --open,需要训练用的 Python 环境;Windows 另需 Git for Windows 提供的 Git Bash)
+(等价于 python -m ml.monitor --open。先激活训练用的 Python 环境;或设环境变量 PYTHON=训练环境 python 的完整路径。
+ 启动器以「能 import torch」判断 Python 可用;Windows 另需 Git for Windows 提供的 Git Bash)
 ```
+不要直接双击 `ml/monitor.html`:那样只能看到示例数据(页面顶部有黄色提示)。
 
 浏览器自动打开 `http://localhost:8100/`,零第三方依赖、不连外网,每 5 秒刷新。
 
-**开始**:点右上角「▶ 开始训练」→ 选起始/结束阶段(默认从第一个还没成功完成的阶段到「验收汇总」)、动作头、共识过滤、任务数、进程数、目标样本数、挖错轮数 → 「开始」。控制台在后台执行 `bash ml/pipeline.sh all`(`FROM`/`TO` 即起止阶段),用的是启动控制台的同一个 Python。
-**停止**:「■ 停止」结束整个进程树,这一步记为「已停止」,已完成的步骤保留;再点「开始」默认从这一步继续。
-**网页可以随时关**,训练不受影响,重新打开即可接着看;**关闭控制台窗口会结束训练**,要关终端继续跑,Linux 用 `nohup bash ml/console.sh &`。
-终端里直接跑的 `bash ml/pipeline.sh …` 同样会显示进度(只是不能在网页上停止);控制台检测到终端里有阶段在跑时不会重复开始。
+**自检**:控制台启动时检查训练用 Python 能否 `import torch`、GPU、node、bash,结果显示在「全流程」卡片底部;不过则「开始训练」不可点。
+**开始**:点「▶ 开始训练」→ 运行方式(续跑 / 从头重跑)、动作头、共识过滤、任务数、进程数、目标样本数、挖错轮数、`MIN_GAP_MM` → 「开始」。控制台在后台执行 `bash ml/pipeline.sh all`。
+- 续跑:跳过已成功的阶段。改了参数时只作废受影响的阶段(改生成参数 → 从生成起重跑;改动作头 → R4 与训练起重跑;改挖错参数 → 从挖错起重跑),旧产物移到 `ml/archive/<时间>/`,不删除。
+- 从头重跑(`FRESH=1`):全部旧产物移到 `ml/archive/<时间>/`。
+**停止**:「■ 停止」时,训练类阶段(预训练 / M5 / APG / 挖错)先收到停止请求(`ml/runs/STOP`),存好 `last.pt` 自行退出(最多等 90 秒),然后结束整个进程树;这一步记为「已停止」并放锁,再点「开始训练」即从断点续跑。终端里按 Ctrl+C 或 `kill` 也会先存档再退出。终端或上一个控制台窗口起的流水线也能在这里停(按锁里记录的进程号)。
+**网页和控制台窗口都可以关**,训练不受影响(Windows 子进程有自己的隐藏控制台,Linux 在独立会话里),重新双击 `console.bat` 即可接着看。
+**互斥**:已有一条在跑时(网页、终端或 `all` 起的都算),第二个入口会被拒绝并给出持锁进程号;进程被强杀没来得及放锁时,心跳 5 分钟不刷新即视为失效。
+**超时**:每个阶段上限 = 估时 ×3(网页「本步粗估 / 上限」;`TIMEOUT_SCALE=2` 整体放宽),超了判卡死、阶段失败、放锁。生成阶段每分钟往终端输出打一行节拍。
+**安全**:开始/停止只接受本控制台页面发起的请求(自定义请求头 + 同源检查),本机其他网页发不进来。
 
 页面内容,从上到下:
 
 | 区域 | 内容 | 数据来源 |
 |---|---|---|
-| 流水线 | 11 个阶段的状态(完成 / 运行中 / 失败 / 已停止 / 未开始)与各步用时 | `ml/runs/pipeline.json`(每个阶段开始结束时自动写) |
+| 流水线 | 12 个阶段的状态(完成 / 运行中 / 失败 / 已停止 / 未开始)与各步用时 | `ml/runs/pipeline.json`(每个阶段开始结束时自动写) |
 | 全流程 | 全流程百分比(按各步粗估耗时加权)、当前步骤与它的进度、总用时、本步用时、剩余粗估、开始/停止按钮 | 同上 + 各阶段产物 |
 | GPU | 利用率、显存、温度、功耗 | `nvidia-smi` |
 | 当前运行 | 正在跑的训练(自动跟随;顶部下拉框可切换 `ml/runs` 下任一次运行):步数、已用/剩余时间、预计完成时刻、评估标记 | `ml/runs/*/log.jsonl` |
@@ -244,7 +260,7 @@ Windows:双击 ml/console.bat          Linux / WSL:bash ml/console.sh
 | 各阶段结果 | 数据统计(含 `gapDrop`)、R2、M5 两种口径、挖错各轮与门禁、部署评测、验收逐项 | `ml/data/*.json`、`ml/runs/*.json` |
 | 终端输出 | 控制台启动的流水线的命令行输出,报错标红 | `ml/runs/console.log` |
 
-当前步骤的进度怎么算:生成数据 = 已写出任务条数 / 目标条数(含自动补量);打包 = 已打包分片 / 分片数;预训练 = (测速 2000 步 + 正式步数) / (2000 + 总步数);APG = 步数;挖错 = 已完成轮数 + 本轮 APG 步数(共最多 `ROUNDS` 轮,提升不足会提前停);M5 = 两遍评估;对拍、老师门禁、导出、部署评测、验收无法计量,显示用时与粗估。全流程百分比与剩余时间按手册表格的粗估耗时加权,只作参考。
+当前步骤的进度怎么算:生成数据 = 已写出任务条数 / 目标条数(含自动补量);打包 = 已打包分片 / 分片数;预训练 = (测速 2000 步 + 正式步数) / (2000 + 总步数);APG = 步数;挖错 = 已完成轮数 + 本轮 APG 步数(共最多 `ROUNDS` 轮,提升不足会提前停);M5 = 两遍评估;对拍、老师门禁、导出、部署评测、验收无法计量,显示用时与粗估。全流程百分比与剩余时间按 `ml/pstate.py` 里的阶段估时加权(与超时上限同一份表),只作参考。
 
 同一局域网的手机或其他电脑要看:`python -m ml.monitor --host 0.0.0.0`,用本机 IP 访问;其他机器只能看,开始/停止只接受本机操作(`--remote-control` 放开)。
 
@@ -252,9 +268,10 @@ Windows:双击 ml/console.bat          Linux / WSL:bash ml/console.sh
 
 - **指纹不符**:`collide.json` 变了。依次重跑第 2、4、5 步并重训。
 - **训练发散**(loss 出现 nan/inf,自动停在 `diverged.pt`):先把 `lr` 降到 5e-4 重试一次,仍发散就走 WTA 回退(第 7 步)。
-- **显存/内存不足**:降 `batch`;APG 还可把 `seg_plans` 降到 5。出题与间隙计算已分块(每块 512 个位形)。
+- **显存/内存不足**:降 `batch`;APG 还可把 `seg_plans` 降到 5。出题与间隙计算按空闲显存自动分块(用 1/4)。
 - **小样本摸底**:几百条任务时 val 可能为空,训练会提示并跳过闭环验证;R2 检查近邻对不足时 `pass` 为 null,不拦后续步骤。
-- **`all` 在第 7 步中止**:M5 门禁不通过时有意退出(返回码 3),按第 7 步换 WTA 头。
+- **返回码**:3 = M5 两种头都没过;4 = R2 不过且 `R2_STRICT=1`;5 = R4 不过;6 = 生成进程崩溃(看 `ml/data/log_*.txt`,采样器连续失败会写明原因);8 = `MIN_GAP_MM` 前后不一致;9 = 已有一条在跑;124 = 阶段超时。
+- **生成阶段报「采样失败:…连续 N 次未命中」**:多半是 `collide.json` 或关节限位变了,某类位形几乎不存在;把该日志发我。
 - **浏览器只有 1 线程**:没走 `ml/serve.py`(缺 COOP/COEP 头)。
 - **查看器徽标「策略模式不可用」**:看控制台报错,多为模型文件缺失、指纹或 TCP 不符。
 
@@ -265,7 +282,7 @@ ml/
   pipeline.sh              一条龙阶段脚本(环境变量见文件头);每阶段开始/结束经 pstate.py 写 runs/pipeline.json
   console.bat / console.sh 训练控制台一键启动(Windows / Linux)
   monitor.py  monitor.html 训练控制台:本地 HTTP 服务(开始/停止/进度/日志接口)+ 零依赖网页(地址带 ?demo=1 看示例数据)
-  pstate.py                流水线状态文件读写
+  pstate.py                流水线状态 runs/pipeline.json、互斥锁 runs/pipeline.lock、阶段估时/超时表(唯一来源)
   gen/                     老师数据:sampler / teacher(确定性 PRM A*)/ cert.cjs(距离下界认证,实现在 ik-policy.js)/
                            pathdev.cjs(同伦类判定,共识过滤与一致性门禁用)/ timelaw / gen_dataset / consistency / stats / run_parallel.sh
   prep/                    export_kin.cjs(→ assets/kin.json)、build_dataset.cjs(→ samples/*.npy)、npy.cjs(.npy 写出)、run_prep.sh
@@ -275,11 +292,12 @@ ml/
   sim/closed_loop.py       部署节奏闭环仿真(验证 / 挖错 / 门禁)
   apg/apg_finetune.py      APG 可微微调
   mining/                  tasks.py(无老师出题)、mine_round.py、gate.py(含 R7)、loop.sh
-  eval/                    dump_parity.cjs + check_parity.py(JS↔PyTorch 对拍)、check_r2.py(R2)、acceptance.py(验收汇总)
+  eval/                    dump_parity.cjs + check_parity.py(JS↔PyTorch 对拍)、check_r2.py(R2)、r4_probe.py(R4 无头浏览器探针)、acceptance.py(验收汇总)
   toy/toy_bimodal.py       2D 双模态对照实验(动作头选型依据)
   export_onnx.py  serve.py
-  configs/                 base.json(MeanFlow)、wta.json(回退)、apg.json
+  configs/                 base.json(MeanFlow)、wta.json(回退)、small.json(R4 不过时的 d=128)、apg.json
 webapp/js/ik-policy.js       运行时:特征、距离下界认证、刹停、投影、精修、Runner(浏览器与 Node 共用)
-webapp/js/ik-policy-glue.js  查看器接入(?policy=1)
+webapp/js/ik-policy-glue.js  查看器接入(?policy=1;&threads= &proxy=0 &ep=webgpu &probe=1)
+webapp/r4_probe.html         R4 探针页(r4_probe.py 打开)
 tests/policy_bench.cjs       部署形态评测(nc / route / r8 / nc1000)
 ```
diff --git a/ml/apg/apg_finetune.py b/ml/apg/apg_finetune.py
index 07ff994..b3bb126 100644
--- a/ml/apg/apg_finetune.py
+++ b/ml/apg/apg_finetune.py
@@ -7,7 +7,7 @@
   "到达"仍是每 seg_plans 段末的距离项(无到达即停),属有意设计:段末项给出持续的接近梯度。
   margin_train 3 mm:与训练数据过滤线一致(认证最小间隙 < 3 mm 的老师轨迹不进训练集),碰撞项不与 BC 标签对抗;
   运行时投影护栏独立保持 5 mm。端点与中点两个铰链取均值,与只查端点同量纲,w_col 无需重标。"""
-import argparse, json, math, os, random, time
+import argparse, json, math, os, random, sys, time
 import torch
 
 from ml.common.kin_torch import Kin
@@ -16,6 +16,7 @@ from ml.data import Samples, load_tasks
 from ml.model.policy import build, wta_loss
 from ml.model.meanflow import meanflow_loss
 from ml.sim.closed_loop import evaluate, tasks_to_tensors, goal_err
+from ml.common.ckpt import Saver, atomic_save
 
 
 def load_pool(paths):
@@ -32,6 +33,7 @@ def main():
     ap.add_argument('--pool', nargs='*', default=[]); ap.add_argument('--out', default='ml/runs/apg')
     ap.add_argument('--config', default='ml/configs/apg.json'); ap.add_argument('--steps', type=int, default=None)
     ap.add_argument('--device', default='cuda' if torch.cuda.is_available() else 'cpu'); ap.add_argument('--seed', type=int, default=0)
+    ap.add_argument('--resume', default=None, help='从 /last.pt 续跑(2026-10-18 第五轮,审查 N-11:几小时的 APG 断了不再从头来)')
     a = ap.parse_args()
     cfg = json.load(open(a.config)); C = cfg['apg']
     if a.steps: C['steps'] = a.steps
@@ -49,13 +51,26 @@ def main():
     opt = torch.optim.AdamW(model.parameters(), lr=C['lr'], betas=(0.9, 0.95), weight_decay=0.0)
     amp = dev.startswith('cuda') and C.get('amp') == 'bf16'
     step = torch.tensor(STEP, device=dev)
-    log = open(os.path.join(a.out, 'log.jsonl'), 'w')
-    base, _ = evaluate(model.eval(), kin, val_tasks, dev)
-    print('EVAL0', json.dumps(base), flush=True); log.write(json.dumps({'eval': base, 'step': 0}) + '\n')
-    best = base['success']
-    torch.save({'model': model.state_dict(), 'cfg': mcfg, 'step': 0, 'fp': kin.fp, 'eval': base}, os.path.join(a.out, 'best.pt'))
+    it0 = 0
+    if a.resume and os.path.exists(a.resume):
+        rk = torch.load(a.resume, map_location=dev); model.load_state_dict(rk['model'])
+        if 'opt' in rk: opt.load_state_dict(rk['opt'])
+        it0 = rk.get('step', 0)
+        bp = os.path.join(a.out, 'best.pt')
+        best = torch.load(bp, map_location='cpu').get('eval', {}).get('success', -1.0) if os.path.exists(bp) else -1.0
+        log = open(os.path.join(a.out, 'log.jsonl'), 'a')
+        print(f'续跑:从第 {it0} 步继续,当前最好 success {best:.4f}', flush=True)
+    else:
+        log = open(os.path.join(a.out, 'log.jsonl'), 'w')
+        base, _ = evaluate(model.eval(), kin, val_tasks, dev)
+        print('EVAL0', json.dumps(base), flush=True); log.write(json.dumps({'eval': base, 'step': 0}) + '\n')
+        best = base['success']
+        atomic_save({'model': model.state_dict(), 'cfg': mcfg, 'step': 0, 'fp': kin.fp, 'eval': base}, os.path.join(a.out, 'best.pt'))
     M = C['margin_train_mm']; t0 = time.time()
-    for it in range(C['steps']):
+    saver = Saver(C.get('ckpt_min', 10))             # 2026-10-18:每 10 分钟 + 收到停止请求时存 last.pt(不再只在每 500 次验证时存)
+    def save_last(n, ev=None):
+        atomic_save({'model': model.state_dict(), 'opt': opt.state_dict(), 'cfg': mcfg, 'step': n, 'fp': kin.fp, 'eval': ev}, os.path.join(a.out, 'last.pt')); saver.mark()
+    for it in range(it0, C['steps']):
         model.train()
         batch = random.choices(train_tasks, weights=w, k=C['batch'])
         q0, T, R, ex, tr = tasks_to_tensors(batch, dev)
@@ -134,14 +149,18 @@ def main():
                'gnorm': gn.item(), **stats, 'min': (time.time() - t0) / 60}
         if it % 10 == 0: print(json.dumps(rec), flush=True)
         log.write(json.dumps(rec) + '\n'); log.flush()
+        if saver.stop():
+            save_last(it + 1); log.close()
+            print(f'收到停止请求:已存档 last.pt(第 {it + 1} 次),下次运行自动续跑', flush=True); sys.exit(143)
+        if saver.due(): save_last(it + 1)
         if (it + 1) % C['eval_every'] == 0 or it == C['steps'] - 1:
             res, _ = evaluate(model.eval(), kin, val_tasks, dev)
             res['it'] = it + 1; res['t'] = time.time()
             print('EVAL', json.dumps(res), flush=True); log.write(json.dumps({'eval': res}) + '\n'); log.flush()
             sd = {'model': model.state_dict(), 'cfg': mcfg, 'step': it + 1, 'fp': kin.fp, 'eval': res}
-            torch.save(sd, os.path.join(a.out, 'last.pt'))
+            save_last(it + 1, res)                                                      # last 带优化器状态供续跑;best 不带(导出/门禁用)
             if res['success'] > best + 1e-4:
-                best = res['success']; torch.save(sd, os.path.join(a.out, 'best.pt'))
+                best = res['success']; atomic_save(sd, os.path.join(a.out, 'best.pt'))
     print(f'done, best success {best:.4f}', flush=True)
 
 
diff --git a/ml/common/ckpt.py b/ml/common/ckpt.py
new file mode 100644
index 0000000..a5963a8
--- /dev/null
+++ b/ml/common/ckpt.py
@@ -0,0 +1,44 @@
+"""断点存档与"体面停止"(2026-10-18 第五轮补充:用户要求长训练意外中断后能接着跑,不从头来)。
+
+- atomic_save:先写 <名>.tmp 再 os.replace —— 断电/被杀发生在写盘中途时,旧的 last.pt 仍完整可用。
+- Saver.due():按墙钟每 every_min 分钟存一次 last.pt(不再只在每 2000 步验证时存,断电最多丢 10 分钟)。
+- Saver.stop():收到 SIGTERM/SIGINT,或看到停止标记文件 ml/runs/STOP 时返回 True;
+  训练循环据此先存 last.pt 再以 143 退出。Windows 上 taskkill /F 是硬杀、收不到信号,
+  所以控制台「停止」先写 STOP 文件、等进程自己存档退出,超时才硬杀(见 monitor.Job.stop)。
+"""
+import os, signal, time
+import torch
+
+ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+STOP = os.path.join(ROOT, 'ml', 'runs', 'STOP')
+
+
+def atomic_save(obj, path):
+    tmp = path + '.tmp'
+    torch.save(obj, tmp)
+    os.replace(tmp, path)
+
+
+class Saver:
+    def __init__(self, every_min=10.0):
+        self.every = every_min * 60; self.last = time.time(); self._sig = False; self._chk = 0.0
+        for s in ('SIGTERM', 'SIGINT', 'SIGBREAK', 'SIGHUP'):
+            if hasattr(signal, s):
+                try: signal.signal(getattr(signal, s), self._on)
+                except (ValueError, OSError): pass   # 非主线程等情况:只靠 STOP 文件
+
+    def _on(self, *_): self._sig = True
+
+    def stop(self):
+        if self._sig: return True
+        now = time.time()
+        if now - self._chk > 2:                      # 文件每 2 s 查一次,不拖慢训练步
+            self._chk = now; self._sig = os.path.exists(STOP)
+        return self._sig
+
+    def due(self):
+        if time.time() - self.last >= self.every:
+            self.last = time.time(); return True
+        return False
+
+    def mark(self): self.last = time.time()
diff --git a/ml/configs/apg.json b/ml/configs/apg.json
index b74db59..209510d 100644
--- a/ml/configs/apg.json
+++ b/ml/configs/apg.json
@@ -5,6 +5,6 @@
     "margin_train_mm": 3.0,
     "w_tgt": 1.0, "w_rot": 0.3, "w_col": 2.0, "w_smooth": 0.02, "w_len": 0.001, "w_bc": 0.5,
     "cat_weights": {"A": 0.4, "B": 1.0, "C": 1.6, "D": 0.6, "Dr": 0.6},
-    "eval_every": 500, "eval_tasks": 512
+    "ckpt_min": 10, "eval_every": 500, "eval_tasks": 512
   }
 }
diff --git a/ml/configs/base.json b/ml/configs/base.json
index 04a36f1..b3695d7 100644
--- a/ml/configs/base.json
+++ b/ml/configs/base.json
@@ -5,6 +5,6 @@
     "amp": "bf16", "grad_clip": 1.0,
     "w_mf": 1.0, "w_fk": 0.5, "w_smooth": 0.05, "w_value": 0.1, "w_sub": 0.1,
     "p_neq": 0.25, "fk_caps": 96,
-    "eval_every": 2000, "eval_tasks": 512, "log_every": 50, "patience_evals": 5
+    "ckpt_min": 10, "eval_every": 2000, "eval_tasks": 512, "log_every": 50, "patience_evals": 5
   }
 }
diff --git a/ml/configs/small.json b/ml/configs/small.json
new file mode 100644
index 0000000..5e7c200
--- /dev/null
+++ b/ml/configs/small.json
@@ -0,0 +1,10 @@
+{
+  "model": {"d": 128, "heads": 8, "ff": 512, "enc": 4, "dec": 4},
+  "train": {
+    "batch": 1024, "lr": 1e-3, "warmup": 1000, "steps": 60000, "wd": 0.01, "betas": [0.9, 0.95],
+    "amp": "bf16", "grad_clip": 1.0,
+    "w_mf": 1.0, "w_fk": 0.5, "w_smooth": 0.05, "w_value": 0.1, "w_sub": 0.1,
+    "p_neq": 0.25, "fk_caps": 96,
+    "ckpt_min": 10, "eval_every": 2000, "eval_tasks": 512, "log_every": 50, "patience_evals": 5
+  }
+}
diff --git a/ml/configs/wta.json b/ml/configs/wta.json
index 87b4f06..407e9a5 100644
--- a/ml/configs/wta.json
+++ b/ml/configs/wta.json
@@ -5,6 +5,6 @@
     "amp": "bf16", "grad_clip": 1.0,
     "w_mf": 1.0, "w_fk": 0.5, "w_smooth": 0.05, "w_value": 0.1, "w_sub": 0.1,
     "p_neq": 0.25, "fk_caps": 96,
-    "eval_every": 2000, "eval_tasks": 512, "log_every": 50, "patience_evals": 5
+    "ckpt_min": 10, "eval_every": 2000, "eval_tasks": 512, "log_every": 50, "patience_evals": 5
   }
 }
diff --git a/ml/console.bat b/ml/console.bat
index 910729f..a4f49f5 100644
--- a/ml/console.bat
+++ b/ml/console.bat
@@ -1,8 +1,17 @@
 @echo off
 rem 训练控制台一键启动(Windows):双击本文件 → 自动打开 http://localhost:8100 ,在网页上点「开始训练」。
-rem 需要 Python(与训练同一个环境)和 Git for Windows(提供 Git Bash)。关闭本窗口会结束控制台和正在跑的训练。
+rem 需要:带 torch 的 Python(与训练同一个环境)和 Git for Windows(提供 Git Bash)。
+rem Python 选择顺序:环境变量 PYTHON → 当前 PATH 里的 python。判据是能 import torch(不是 where python:
+rem   WindowsApps 里的假 python 也能被 where 找到,旧写法会"看起来正常、到训练那步才炸",审查 N-10)。
+rem 关闭本窗口只关控制台;已开始的训练继续跑,重新双击即可接着看、可停止。
 chcp 65001 >nul
 cd /d "%~dp0\.."
-where python >nul 2>nul || (echo 找不到 python,请先激活训练用的 Python 环境 & pause & exit /b 1)
-python -m ml.monitor --open %*
+if "%PYTHON%"=="" set "PYTHON=python"
+"%PYTHON%" -c "import torch" >nul 2>nul
+if errorlevel 1 (
+  echo 当前 Python(%PYTHON%)不能 import torch。
+  echo 请先在 Anaconda Prompt / 终端里激活训练环境后运行 ml\console.bat,或设置环境变量 PYTHON 为训练环境的 python.exe 完整路径。
+  pause & exit /b 1
+)
+"%PYTHON%" -m ml.monitor --open %*
 pause
diff --git a/ml/console.sh b/ml/console.sh
index 43975d2..142cb54 100755
--- a/ml/console.sh
+++ b/ml/console.sh
@@ -1,6 +1,11 @@
 #!/usr/bin/env bash
 # 训练控制台一键启动(Linux / WSL / Git Bash):bash ml/console.sh → 打开 http://localhost:8100 ,在网页上点「开始训练」。
-# 关闭本终端会结束控制台和正在跑的训练;要关终端继续跑,用 nohup bash ml/console.sh & 。
+# Python 选择与 console.bat 一致:PYTHON → python → python3,判据是能 import torch(审查 N-10)。
+# 训练进程独立会话运行:关掉终端只关控制台,训练继续;重新运行本脚本即可接着看、可停止。
 cd "$(dirname "$0")/.."
-PY=${PYTHON:-python}; command -v "$PY" >/dev/null 2>&1 || PY=python3
+for c in "${PYTHON:-}" python python3; do
+  [ -n "$c" ] && command -v "$c" >/dev/null 2>&1 && "$c" -c "import torch" 2>/dev/null && { PY=$c; break; }
+done
+[ -n "${PY:-}" ] || { echo "找不到能 import torch 的 Python:先激活训练环境,或设 PYTHON=..."; exit 1; }
+export PYTHON=$PY
 exec "$PY" -m ml.monitor --open "$@"
diff --git a/ml/eval/acceptance.py b/ml/eval/acceptance.py
index 41720a2..080d9de 100644
--- a/ml/eval/acceptance.py
+++ b/ml/eval/acceptance.py
@@ -7,7 +7,10 @@
   ml/runs/bench_*/log.jsonl   R10 吞吐 ≥ 5000 样本/s(≈ 60000 步 ≤ 3.5 h)
   ml/runs/m5_gate_*.json      M5 头门禁
   ml/runs/mine/r*/gate.json   R7 最后一轮左右比例 / 行程门禁
-2026-10-18 新增(审查:§9 验收表在流水线里没有执行点)。"""
+  ml/runs/r4.json             R4 浏览器推理 batch=8 P99 < 15 ms(r4 探针)
+  webapp/models/*.meta.json   被评测的模型不能是 --random 探针
+2026-10-18 新增(审查:§9 验收表在流水线里没有执行点)。
+2026-10-18 第五轮:加 R4、随机探针模型拒绝(N-5)、R8 出题不足额判不通过(N-4);R2 改为参考项(N-2:阈值与池大小耦合、未校准)。"""
 import argparse, glob, json, os, re
 import torch
 
@@ -52,6 +55,10 @@ def main():
         add('D 类停靠距离 ≤ 老师 + 5 mm 的比例', rate, rate >= 0.98, '≥ 0.98')
 
     # 2) 部署形态
+    mm = jload('webapp/models/traj_policy.meta.json')
+    add('部署模型是训练产物(非随机探针)', None if mm is None else not mm.get('random_init'), mm is not None and not mm.get('random_init'), 'meta.random_init 不为真')
+    r4 = jload('ml/runs/r4.json')
+    add('R4 浏览器推理 batch=8 P99(ms)', (r4 or {}).get('deploy', {}).get('p99') if r4 else 'missing', bool(r4 and r4.get('pass')), '< 15(随机初始化同结构模型实测)')
     pb = jload('ml/runs/policy_bench.json')
     if pb is None:
         add('部署形态评测', 'missing', False, '先跑 bash ml/pipeline.sh bench')
@@ -65,6 +72,8 @@ def main():
         plans = sum(v.get('plans', 0) for v in sets.values()); br = sum(v.get('brakes', 0) for v in sets.values())
         add('刹停发生率(刹停 / 推理)', br / plans if plans else None, (br / plans < 0.02) if plans else False, '< 0.02')
         r8 = sets.get('r8_adversarial')
+        if r8 and r8.get('requested') and r8['n'] < r8['requested']:
+            add('R8 出题数', f"{r8['n']}/{r8['requested']}", False, '出满要求题数(不足则恢复率分母缩水)')
         if r8:
             add('R8 对抗集 3 s 内恢复率', r8['recoveryRate'], r8['recoveryRate'] >= 0.95, '≥ 0.95')
             add('R8 活锁', r8['livelock'], r8['livelock'] == 0, '= 0')
@@ -73,7 +82,7 @@ def main():
 
     # 3) 数据 / 训练 / 后训练门禁
     r2 = jload('ml/data/r2.json')
-    add('R2 近邻标签 std(步长比例)', r2['label_std_mean'] if r2 else 'missing', bool(r2 and r2['pass']), '< 0.10')
+    add('R2 近邻标签 std(步长比例)', [r2.get('label_std_mean'), r2.get('label_std_norm')] if r2 else 'missing', None, '参考:< 0.10;按近邻距离归一后看 label_std_norm(见 check_r2)')
     sps = None
     for f in sorted(glob.glob('ml/runs/bench_*/log.jsonl')):
         recs = [json.loads(l) for l in open(f) if '"samples_per_s"' in l]
diff --git a/ml/eval/check_r2.py b/ml/eval/check_r2.py
index 8c88669..da8e5ec 100644
--- a/ml/eval/check_r2.py
+++ b/ml/eval/check_r2.py
@@ -3,6 +3,10 @@
 做法:特征逐维标准化;随机取 Q 个查询样本,在 P 个样本池里找最近邻(排除同一任务,避免同轨迹相邻帧冒充"近邻");
 取距离最近的 5% 近邻对,比较前 3 步(实际提交的动作)标签:std ≈ RMS(ΔY)/√2,单位为每步最大步长 STEP 的比例。
 判据:label_std < 0.10(即 < 10% 步长)。起步失败率在闭环仿真结果的 start_fail 字段(acceptance 汇总)。
+2026-10-18 第五轮(审查 N-2:判据与搜索池耦合):最近 5% 的近邻距离随池大小变,label_std 随之系统偏大/偏小。
+  改为按"近邻距离 → 标签差"做线性回归,取距离 → 0 的截距 label_std_norm 作判据(与池大小无关:池大只是近邻更近、点更多,
+  截距估计的是"同一输入下的固有标签分歧");旧口径 label_std_mean 保留作参考。pipeline 默认只警告,R2_STRICT=1 才拦。
+  自检:同一份数据 --pool 50000 与 --pool 200000 两跑,label_std_norm 应一致(差 < 0.02)。
 2026-10-18 新增(审查:R2 无执行点)。默认 5000 查询 × 50000 池,分块 256:GPU 峰值 < 0.5 GB,CPU 约 1–2 分钟;近邻对为空时输出 pass=null 而不报错。"""
 import argparse, json, math
 import torch
@@ -31,16 +35,26 @@ def main():
         v, j = D.min(1); best_d[s:s + 256] = v; best_j[s:s + 256] = j
     fin = torch.isfinite(best_d)
     if fin.sum() < 20:
-        out = {'pairs': 0, 'label_std_mean': None, 'pass': None, 'rule': 'label_std_mean < 0.10', 'note': '近邻对不足(样本太少),未判定'}
+        out = {'pairs': 0, 'label_std_mean': None, 'pass': None, 'pool': P, 'rule': 'label_std_norm < 0.10', 'note': '近邻对不足(样本太少),未判定'}
         print(json.dumps(out)); json.dump(out, open(a.out, 'w'), indent=1); return
+    # 与池无关的口径:全部有效近邻对(取距离较近的一半,排除远处非线性段)上回归 std ~ a + b·dist,截距 a
+    dd = best_d[fin]; jj = best_j[fin]; qf = torch.nonzero(fin).squeeze(1)
+    half = dd <= torch.quantile(dd, 0.5)
+    Ya, Yb = d.Y[qi][qf[half], :3].float(), d.Y[pi][jj[half], :3].float()
+    sa = (Ya - Yb).pow(2).mean(-1).mean(-1).sqrt() / math.sqrt(2)
+    A = torch.stack([torch.ones_like(dd[half]), dd[half]], 1)
+    coef = torch.linalg.lstsq(A.cpu(), sa.cpu()[:, None]).solution.squeeze(1)
+    std_norm = max(0.0, coef[0].item())
     thr = torch.quantile(best_d[fin], a.frac)
     m = fin & (best_d <= thr)
     Yq, Yp = d.Y[qi][m, :3].float(), d.Y[pi][best_j[m], :3].float()
     rms = (Yq - Yp).pow(2).mean(-1).mean(-1).sqrt()            # 每对、6 轴 × 3 步平均
     std = (rms / math.sqrt(2))
-    out = {'pairs': int(m.sum()), 'nn_dist_threshold': thr.item(), 'label_std_mean': std.mean().item(),
-           'label_std_p90': torch.quantile(std, 0.9).item(), 'pass': std.mean().item() < 0.10,
-           'rule': 'label_std_mean < 0.10(10% 步长)'}
+    out = {'pairs': int(m.sum()), 'pool': P, 'queries': Q, 'nn_dist_threshold': thr.item(),
+           'label_std_norm': std_norm, 'label_std_slope': coef[1].item(),
+           'label_std_mean': std.mean().item(), 'label_std_p90': torch.quantile(std, 0.9).item(),
+           'pass': std_norm < 0.10,
+           'rule': 'label_std_norm(近邻距离→0 的回归截距)< 0.10(10% 步长);label_std_mean 为旧口径(随池大小变),仅参考'}
     print(json.dumps(out, indent=1))
     json.dump(out, open(a.out, 'w'), indent=1)
 
diff --git a/ml/eval/r4_probe.py b/ml/eval/r4_probe.py
new file mode 100644
index 0000000..6c4f4ec
--- /dev/null
+++ b/ml/eval/r4_probe.py
@@ -0,0 +1,119 @@
+"""方案 R4 自动探针:无头 Edge / Chrome 里测浏览器推理延迟(部署形态:1 行特征 + K=8 条候选)。
+  python -m ml.eval.r4_probe --model ml/runs/r4/traj_policy_r4.onnx --out ml/runs/r4.json
+做法:本进程起一个带 COOP/COEP 头的静态服务(根目录 = 仓库),逐个配置用无头浏览器打开 webapp/r4_probe.html,
+页面把 P50/P95/P99 与 20 ms 节拍最大间隔 POST 回来。配置:
+  wasm·默认线程(与查看器同一默认 min(16, ⌊0.6×逻辑核⌋),推理在 Worker)——部署默认口径
+  wasm·4 线程(第四轮实测的旧默认,作对照)
+  wasm·默认线程·不开 Worker(看推理是否卡主线程:tickGapMax)
+  webgpu
+判据:部署口径 P99 < 15 ms 即通过;wasm 不过而 WebGPU 过,也判通过并记 deploy_ep=webgpu(查看器用 &ep=webgpu)。
+找不到浏览器 / 页面没回报 → pass=null(流水线警告继续,验收标红)。浏览器路径可用 R4_BROWSER 指定。
+2026-10-18 第五轮新增(审查第四轮缺口 3:R4 需要人开浏览器看,导致 `all` 天生不完整)。
+范围说明:这里测的是纯推理;贴柱处安全层耗时(R9)由 bench(tests/policy_bench.cjs 的 safetyMsP99)给。"""
+import argparse, http.server, json, os, shutil, socket, subprocess, sys, tempfile, threading, time, urllib.parse
+
+ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+LIMIT_MS = 15.0
+
+
+def find_browser():
+    if os.environ.get('R4_BROWSER'): return os.environ['R4_BROWSER']
+    cands = []
+    for base in (os.environ.get('ProgramFiles(x86)'), os.environ.get('ProgramFiles'), os.environ.get('LOCALAPPDATA')):
+        if base:
+            cands += [os.path.join(base, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
+                      os.path.join(base, 'Google', 'Chrome', 'Application', 'chrome.exe')]
+    for n in ('microsoft-edge', 'microsoft-edge-stable', 'google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser'):
+        p = shutil.which(n)
+        if p: cands.append(p)
+    return next((c for c in cands if c and os.path.exists(c)), None)
+
+
+class Srv:
+    def __init__(self):
+        self.result, self.ev = None, threading.Event()
+        me = self
+
+        class H(http.server.SimpleHTTPRequestHandler):
+            extensions_map = {**http.server.SimpleHTTPRequestHandler.extensions_map, '.wasm': 'application/wasm', '.mjs': 'text/javascript'}
+
+            def __init__(s, *a, **k): super().__init__(*a, directory=ROOT, **k)
+
+            def end_headers(s):
+                s.send_header('Cross-Origin-Opener-Policy', 'same-origin'); s.send_header('Cross-Origin-Embedder-Policy', 'require-corp')
+                s.send_header('Cross-Origin-Resource-Policy', 'cross-origin'); s.send_header('Cache-Control', 'no-store')
+                super().end_headers()
+
+            def do_POST(s):
+                n = int(s.headers.get('Content-Length') or 0)
+                try: me.result = json.loads(s.rfile.read(n) or b'{}')
+                except Exception as e: me.result = {'error': 'bad json: %s' % e}
+                me.ev.set(); s.send_response(200); s.end_headers(); s.wfile.write(b'ok')
+
+            def log_message(s, *a): pass
+
+        sk = socket.socket(); sk.bind(('127.0.0.1', 0)); self.port = sk.getsockname()[1]; sk.close()
+        self.httpd = http.server.ThreadingHTTPServer(('127.0.0.1', self.port), H)
+        threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
+
+
+def kill_tree(p):
+    if os.name == 'nt': subprocess.run(['taskkill', '/T', '/F', '/PID', str(p.pid)], capture_output=True)
+    else:
+        try: p.terminate(); p.wait(5)
+        except Exception: p.kill()
+
+
+def one(srv, browser, model_url, ep, threads, proxy, n, timeout_s):
+    srv.result = None; srv.ev.clear()
+    q = urllib.parse.urlencode({'model': model_url, 'ep': ep, 'threads': threads, 'proxy': proxy, 'n': n,
+                                **({'ortLocal': '1'} if os.environ.get('ORT_LOCAL') == '1' else {})})
+    url = f'http://127.0.0.1:{srv.port}/webapp/r4_probe.html?{q}'
+    prof = tempfile.mkdtemp(prefix='r4prof_')
+    args = [browser, '--headless=new', f'--user-data-dir={prof}', '--no-first-run', '--no-default-browser-check',
+            '--disable-extensions', '--enable-unsafe-webgpu', url]
+    p = subprocess.Popen(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+    got = srv.ev.wait(timeout_s)
+    kill_tree(p); shutil.rmtree(prof, ignore_errors=True)
+    r = srv.result if got else {'error': f'{timeout_s} s 内页面没有回报'}
+    r = dict(r or {}, config=f'{ep}·threads={threads}·proxy={proxy}')
+    print(json.dumps(r, ensure_ascii=False), flush=True)
+    return r
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--model', default='ml/runs/r4/traj_policy_r4.onnx'); ap.add_argument('--out', default='ml/runs/r4.json')
+    ap.add_argument('--n', type=int, default=150); ap.add_argument('--timeout', type=int, default=240)
+    a = ap.parse_args()
+    out = {'rule': f'部署口径 batch=8 P99 < {LIMIT_MS:g} ms', 'model': a.model, 'pass': None}
+    br = find_browser()
+    if not br:
+        out['note'] = '没找到 Edge / Chrome(可设 R4_BROWSER=浏览器路径)'; print(out['note'])
+    else:
+        out['browser'] = br
+        srv = Srv()
+        mu = '/' + os.path.relpath(os.path.abspath(a.model), ROOT).replace(os.sep, '/')
+        runs = [one(srv, br, mu, 'wasm', 'auto', 1, a.n, a.timeout), one(srv, br, mu, 'wasm', '4', 1, a.n, a.timeout),
+                one(srv, br, mu, 'wasm', 'auto', 0, a.n, a.timeout), one(srv, br, mu, 'webgpu', 'auto', 0, a.n, a.timeout)]
+        srv.httpd.shutdown()
+        out['runs'] = runs
+        dep, gpu = runs[0], runs[3]
+        ok = lambda r: r.get('p99') is not None and r['p99'] < LIMIT_MS
+        if dep.get('p99') is None and gpu.get('p99') is None:
+            out['note'] = '页面没有给出数字(见 runs[].error)'
+        elif ok(dep):
+            out['pass'] = True; out['deploy'] = dep; out['deploy_ep'] = 'wasm'
+        elif ok(gpu):
+            out['pass'] = True; out['deploy'] = gpu; out['deploy_ep'] = 'webgpu'
+            out['note'] = 'wasm 不达标而 WebGPU 达标:查看器部署用 &ep=webgpu'
+        else:
+            out['pass'] = False; out['deploy'] = dep if dep.get('p99') is not None else gpu
+    os.makedirs(os.path.dirname(a.out) or '.', exist_ok=True)
+    json.dump(out, open(a.out, 'w', encoding='utf-8'), indent=1, ensure_ascii=False)
+    d = out.get('deploy') or {}
+    print(f"R4:{ {True: '通过', False: '未通过', None: '未判定'}[out['pass']] }  部署口径 P99 {d.get('p99')} ms({d.get('config')})")
+
+
+if __name__ == '__main__':
+    main()
diff --git a/ml/export_onnx.py b/ml/export_onnx.py
index d50e495..79e989d 100644
--- a/ml/export_onnx.py
+++ b/ml/export_onnx.py
@@ -1,7 +1,9 @@
 """导出部署包(方案 §8.4):
   python -m ml.export_onnx --ckpt ml/runs/current.pt --out webapp/models/traj_policy.onnx [--fp16]
-输出 traj_policy.onnx(输入 feat[B,83]、z[B,16,6];输出 act[B,16,6] 归一化增量、value[B]、sub[B,13])
-与 traj_policy.meta.json(碰撞模型指纹、特征版本、维度)。导出后用 onnxruntime 与 PyTorch 对拍。"""
+输出 traj_policy.onnx(输入 feat[1,83]、z[K,16,6];输出 act[K,16,6] 归一化增量、value[K]、sub[K,13])
+与 traj_policy.meta.json(碰撞模型指纹、特征版本、维度、enc_once)。导出后用 onnxruntime 与 PyTorch 对拍。
+2026-10-18 第五轮:改为"编码一次、解码 K 次"(Policy.sample_shared),meta.enc_once=true;运行时据此只送 1 行特征。
+  --random 导出的是延迟探针(meta.random_init=true):查看器 / bench / 验收都会拒绝把它当策略用。"""
 import argparse, json, os
 import numpy as np
 import torch
@@ -16,7 +18,7 @@ class Wrap(torch.nn.Module):
         super().__init__(); self.m = m
 
     def forward(self, feat, z):
-        return self.m.sample(feat, z)
+        return self.m.sample_shared(feat, z)
 
 
 def main():
@@ -34,9 +36,9 @@ def main():
     kin = Kin()
     if ck.get('fp') and ck['fp'] != kin.fp: raise SystemExit(f"checkpoint 指纹 {ck['fp']} ≠ 当前碰撞模型 {kin.fp}:须重训")
     os.makedirs(os.path.dirname(a.out) or '.', exist_ok=True)
-    feat, z = torch.randn(8, F_DIM), torch.randn(8, H, 6)
+    feat, z = torch.randn(1, F_DIM), torch.randn(8, H, 6)
     torch.onnx.export(Wrap(m), (feat, z), a.out, input_names=['feat', 'z'], output_names=['act', 'value', 'sub'],
-                      dynamic_axes={'feat': {0: 'B'}, 'z': {0: 'B'}, 'act': {0: 'B'}, 'value': {0: 'B'}, 'sub': {0: 'B'}},
+                      dynamic_axes={'z': {0: 'K'}, 'act': {0: 'K'}, 'value': {0: 'K'}, 'sub': {0: 'K'}},
                       opset_version=a.opset, dynamo=False)
     if a.fp16:
         from onnxconverter_common import float16
@@ -46,10 +48,14 @@ def main():
     import onnxruntime as ort
     s = ort.InferenceSession(a.out, providers=['CPUExecutionProvider'])
     o = s.run(None, {'feat': feat.numpy(), 'z': z.numpy()})
-    with torch.no_grad(): t = m.sample(feat, z)
+    with torch.no_grad():
+        t = m.sample_shared(feat, z)
+        t8 = m.sample(feat.expand(8, -1), z)               # 与旧的"复制 8 行"口径逐元素一致
+    err8 = max(float((t[i] - t8[i]).abs().max()) for i in range(3))
+    assert err8 < 1e-4, f'sample_shared 与 sample 不一致:{err8}'
     err = max(float(np.abs(o[i] - t[i].numpy()).max()) for i in range(3))
     meta = {'fp': kin.fp, 'tcp0': kin.tcp0.tolist(), 'head': m.head, 'feat_ver': FEAT_VER, 'f_dim': F_DIM, 'h': H, 'step': STEP, 'fp16': a.fp16,
-            'ckpt': os.path.abspath(a.ckpt) if a.ckpt else None, 'random_init': bool(a.random), 'eval': ck.get('eval'), 'max_abs_err_vs_torch': err}
+            'ckpt': os.path.abspath(a.ckpt) if a.ckpt else None, 'random_init': bool(a.random), 'enc_once': True, 'eval': ck.get('eval'), 'max_abs_err_vs_torch': err}
     json.dump(meta, open(a.out.replace('.onnx', '.meta.json'), 'w'), indent=1)
     print(json.dumps({'onnx': a.out, 'MB': os.path.getsize(a.out) / 1e6, 'max_abs_err_vs_torch': err}))
 
diff --git a/ml/gen/gen_dataset.cjs b/ml/gen/gen_dataset.cjs
index f2e2b21..cbe9525 100644
--- a/ml/gen/gen_dataset.cjs
+++ b/ml/gen/gen_dataset.cjs
@@ -3,7 +3,9 @@
  * --consensus k:邻域共识过滤。起点不变、目标位置各扰动 5 mm 造 k 个邻居跑老师,原任务路径若与多数邻居
  *   不在同一绕行类(pathdev.sameClass),整条样本 keep=false(why=consensus)。成本约 (1+k) 倍。
  * 每行一条任务:采样 → 老师 → 距离下界认证过滤 → 局部时间律(50 Hz)→ 写出。
- * 认证不过的样本也写出(keep=false + 原因),供 stats 统计丢弃率;训练只用 keep=true。 */
+ * 认证不过的样本也写出(keep=false + 原因),供 stats 统计丢弃率;训练只用 keep=true。
+ * 2026-10-18 断点续写:第 i 条任务的随机数只由 (seed, i) 决定(sampler.reseed);输出文件已存在时保留其中完整的行、
+ *   截掉写了一半的最后一行,从下一条接着写 —— 生成几小时被中断,重跑只补剩下的,不重来、不重复。 */
 'use strict';
 const fs = require('fs'), path = require('path');
 const args = {}; for (let i = 2; i < process.argv.length; i += 2) args[process.argv[i].replace(/^--/, '')] = process.argv[i + 1];
@@ -20,10 +22,20 @@ const cert = require('./cert.cjs')(S, {});
 const { timeParam } = require('./timelaw.cjs');
 const fp = S.modelFingerprint ? S.modelFingerprint() : null;
 fs.mkdirSync(path.dirname(OUT), { recursive: true });
-const fd = fs.openSync(OUT, 'w');
+let done = 0;
+if (fs.existsSync(OUT)) {
+  const keep = [];
+  for (const l of fs.readFileSync(OUT, 'utf8').split('\n')) { if (!l) continue; try { JSON.parse(l); keep.push(l); } catch (e) { break; } }
+  done = Math.min(keep.length, NT);
+  fs.writeFileSync(OUT, keep.slice(0, done).map(l => l + '\n').join(''));
+  if (done) process.stderr.write(`[seed ${SEED}] 续写:已有 ${done}/${NT} 条,从第 ${done + 1} 条继续\n`);
+}
+const fd = fs.openSync(OUT, 'a');
+const taskSeed = i => (Math.imul(SEED, 2654435761) ^ Math.imul(i + 1, 40503) ^ 0x9e3779b9) >>> 0;
 const r3 = v => Math.round(v * 1000) / 1000;
 const t00 = performance.now();
-for (let i = 0; i < NT; i++) {
+for (let i = done; i < NT; i++) {
+  sampler.reseed(taskSeed(i));
   const task = GIVEN ? Object.assign({ cat: 'E2' }, GIVEN[i], { mode: GIVEN[i].R ? 'pose' : 'pos' }) : sampler.sample();
   const t = teach(task);
   const rec = { id: 's' + String(SEED).padStart(4, '0') + '_' + String(i).padStart(6, '0'), cat: task.cat, mode: task.mode,
@@ -53,7 +65,7 @@ for (let i = 0; i < NT; i++) {
   if (t.poseRelaxed && task.mode === 'pose') { rec.mode = 'pos'; rec.R = null; rec.demoted = true; rec.reached = t.err < 1; }
   if (rec.keep) { const tp = timeParam(t.pts); rec.traj = tp.traj.map(q => q.map(r3)); rec.exec_s = r3(tp.steps * 0.02); }
   fs.writeSync(fd, JSON.stringify(rec) + '\n');
-  if ((i + 1) % 25 === 0) process.stderr.write(`[seed ${SEED}] ${i + 1}/${NT}  ${((performance.now() - t00) / 1000 / (i + 1)).toFixed(2)} s/条\n`);
+  if ((i + 1) % 25 === 0) process.stderr.write(`[seed ${SEED}] ${i + 1}/${NT}  ${((performance.now() - t00) / 1000 / (i + 1 - done)).toFixed(2)} s/条\n`);
 }
 fs.closeSync(fd);
-process.stderr.write(`[seed ${SEED}] done ${NT} in ${((performance.now() - t00) / 1000).toFixed(0)} s\n`);
+process.stderr.write(`[seed ${SEED}] done ${NT}(本次 ${NT - done}) in ${((performance.now() - t00) / 1000).toFixed(0)} s\n`);
diff --git a/ml/gen/run_parallel.sh b/ml/gen/run_parallel.sh
index 1146078..cedcc47 100755
--- a/ml/gen/run_parallel.sh
+++ b/ml/gen/run_parallel.sh
@@ -1,11 +1,38 @@
 #!/usr/bin/env bash
 # 并行生成:bash ml/gen/run_parallel.sh <每进程条数> <进程数> [起始种子=1] ["--consensus 4"]
 # 例:8 核机器 5 万条 → bash ml/gen/run_parallel.sh 6250 8
+# 2026-10-18 第五轮(审查 N-7 / N-16 / 缺口 1):
+#   - 每个种子先写 shard_XXXX.jsonl.part,写完才改名 → 中断/并发都不会留下被截断却看似完整的 shard;
+#   - 已存在的 shard_XXXX.jsonl(写完的)直接跳过;没写完的 .part 由 gen_dataset 从断点续写(只补剩下的条数);
+#   - 逐个 wait 子进程并检查返回码(此前裸 `wait` 恒为 0,某个 node 崩了也会继续统计);
+#   - 每 60 s 往控制台打一行节拍(已写条数 + 各进程最新进度),网页「终端输出」可见。
 set -e
 N=${1:-500}; P=${2:-2}; S0=${3:-1}; EXTRA=${4:-}
 cd "$(dirname "$0")/../.."
 mkdir -p ml/data
-for ((i=0;i ml/data/log_$s.txt & done
-wait
+pids=(); seeds=()
+for ((i=0;i> ml/data/log_$s.txt && mv "$f.part" "$f" ) &
+  pids+=($!); seeds+=($s)
+done
+t0=$(date +%s); tl=$t0
+while :; do
+  alive=0; for p in "${pids[@]}"; do kill -0 $p 2>/dev/null && alive=$((alive+1)); done
+  [ $alive = 0 ] && break
+  sleep 5
+  if [ $(( $(date +%s) - tl )) -ge 60 ]; then
+    tl=$(date +%s)
+    n=$(cat ml/data/shard_*.jsonl.part 2>/dev/null | wc -l)
+    last=$(for s in "${seeds[@]}"; do tail -n1 ml/data/log_$s.txt 2>/dev/null; done | head -n 2 | tr '\n' ' ')
+    echo "[gen 节拍] $(( (tl-t0)/60 )) 分钟  运行中 $alive/${#pids[@]} 进程  本批已写 $n 条  $last"
+  fi
+done
+fail=0
+for k in "${!pids[@]}"; do
+  if ! wait ${pids[$k]}; then echo "[gen] 种子 ${seeds[$k]} 失败,最后几行:"; tail -n 5 ml/data/log_${seeds[$k]}.txt || true; fail=1; fi
+done
+[ $fail = 0 ] || exit 6
 node ml/gen/stats.cjs ml/data/shard_*.jsonl > ml/data/stats.json
 cat ml/data/stats.json
diff --git a/ml/gen/sampler.cjs b/ml/gen/sampler.cjs
index 0dbc753..a595ccd 100644
--- a/ml/gen/sampler.cjs
+++ b/ml/gen/sampler.cjs
@@ -1,5 +1,8 @@
 /* 任务采样(方案 §4.3):A 均匀 45% / B 直线被挡 30% / C 贴立柱 15% / D 目标不可达 10%;pos/pose 各半(D 只做 pos) */
 'use strict';
+/* 2026-10-18 第五轮(审查 N-16):所有拒绝采样都有次数上限,超限抛错带原因(此前 for(;;) 在 collide.json 改动后可能静默死循环)。 */
+const CAP = 200000;
+function giveUp(what, n) { throw new Error(`采样失败:${what} 连续 ${n} 次未命中——多半是 collide.json / 关节限位变了,导致该类位形几乎不存在`); }
 module.exports = function makeSampler(S, seed) {
   const L = S.LIM, O = S.ORDER, w = S.wrap180;
   let st = (seed >>> 0) || 1; const rnd = () => { st = (st * 1664525 + 1013904223) >>> 0; return st / 4294967296; };
@@ -8,19 +11,20 @@ module.exports = function makeSampler(S, seed) {
   const segOK = (a, b) => { const dd = d(a, b); const n = Math.max(1, Math.ceil(Math.max(...dd.map(Math.abs)))); for (let i = 1; i <= n; i++) if (!S.collideFree(a.map((v, k) => v + dd[k] * i / n))) return false; return true; };
   const hitsColumn = q => (S.collideDetail(q) || []).some(p => /立柱/.test(p.cnA + p.cnB));
   const nearColumn = q => { for (const k of [0, 1, 2]) for (const sg of [-4, 4]) { const p = q.slice(); p[k] += sg; if (hitsColumn(p)) return true; } return false; };
-  const freeQ = () => { for (;;) { const q = rq(); if (S.collideFree(q)) return q; } };
+  const freeQ = () => { for (let n = 0; n < CAP; n++) { const q = rq(); if (S.collideFree(q)) return q; } giveUp('无碰位形', CAP); };
   function sample() {
     const u = rnd();
     const cat = u < 0.45 ? 'A' : u < 0.75 ? 'B' : u < 0.90 ? 'C' : 'D';
     let a, b, T, R = null;
     if (cat === 'A') { a = freeQ(); b = freeQ(); }
-    else if (cat === 'B') { for (;;) { a = freeQ(); b = freeQ(); if (!segOK(a, b)) break; } }
+    else if (cat === 'B') { let n = 0; for (;; n++) { if (n >= 20000) giveUp('B 类直线被挡', n); a = freeQ(); b = freeQ(); if (!segOK(a, b)) break; } }
     else if (cat === 'C') {
-      for (;;) { b = rq(); if (!S.collideFree(b) || !nearColumn(b)) continue; a = null;
+      let n = 0;
+      for (;; n++) { if (n >= CAP) giveUp('C 类贴立柱终点', n); b = rq(); if (!S.collideFree(b) || !nearColumn(b)) continue; a = null;
         for (let t = 0; t < 200; t++) { const c = rq(); if (S.collideFree(c) && !segOK(c, b)) { a = c; break; } } if (a) break; }
     } else {
       a = freeQ();
-      if (rnd() < 0.6) { for (;;) { b = rq(); if (hitsColumn(b)) break; } T = S.fk(b, 0); }    // 目标位置落在立柱附近/内部
+      if (rnd() < 0.6) { let n = 0; for (;; n++) { if (n >= CAP) giveUp('D 类立柱内目标', n); b = rq(); if (hitsColumn(b)) break; } T = S.fk(b, 0); }    // 目标位置落在立柱附近/内部
       else { b = rq(); const p = S.fk(b, 0), o = S.fk(O.map(() => 0), 0); T = p.map((v, i) => o[i] + (v - o[i]) * 1.6); } // 工作空间外
     }
     const mode = cat === 'D' ? 'pos' : (rnd() < 0.5 ? 'pos' : 'pose');
@@ -28,5 +32,6 @@ module.exports = function makeSampler(S, seed) {
     if (mode === 'pose') R = S.orient(b);
     return { cat, mode, q0: a, T, R, qb: b };
   }
-  return { sample, rnd, segOK };
+  const reseed = v => { st = (v >>> 0) || 1; };   // 2026-10-18:每条任务独立种子,shard 中断后可从第 i 条接着生成
+  return { sample, rnd, segOK, reseed };
 };
diff --git a/ml/mining/gate.py b/ml/mining/gate.py
index 96ac404..e7900e8 100644
--- a/ml/mining/gate.py
+++ b/ml/mining/gate.py
@@ -45,9 +45,9 @@ def main():
     so_side = torch.cat([p['side'] for _, p in po]); sn_side = torch.cat([p['side'] for _, p in pn])
     det = torch.tensor([c in ('B', 'C') for c in cat])
     # 2026-10-18:左右比例按全部 B/C 题的首次 J1 方向统计,不以到达为条件(APG 把一侧绕行全做失败时,到达子集比例可纹丝不动)
-    left = lambda sd, ok: (((sd > 0) & det).sum() / det.sum().clamp(min=1)).item()
+    left = lambda sd: (((sd > 0) & det).sum() / det.sum().clamp(min=1)).item()   # side 为首次 J1 方向(closed_loop,第五轮改)
     ok_o, ok_n = torch.tensor(so, dtype=torch.bool), torch.tensor(sn, dtype=torch.bool)
-    lr_shift = abs(left(sn_side, ok_n) - left(so_side, ok_o)) * 100
+    lr_shift = abs(left(sn_side) - left(so_side)) * 100
     both = ok_o & ok_n
     tro = torch.cat([p['travel'] for _, p in po]); trn = torch.cat([p['travel'] for _, p in pn])
     travel_ratio = (trn[both].median() / tro[both].median().clamp(min=1e-6)).item() if both.any() else None
diff --git a/ml/mining/loop.sh b/ml/mining/loop.sh
index acd1628..d0f18d5 100755
--- a/ml/mining/loop.sh
+++ b/ml/mining/loop.sh
@@ -3,6 +3,8 @@
 #   bash ml/mining/loop.sh [轮数=6] [每轮 APG 步数=1000] [每轮出题数=50000]
 # 2026-10-18:APG 采样池只用本轮错题(此前累积所有历史轮,后期分布偏向老错题);老错题的保持由门禁"上轮修好仍对"负责。
 #             统一 $PYTHON;门禁加 R7(左右比例 / 行程)。
+# 2026-10-18 第五轮(审查 N-11 / 缺口 1):可续跑——已有 gate.json 的轮次直接跳过(读回 gain 与 fixed 链),
+#             中途断在某轮的 APG 时用该轮 last.pt 续跑;current.pt 只在门禁通过时由 gate.py 更新,本身就是断点。
 set -e
 cd "$(dirname "$0")/../.."
 PY=${PYTHON:-python}
@@ -13,14 +15,30 @@ mkdir -p ml/runs/mine
 SAMPLES="ml/data/samples"; FIXED=""; small=0
 for ((r=1; r<=ROUNDS; r++)); do
   D=ml/runs/mine/r$r; mkdir -p $D
-  $PY -m ml.mining.mine_round --ckpt ml/runs/current.pt --seed $((1000+r)) --n $N --out $D
+  if [ -f $D/gate.json ]; then
+    gain=$($PY -c "import json;print(json.load(open('$D/gate.json'))['gain_pp'])")
+    echo "round $r 已完成(gain ${gain} pp),跳过"; FIXED="--fixed $D/gate_fixed.jsonl"
+    [ -s ml/data/e2/shard_e2_r$r.jsonl ] && SAMPLES="ml/data/samples ml/data/samples_e2"
+    if $PY -c "import sys;sys.exit(0 if $gain < 0.5 else 1)"; then small=$((small+1)); else small=0; fi
+    if [ $small -ge 2 ]; then echo "连续 2 轮提升 < 0.5 pp,停止"; break; fi
+    continue
+  fi
+  # 2026-10-18 断点续跑:挖错做完打 mined.ok;E2 老师补路径写 .part 续写、写完改名。重挖时清掉旧的补路径,免得和新题对不上
+  E2F=ml/data/e2/shard_e2_r$r.jsonl
+  if [ -f $D/mined.ok ]; then echo "round $r 挖错已完成,跳过"
+  else
+    rm -f $E2F $E2F.part
+    $PY -m ml.mining.mine_round --ckpt ml/runs/current.pt --seed $((1000+r)) --n $N --out $D
+    touch $D/mined.ok
+  fi
   # E2(局部极小)请老师补少量路径(唯一保留的后训练老师用途)
   if [ -s $D/e2_tasks.jsonl ]; then
-    node ml/gen/gen_dataset.cjs --tasks $D/e2_tasks.jsonl --seed $((5000+r)) --out ml/data/e2/shard_e2_r$r.jsonl
+    [ -f $E2F ] || { node ml/gen/gen_dataset.cjs --tasks $D/e2_tasks.jsonl --seed $((5000+r)) --out $E2F.part && mv $E2F.part $E2F; }
     node ml/prep/build_dataset.cjs ml/data/e2/shard_e2_r$r.jsonl ml/data/samples_e2
     SAMPLES="ml/data/samples ml/data/samples_e2"
   fi
-  $PY -m ml.apg.apg_finetune --init ml/runs/current.pt --samples $SAMPLES --pool $D/pool.jsonl --out ml/runs/apg_r$r --steps $STEPS
+  res=""; [ -f ml/runs/apg_r$r/last.pt ] && res="--resume ml/runs/apg_r$r/last.pt"
+  $PY -m ml.apg.apg_finetune --init ml/runs/current.pt --samples $SAMPLES --pool $D/pool.jsonl --out ml/runs/apg_r$r --steps $STEPS $res
   $PY -m ml.mining.gate --old ml/runs/current.pt --new ml/runs/apg_r$r/best.pt --tasks ml/runs/gate_tasks.jsonl --out $D/gate.json $FIXED
   FIXED="--fixed $D/gate_fixed.jsonl"
   gain=$($PY -c "import json;print(json.load(open('$D/gate.json'))['gain_pp'])")
diff --git a/ml/mining/tasks.py b/ml/mining/tasks.py
index a3c0df4..b1fe701 100644
--- a/ml/mining/tasks.py
+++ b/ml/mining/tasks.py
@@ -7,8 +7,19 @@ import torch
 from ml.common.features import wrap180
 
 
-def min_gap_chunked(kin, q, cap=0.5, chunk=512):
-    """分块算最小间隙:随机位形每个约 2800 个幸存胶囊对、峰值约 1.5 MB/位形,一次 4 万位形会爆内存/显存(2026-10-18 实测出题 OOM),每块 512"""
+def auto_chunk(kin, mb_per_q, lo=256, hi=8192):
+    """2026-10-18 第五轮(审查 N-6):分块大小按设备空闲显存定,用掉其 1/4。
+    两处每位形峰值不同不是矛盾:kin.gaps 按 (胶囊对下限 + cap)² 筛候选对,cap=0.5 mm 时幸存约 2800 对(≈1.5 MB/位形),
+    column_gap 用 cap=40 mm,40 mm 内幸存的对多一个量级(≈20 MB/位形)。CPU 上取下限 256。"""
+    if kin.lo.device.type == 'cuda':
+        free = torch.cuda.mem_get_info(kin.lo.device)[0]
+        return int(max(lo, min(hi, free * 0.25 / (mb_per_q * 1e6))))
+    return lo
+
+
+def min_gap_chunked(kin, q, cap=0.5, chunk=None):
+    """分块算最小间隙:一次 4 万位形会爆内存/显存(2026-10-18 实测出题 OOM);块大小见 auto_chunk(≈1.5 MB/位形)"""
+    chunk = chunk or auto_chunk(kin, 1.5)
     return torch.cat([kin.gaps(q[i:i + chunk], cap)['gm'].min(-1).values for i in range(0, q.shape[0], chunk)])
 
 
@@ -33,7 +44,8 @@ def straight_blocked(kin, a, b, n=24):
     return (min_gap_chunked(kin, pts) <= 0).view(a.shape[0], n).any(-1)
 
 
-def column_gap(kin, q, chunk=256):
+def column_gap(kin, q, chunk=None):
+    chunk = chunk or auto_chunk(kin, 20.0)                                     # cap=40 → ≈20 MB/位形
     out = []
     for s in range(0, q.shape[0], chunk):                                       # 与 min_gap_chunked 一样分块防 OOM
         gm = kin.gaps(q[s:s + chunk], 40)['gm']
diff --git a/ml/model/policy.py b/ml/model/policy.py
index 21999e5..8b97afb 100644
--- a/ml/model/policy.py
+++ b/ml/model/policy.py
@@ -126,6 +126,22 @@ class Policy(nn.Module):
         x = z - self.decode(mem, z, zero, one)
         return x, v, s
 
+    def sample_shared(self, feat1, z):
+        """部署接口(ONNX,2026-10-18 第五轮,R4 实测后改):一次观测、K 条候选。
+        feat1 [1,F],z [K,16,6] → act [K,16,6],value [K],sub [K,13](value/sub 对 K 行相同,展开只为保持运行时读法)。
+        此前部署把同一观测复制 K=8 行喂 sample(),encoder 被重复算 8 次(约占总计算 29%);这里只编码一次再展开给解码器。"""
+        mem = self.encode(feat1)
+        v, s = self.heads(mem)
+        K = z.shape[0]
+        if self.head == 'wta':
+            act, _ = self.wta_modes(mem)
+            idx = torch.arange(K, device=z.device) % self.K
+            return act[0, idx] + 0.0 * z, v.expand(K), s.expand(K, -1)
+        m = mem.expand(K, -1, -1)
+        one = torch.ones(K, device=z.device, dtype=z.dtype)
+        x = z - self.decode(m, z, torch.zeros_like(one), one)
+        return x, v.expand(K), s.expand(K, -1)
+
     def sample_k(self, feat, K, z=None):
         """仿真 / APG 用:每个观测 K 条候选 → act [B,K,16,6], value [B], sub [B,13]"""
         mem = self.encode(feat)
diff --git a/ml/monitor.html b/ml/monitor.html
index c185960..250221b 100644
--- a/ml/monitor.html
+++ b/ml/monitor.html
@@ -141,6 +141,8 @@
   .form label { display: flex; flex-direction: column; gap: 5px; font-size: 12px; color: var(--dim); }
   .form input, .form select { background: #0f1520; color: var(--text); border: 1px solid var(--line2); border-radius: 8px; padding: 7px 9px; font: 13px var(--mono); }
   .form .full { grid-column: 1 / -1; }
+  .ckline { margin-top: 12px; font-size: 12px; color: var(--dim); } .ckline.bad { color: var(--bad); } .ckline .warn { color: var(--warn); }
+  .demobar { background: #fbbf2418; border: 1px solid #fbbf2455; color: var(--warn); border-radius: 10px; padding: 8px 12px; margin: 10px 0; font-size: 13px; }
   .dlg .acts { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; }
   .dlg .warnbox { margin-top: 12px; font-size: 12px; color: var(--warn); background: #fbbf2410; border: 1px solid #fbbf2433; border-radius: 8px; padding: 8px 10px; display: none; }
   .toast { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%); background: #1b2433; border: 1px solid var(--line2); padding: 9px 16px; border-radius: 10px; font-size: 13px; z-index: 50; display: none; box-shadow: 0 10px 30px #0008; }
@@ -190,16 +192,16 @@