第五轮:锁/续跑/超时/自动分支/R4 探针与 encode-once/控制台加固;阶段内断点续跑(gen 按条、prep 按 shard、训练与 APG 定时原子存档+体面停止);分歧以实现方意见为准

This commit is contained in:
Hark
2026-10-17 18:57:48 +00:00
parent e3bb78388d
commit a5e779c515
36 changed files with 1199 additions and 285 deletions
+3
View File
@@ -43,3 +43,6 @@ webapp/generated/
# 策略模型产物(由 python -m ml.export_onnx 生成,20 MB 级,不入库) # 策略模型产物(由 python -m ml.export_onnx 生成,20 MB 级,不入库)
webapp/models/*.onnx webapp/models/*.onnx
webapp/models/*.meta.json webapp/models/*.meta.json
# 从头重跑时旧产物挪到这里(不入库)
ml/archive/
+100
View File
@@ -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 周期的两半,必须一起测。 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 因此仍无数据。 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` 的默认输出同名同目录,只差文件名后缀。 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 步继续」。
+22 -10
View File
@@ -519,9 +519,11 @@ MeanFlow
</tr> </tr>
<tr class="odd"> <tr class="odd">
<td>推理后端</td> <td>推理后端</td>
<td>onnxruntime-web wasm-SIMD 多线程</td> <td>onnxruntime-web wasm-SIMD 多线程,推理放代理
Worker(不占主线程),线程数 min(16, ⌊0.6×逻辑核⌋);R4 探针同测
WebGPU,wasm 不达标而 WebGPU 达标则部署用 WebGPU</td>
<td>C</td> <td>C</td>
<td>小模型下 WebGPU 调度开销占主导</td> <td>小模型下 WebGPU 调度开销通常占主导,以 R4 实测为准</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -882,7 +884,9 @@ WTA 谁更好</strong>;因此 WTA 只作为 M5
</tr> </tr>
<tr class="odd"> <tr class="odd">
<td>部署推理</td> <td>部署推理</td>
<td>浏览器 onnxruntime-web wasm-SIMD 多线程;后续真机控制器</td> <td>浏览器 onnxruntime-web wasm-SIMD 多线程(代理 Worker);ONNX 接口
feat[1,83]、z[K,16,6]:观测编码一次、K=8
条候选共享编码再解码;后续真机控制器</td>
<td>与现有查看器同页</td> <td>与现有查看器同页</td>
</tr> </tr>
</tbody> </tbody>
@@ -1515,10 +1519,15 @@ held-out 闭环仿真(开仿真安全层)测量,由
<tr class="even"> <tr class="even">
<td>R4</td> <td>R4</td>
<td>浏览器推理延迟</td> <td>浏览器推理延迟</td>
<td>训练前用随机初始化模型导出 ONNX(延迟与权重无关),wasm-SIMD <td>训练前用随机初始化模型导出
多线程测 batch=8 P99 &lt; 15 ms</td> ONNX(延迟与权重无关),<code>pipeline.sh r4</code>
<td>减小模型(d=128,约 1.8M 参数);同时测 WebGPU,更快则换</td> 无头浏览器自动测部署口径 K=8 P99 &lt; 15 ms(2026-10-18 本机实测旧接口 4
<td><strong>M1 前(最大不确定项,训练前结掉)</strong></td> 线程 37.0 ms、16 线程 19.5 ms,均不达标)</td>
<td>先做三项不改模型的改动:编码一次(沙箱 CPU 实测省约 20%)、默认线程
16、推理移出主线程;仍不达标则 WebGPU 达标即用 WebGPU,否则
d=128(<code>configs/small.json</code>,约 1.8M 参数)。K=8 不降</td>
<td><strong>M1
前(最大不确定项,训练前结掉;不过流程自动停)</strong></td>
</tr> </tr>
<tr class="odd"> <tr class="odd">
<td>R5</td> <td>R5</td>
@@ -1596,20 +1605,23 @@ a_max、插补频率与提交步数。</li>
</ol> </ol>
<hr /> <hr />
<h2 id="11-目录规划">11. 目录规划</h2> <h2 id="11-目录规划">11. 目录规划</h2>
<p>运行步骤、命令与耗时见 <code>ml/README.md</code>。</p> <p>运行步骤、命令与耗时见
<code>ml/README.md</code>。所有长步骤都能断点续跑,意外中断最多损失几分钟:生成按条续写(每条任务的随机数只由种子和序号决定),打包按
shard 跳过,预训练与 APG 每 10 分钟原子存一次
<code>last.pt</code>,停止时先存档再退出。</p>
<pre><code>ml/ <pre><code>ml/
pipeline.sh 阶段脚本:setup / kin / gate1 / gen / prep / train / m5 / apg / mine / export / bench 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 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 prep/ export_kin.cjs → assets/kin.json;build_dataset.cjs → data/samples/*.npy;run_prep.sh
data/ (不入库)shard_*.jsonl stats.json consistency.json samples/ 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 model/ policy.py(MeanFlow 头;WTA K=8 回退头) meanflow.py
data.py train.py 样本加载、预训练 data.py train.py 样本加载、预训练
sim/closed_loop.py 闭环仿真(验证、挖错、门禁共用) sim/closed_loop.py 闭环仿真(验证、挖错、门禁共用)
apg/apg_finetune.py APG 可微微调 apg/apg_finetune.py APG 可微微调
mining/ tasks.py mine_round.py gate.py loop.sh mining/ tasks.py mine_round.py gate.py loop.sh
eval/ dump_parity.cjs check_parity.py(JS ↔ PyTorch 对拍) 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 export_onnx.py serve.py README.md
webapp/js/ik-policy.js 运行时:特征、距离下界认证、刹停、投影、精修、Runner(浏览器与 Node 共用) webapp/js/ik-policy.js 运行时:特征、距离下界认证、刹停、投影、精修、Runner(浏览器与 Node 共用)
webapp/js/ik-policy-glue.js 查看器接入(?policy=1) webapp/js/ik-policy-glue.js 查看器接入(?policy=1)
+6 -6
View File
@@ -39,7 +39,7 @@
| 安全层 | 距离下界认证 + 可刹停不变式 | A | Schwarzer 2005 连续认证;Thumm & Althoff 2022 failsafe | | 安全层 | 距离下界认证 + 可刹停不变式 | A | Schwarzer 2005 连续认证;Thumm & Althoff 2022 failsafe |
| 30/30 手段 | 子目标头、价值头、冷启动/卡死时短前瞻、卡死换向 | B / C | AWE、SHAC、BID | | 30/30 手段 | 子目标头、价值头、冷启动/卡死时短前瞻、卡死换向 | B / C | AWE、SHAC、BID |
| 老师 | PRM 路网 A* + 捷径平滑(确定性);A* 失败才用 `solveDyn` | B | MπNets:老师不一致碰撞率 13.78%,一致老师 0.94% | | 老师 | 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 取舍决定(每项只有一个答案) ### 1.1 取舍决定(每项只有一个答案)
@@ -130,7 +130,7 @@
|---|---|---| |---|---|---|
| 数据生成 | Hark 沙箱(2 vCPU / 7 GB,Node)或用户机器 | 纯 JS,无需 GPU | | 数据生成 | Hark 沙箱(2 vCPU / 7 GB,Node)或用户机器 | 纯 JS,无需 GPU |
| 预训练 + 后训练 | 用户 RTX 3090 24 GB | batch 1024–2048 | | 预训练 + 后训练 | 用户 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 ```bash
conda create -n armtf python=3.11 -y && conda activate armtf conda create -n armtf python=3.11 -y && conda activate armtf
@@ -444,7 +444,7 @@ for 每个 batch(随机采样 q0 和目标,不需要老师):
| R1 | 老师在相邻任务上左右绕行跳变,标签自相矛盾 | 起终点扰动 ±5 mm 的邻居任务同伦类一致率 ≥ 90% | 对 A* 边代价加左右偏好项,强制同区域选同侧 | M1 | | R1 | 老师在相邻任务上左右绕行跳变,标签自相矛盾 | 起终点扰动 ±5 mm 的邻居任务同伦类一致率 ≥ 90% | 对 A* 边代价加左右偏好项,强制同区域选同侧 | M1 |
| R2 | 时间律仍有不可观测成分,闭环起不了步 | 近邻样本标签方差 < 10% 步长(`ml/eval/check_r2.py`);起步失败率 = 0(闭环 `start_fail`) | 进一步把剩余路程估计加入输入 | M1 / M5 | | 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 | | 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 | | R5 | MeanFlow 头在 6 轴、位姿目标下穿柱或训练不稳 | near_column 穿柱率(关安全层)≤ 2%;损失不发散 | 同骨干换 WTA K=8 头 | M5 |
| R6 | 位姿目标的多 IK 分支带来额外多峰 | 不同肘/腕分支子集到达率 ≥ 主集 − 2 个百分点 | 加目标分支标志 | M5 | | R6 | 位姿目标的多 IK 分支带来额外多峰 | 不同肘/腕分支子集到达率 ≥ 主集 − 2 个百分点 | 加目标分支标志 | M5 |
| R7 | APG 压缩多模态或拉长路径(ELMP 中路径 +23%) | 每轮门禁:B/C 类左右绕行比例变化 ≤ 5 个百分点、行程中位数比 ≤ 1.05(`ml/mining/gate.py`) | 加大 BC 锚定权重;行程项 | M6 | | R7 | APG 压缩多模态或拉长路径(ELMP 中路径 +23%) | 每轮门禁:B/C 类左右绕行比例变化 ≤ 5 个百分点、行程中位数比 ≤ 1.05(`ml/mining/gate.py`) | 加大 BC 锚定权重;行程项 | M6 |
@@ -469,7 +469,7 @@ for 每个 batch(随机采样 q0 和目标,不需要老师):
## 11. 目录规划 ## 11. 目录规划
运行步骤、命令与耗时见 `ml/README.md`。 运行步骤、命令与耗时见 `ml/README.md`。所有长步骤都能断点续跑,意外中断最多损失几分钟:生成按条续写(每条任务的随机数只由种子和序号决定),打包按 shard 跳过,预训练与 APG 每 10 分钟原子存一次 `last.pt`,停止时先存档再退出。
``` ```
ml/ ml/
@@ -477,14 +477,14 @@ ml/
gen/ sampler / teacher(确定性 PRM A*)/ timelaw / gen_dataset / consistency / stats / run_parallel.sh 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 prep/ export_kin.cjs → assets/kin.json;build_dataset.cjs → data/samples/*.npy;run_prep.sh
data/ (不入库)shard_*.jsonl stats.json consistency.json samples/ 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 model/ policy.py(MeanFlow 头;WTA K=8 回退头) meanflow.py
data.py train.py 样本加载、预训练 data.py train.py 样本加载、预训练
sim/closed_loop.py 闭环仿真(验证、挖错、门禁共用) sim/closed_loop.py 闭环仿真(验证、挖错、门禁共用)
apg/apg_finetune.py APG 可微微调 apg/apg_finetune.py APG 可微微调
mining/ tasks.py mine_round.py gate.py loop.sh mining/ tasks.py mine_round.py gate.py loop.sh
eval/ dump_parity.cjs check_parity.py(JS ↔ PyTorch 对拍) 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 export_onnx.py serve.py README.md
webapp/js/ik-policy.js 运行时:特征、距离下界认证、刹停、投影、精修、Runner(浏览器与 Node 共用) webapp/js/ik-policy.js 运行时:特征、距离下界认证、刹停、投影、精修、Runner(浏览器与 Node 共用)
webapp/js/ik-policy-glue.js 查看器接入(?policy=1) webapp/js/ik-policy-glue.js 查看器接入(?policy=1)
+54 -36
View File
@@ -3,14 +3,24 @@
目标:在你的机器上从零跑出 Transformer 避障策略,并在查看器里直接用上。 目标:在你的机器上从零跑出 Transformer 避障策略,并在查看器里直接用上。
方案依据见 `docs/Transformer轨迹拟合训练方案.md`。所有命令在**仓库根目录**执行。 方案依据见 `docs/Transformer轨迹拟合训练方案.md`。所有命令在**仓库根目录**执行。
**一键运行**:Windows 双击 `ml/console.bat`(Linux/WSL 运行 `bash ml/console.sh`),浏览器自动打开训练控制台 `http://localhost:8100/`,点「开始训练」选好起止阶段即从对拍一路跑到验收,全程进度、曲线、终端输出都在网页上,可随时停止、从停下的那一步继续(见「训练控制台」)。 **一键运行(无人值守)**:先激活训练用的 Python 环境,Windows 双击 `ml/console.bat`(Linux/WSL 运行 `bash ml/console.sh`),浏览器自动打开训练控制台 `http://localhost:8100/`,点「开始训练」即从对拍一路跑到验收。等价的终端命令是 `bash ml/pipeline.sh all`。
也可以在终端按阶段执行 `bash ml/pipeline.sh <阶段>`,或 `bash ml/pipeline.sh all` 一次跑完;每一阶段完成后看本手册对应小节的「检查」。R2(第 5 步)或 M5(第 7 步)不通过时一键流程会停下,按对应小节处理。 - **断了再点一次就是续跑**(断电、蓝屏、误关、手动停止都一样),损失不超过几分钟:
- 已成功的阶段自动跳过;
- 生成:每条任务的随机数只由(种子, 序号)决定,没写完的 `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) | 产出 | | 阶段 | 命令 | 预计耗时(28 核 CPU + RTX 3090) | 产出 |
|---|---|---|---| |---|---|---|---|
| 1 环境 | `bash ml/pipeline.sh setup` | 10 分钟 | 依赖 | | 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 对拍 | `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` | | 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` | | 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` | | 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` | — | — | | 13 使用 | `python ml/serve.py` → 浏览器 `?policy=1` | — | — |
第 4/5/6 步耗时为 RTX 3090 + 28 核实测换算;标「估算」「未实测」的不要当预算,以每步首次输出的速度为准。 第 4/5/6 步耗时为 RTX 3090 + 28 核实测换算;标「估算」「未实测」的不要当预算,以每步首次输出的速度为准。
第 1 步(安装依赖)和第 2′ 步(R4,需要在浏览器里看延迟)建议先手动做;一键流程默认从第 2 步开始。 第 1 步(安装依赖)建议先手动做;一键流程默认从第 2 步开始,含 2′。
--- ---
> 分歧规则:审查方与实现方意见讨论后仍不一致时,以实现方(Hark)的意见为准先落地;下一轮用实测数据说话(详见 docs/ML运行手册审查.md 第五轮第〇节)。
## 1. 环境 ## 1. 环境
- Linux / WSL2,或 **Windows 原生 + Git Bash**(实测第 2/4/5/6 步与闭环仿真可跑);NVIDIA 驱动 + CUDA(`nvidia-smi` 能看到 3090) - 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 一致)。 检查:最后打印的 JSON 里 `"PASS": true`(fk、姿态、组对间隙、83 维特征、间隙梯度与 JS 一致)。
`docs/collide.json` 以后只要重新拟合或重生成 `pair2d`/`roadmap`,这一步、数据、模型都要重来(指纹不符时各脚本会拒绝运行)。 `docs/collide.json` 以后只要重新拟合或重生成 `pair2d`/`roadmap`,这一步、数据、模型都要重来(指纹不符时各脚本会拒绝运行)。
**R4 延迟摸底(训练前做)**:推理延迟只取决于模型结构,与权重无关,所以用随机初始化模型先测,不过就在训练前改结构,不必等训完。 **R4 延迟(训练前做,自动)**:推理延迟只取决于模型结构,与权重无关,所以用随机初始化模型先测,不过就在训练前改结构,不必等训完。
```bash ```bash
bash ml/pipeline.sh r4 # 随机初始化 4.92 M 模型导出 fp32 ONNX 到 webapp/models/ bash ml/pipeline.sh r4 # 随机初始化模型导出到 ml/runs/r4/traj_policy_r4.onnx(不碰 webapp/models/),无头浏览器自动测 → ml/runs/r4.json
python ml/serve.py # 浏览器打开 http://localhost:8000/?policy=1 ,来回拖动黄球约 1 分钟
``` ```
控制台运行 `__POLICY.latency()`:推理 batch=8 P99 < 15 ms、安全层 P99 < 15 ms 为通过(随机权重的动作是乱的,手臂会乱动或频繁刹停,这一步只看延迟)。 部署形态:模型每次只编码 1 行观测、解码 K=8 条候选(`meta.enc_once`);wasm 线程默认 `min(16, ⌊0.6×逻辑核⌋)`,推理放在 onnxruntime 的代理 Worker 里不占主线程。
不通过:把 `ml/configs/base.json` 的 `model.d` 从 192 改为 128(`ff` 改 512)后重跑 `r4`;仍不通过再加 `&ep=webgpu` 对比。把输出发我。 探针测 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. 老师一致性门禁 ## 3. 老师一致性门禁
@@ -97,7 +112,9 @@ bash ml/pipeline.sh prep
每条轨迹按 50 Hz 时间律重放,每 3 步取一个样本(特征与部署同一份 JS 代码计算),30% 样本加 σ = 1° 噪声增广(加噪后间隙 < 3 mm 的不用)。 每条轨迹按 50 Hz 时间律重放,每 3 步取一个样本(特征与部署同一份 JS 代码计算),30% 样本加 σ = 1° 噪声增广(加噪后间隙 < 3 mm 的不用)。
认证最小间隙 < 3 mm 的老师轨迹不进训练集(`MIN_GAP_MM=3`,与 `stats.json` 的 `gapDrop` 同一口径):模型只学离障碍 ≥ 3 mm 的走法,运行时投影护栏仍保持 5 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)。 检查 `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. 预训练 ## 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 - **通过线(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) - 训练中每 2000 步打印一行 `EVAL {...}`:val 任务闭环(部署节奏、不开安全层)`success`、`collision_rate`、`time_ok_1p3`、`start_fail`。时限 = 老师时长 × 2(至少 3 s)
- 显存不够把 `batch` 改成 512。中断后续训(日志追加;新训练会覆盖旧日志): - 显存不够把 `batch` 改成 512。中断后再跑 `bash ml/pipeline.sh train`(或 `all`)会自动从 `ml/runs/<头>/last.pt` 续训(每 10 分钟存一次),测速那 2000 步做过就跳过。训练发散时不会用发散的权重覆盖 `last.pt` / `best.pt`。
```bash
python -m ml.train --samples ml/data/samples --out ml/runs/base --resume ml/runs/base/last.pt
```
## 7. M5 门禁:选定动作头 ## 7. M5 门禁:选定动作头
@@ -128,12 +141,8 @@ bash ml/pipeline.sh m5
三种口径的关系:M5 是纯策略能力;仿真安全层是近似;**部署能不能过只看第 11 步**(与浏览器同一份运行时,含投影、距离认证、刹停、预算、换向锁)。三者互不替代。 三种口径的关系:M5 是纯策略能力;仿真安全层是近似;**部署能不能过只看第 11 步**(与浏览器同一份运行时,含投影、距离认证、刹停、预算、换向锁)。三者互不替代。
不通过(或训练日志出现「训练发散」)则换 WTA K=8 头。WTA 的产物在独立目录,不会覆盖 MeanFlow: 不通过时**流程内自动**改训 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`。
```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
```
需要强制指定后续阶段的起点模型时用 `CKPT=路径 bash ml/pipeline.sh apg`。 需要强制指定后续阶段的起点模型时用 `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` | | 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` | | 6 | `ml/runs/bench_base/log.jsonl`(测速)、`ml/runs/base/log.jsonl` |
| 7 | `ml/runs/m5_gate_*.json` | | 7 | `ml/runs/m5_gate_*.json` |
| 8–9 | `ml/runs/apg/log.jsonl`、`ml/runs/mine/r*/report.json` 与 `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`(控制台启动时)或终端输出 | | 任一步出错 | `ml/runs/pipeline.json`、`ml/runs/console.log`(控制台启动时)或终端输出 |
## 训练控制台 ## 训练控制台
```text ```text
Windows:双击 ml/console.bat Linux / WSL:bash ml/console.sh 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 秒刷新。 浏览器自动打开 `http://localhost:8100/`,零第三方依赖、不连外网,每 5 秒刷新。
**开始**:点右上角「▶ 开始训练」→ 选起始/结束阶段(默认从第一个还没成功完成的阶段到「验收汇总」)、动作头、共识过滤、任务数、进程数、目标样本数、挖错轮数 → 「开始」。控制台在后台执行 `bash ml/pipeline.sh all`(`FROM`/`TO` 即起止阶段),用的是启动控制台的同一个 Python。 **自检**:控制台启动时检查训练用 Python 能否 `import torch`、GPU、node、bash,结果显示在「全流程」卡片底部;不过则「开始训练」不可点。
**停止**:「■ 停止」结束整个进程树,这一步记为「已停止」,已完成的步骤保留;再点「开始」默认从这一步继续。 **开始**:点「▶ 开始训练」→ 运行方式(续跑 / 从头重跑)、动作头、共识过滤、任务数、进程数、目标样本数、挖错轮数、`MIN_GAP_MM` → 「开始」。控制台在后台执行 `bash ml/pipeline.sh all`。
**网页可以随时关**,训练不受影响,重新打开即可接着看;**关闭控制台窗口会结束训练**,要关终端继续跑,Linux 用 `nohup bash ml/console.sh &`。 - 续跑:跳过已成功的阶段。改了参数时只作废受影响的阶段(改生成参数 → 从生成起重跑;改动作头 → R4 与训练起重跑;改挖错参数 → 从挖错起重跑),旧产物移到 `ml/archive/<时间>/`,不删除。
终端里直接跑的 `bash ml/pipeline.sh …` 同样会显示进度(只是不能在网页上停止);控制台检测到终端里有阶段在跑时不会重复开始。 - 从头重跑(`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` | | GPU | 利用率、显存、温度、功耗 | `nvidia-smi` |
| 当前运行 | 正在跑的训练(自动跟随;顶部下拉框可切换 `ml/runs` 下任一次运行):步数、已用/剩余时间、预计完成时刻、评估标记 | `ml/runs/*/log.jsonl` | | 当前运行 | 正在跑的训练(自动跟随;顶部下拉框可切换 `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` | | 各阶段结果 | 数据统计(含 `gapDrop`)、R2、M5 两种口径、挖错各轮与门禁、部署评测、验收逐项 | `ml/data/*.json`、`ml/runs/*.json` |
| 终端输出 | 控制台启动的流水线的命令行输出,报错标红 | `ml/runs/console.log` | | 终端输出 | 控制台启动的流水线的命令行输出,报错标红 | `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` 放开)。 同一局域网的手机或其他电脑要看:`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 步并重训。 - **指纹不符**:`collide.json` 变了。依次重跑第 2、4、5 步并重训。
- **训练发散**(loss 出现 nan/inf,自动停在 `diverged.pt`):先把 `lr` 降到 5e-4 重试一次,仍发散就走 WTA 回退(第 7 步)。 - **训练发散**(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,不拦后续步骤。 - **小样本摸底**:几百条任务时 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 头)。 - **浏览器只有 1 线程**:没走 `ml/serve.py`(缺 COOP/COEP 头)。
- **查看器徽标「策略模式不可用」**:看控制台报错,多为模型文件缺失、指纹或 TCP 不符。 - **查看器徽标「策略模式不可用」**:看控制台报错,多为模型文件缺失、指纹或 TCP 不符。
@@ -265,7 +282,7 @@ ml/
pipeline.sh 一条龙阶段脚本(环境变量见文件头);每阶段开始/结束经 pstate.py 写 runs/pipeline.json pipeline.sh 一条龙阶段脚本(环境变量见文件头);每阶段开始/结束经 pstate.py 写 runs/pipeline.json
console.bat / console.sh 训练控制台一键启动(Windows / Linux) console.bat / console.sh 训练控制台一键启动(Windows / Linux)
monitor.py monitor.html 训练控制台:本地 HTTP 服务(开始/停止/进度/日志接口)+ 零依赖网页(地址带 ?demo=1 看示例数据) 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)/ gen/ 老师数据:sampler / teacher(确定性 PRM A*)/ cert.cjs(距离下界认证,实现在 ik-policy.js)/
pathdev.cjs(同伦类判定,共识过滤与一致性门禁用)/ timelaw / gen_dataset / consistency / stats / run_parallel.sh 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 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 部署节奏闭环仿真(验证 / 挖错 / 门禁) sim/closed_loop.py 部署节奏闭环仿真(验证 / 挖错 / 门禁)
apg/apg_finetune.py APG 可微微调 apg/apg_finetune.py APG 可微微调
mining/ tasks.py(无老师出题)、mine_round.py、gate.py(含 R7)、loop.sh 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 双模态对照实验(动作头选型依据) toy/toy_bimodal.py 2D 双模态对照实验(动作头选型依据)
export_onnx.py serve.py 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.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) tests/policy_bench.cjs 部署形态评测(nc / route / r8 / nc1000)
``` ```
+28 -9
View File
@@ -7,7 +7,7 @@
"到达"仍是每 seg_plans 段末的距离项(无到达即停),属有意设计:段末项给出持续的接近梯度。 "到达"仍是每 seg_plans 段末的距离项(无到达即停),属有意设计:段末项给出持续的接近梯度。
margin_train 3 mm:与训练数据过滤线一致(认证最小间隙 < 3 mm 的老师轨迹不进训练集),碰撞项不与 BC 标签对抗; margin_train 3 mm:与训练数据过滤线一致(认证最小间隙 < 3 mm 的老师轨迹不进训练集),碰撞项不与 BC 标签对抗;
运行时投影护栏独立保持 5 mm。端点与中点两个铰链取均值,与只查端点同量纲,w_col 无需重标。""" 运行时投影护栏独立保持 5 mm。端点与中点两个铰链取均值,与只查端点同量纲,w_col 无需重标。"""
import argparse, json, math, os, random, time import argparse, json, math, os, random, sys, time
import torch import torch
from ml.common.kin_torch import Kin 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.policy import build, wta_loss
from ml.model.meanflow import meanflow_loss from ml.model.meanflow import meanflow_loss
from ml.sim.closed_loop import evaluate, tasks_to_tensors, goal_err from ml.sim.closed_loop import evaluate, tasks_to_tensors, goal_err
from ml.common.ckpt import Saver, atomic_save
def load_pool(paths): 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('--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('--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('--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='从 <out>/last.pt 续跑(2026-10-18 第五轮,审查 N-11:几小时的 APG 断了不再从头来)')
a = ap.parse_args() a = ap.parse_args()
cfg = json.load(open(a.config)); C = cfg['apg'] cfg = json.load(open(a.config)); C = cfg['apg']
if a.steps: C['steps'] = a.steps 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) 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' amp = dev.startswith('cuda') and C.get('amp') == 'bf16'
step = torch.tensor(STEP, device=dev) step = torch.tensor(STEP, device=dev)
log = open(os.path.join(a.out, 'log.jsonl'), 'w') it0 = 0
base, _ = evaluate(model.eval(), kin, val_tasks, dev) if a.resume and os.path.exists(a.resume):
print('EVAL0', json.dumps(base), flush=True); log.write(json.dumps({'eval': base, 'step': 0}) + '\n') rk = torch.load(a.resume, map_location=dev); model.load_state_dict(rk['model'])
best = base['success'] if 'opt' in rk: opt.load_state_dict(rk['opt'])
torch.save({'model': model.state_dict(), 'cfg': mcfg, 'step': 0, 'fp': kin.fp, 'eval': base}, os.path.join(a.out, 'best.pt')) 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() 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() model.train()
batch = random.choices(train_tasks, weights=w, k=C['batch']) batch = random.choices(train_tasks, weights=w, k=C['batch'])
q0, T, R, ex, tr = tasks_to_tensors(batch, dev) 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} 'gnorm': gn.item(), **stats, 'min': (time.time() - t0) / 60}
if it % 10 == 0: print(json.dumps(rec), flush=True) if it % 10 == 0: print(json.dumps(rec), flush=True)
log.write(json.dumps(rec) + '\n'); log.flush() 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: if (it + 1) % C['eval_every'] == 0 or it == C['steps'] - 1:
res, _ = evaluate(model.eval(), kin, val_tasks, dev) res, _ = evaluate(model.eval(), kin, val_tasks, dev)
res['it'] = it + 1; res['t'] = time.time() res['it'] = it + 1; res['t'] = time.time()
print('EVAL', json.dumps(res), flush=True); log.write(json.dumps({'eval': res}) + '\n'); log.flush() 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} 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: 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) print(f'done, best success {best:.4f}', flush=True)
+44
View File
@@ -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()
+1 -1
View File
@@ -5,6 +5,6 @@
"margin_train_mm": 3.0, "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, "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}, "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
} }
} }
+1 -1
View File
@@ -5,6 +5,6 @@
"amp": "bf16", "grad_clip": 1.0, "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, "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, "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
} }
} }
+10
View File
@@ -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
}
}
+1 -1
View File
@@ -5,6 +5,6 @@
"amp": "bf16", "grad_clip": 1.0, "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, "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, "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
} }
} }
+12 -3
View File
@@ -1,8 +1,17 @@
@echo off @echo off
rem 训练控制台一键启动(Windows):双击本文件 → 自动打开 http://localhost:8100 ,在网页上点「开始训练」。 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 chcp 65001 >nul
cd /d "%~dp0\.." cd /d "%~dp0\.."
where python >nul 2>nul || (echo 找不到 python,请先激活训练用的 Python 环境 & pause & exit /b 1) if "%PYTHON%"=="" set "PYTHON=python"
python -m ml.monitor --open %* "%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 pause
+7 -2
View File
@@ -1,6 +1,11 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# 训练控制台一键启动(Linux / WSL / Git Bash):bash ml/console.sh → 打开 http://localhost:8100 ,在网页上点「开始训练」。 # 训练控制台一键启动(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")/.." 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 "$@" exec "$PY" -m ml.monitor --open "$@"
+11 -2
View File
@@ -7,7 +7,10 @@
ml/runs/bench_*/log.jsonl R10 吞吐 ≥ 5000 样本/s(≈ 60000 步 ≤ 3.5 h) ml/runs/bench_*/log.jsonl R10 吞吐 ≥ 5000 样本/s(≈ 60000 步 ≤ 3.5 h)
ml/runs/m5_gate_*.json M5 头门禁 ml/runs/m5_gate_*.json M5 头门禁
ml/runs/mine/r*/gate.json R7 最后一轮左右比例 / 行程门禁 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 argparse, glob, json, os, re
import torch import torch
@@ -52,6 +55,10 @@ def main():
add('D 类停靠距离 ≤ 老师 + 5 mm 的比例', rate, rate >= 0.98, '≥ 0.98') add('D 类停靠距离 ≤ 老师 + 5 mm 的比例', rate, rate >= 0.98, '≥ 0.98')
# 2) 部署形态 # 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') pb = jload('ml/runs/policy_bench.json')
if pb is None: if pb is None:
add('部署形态评测', 'missing', False, '先跑 bash ml/pipeline.sh bench') 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()) 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') add('刹停发生率(刹停 / 推理)', br / plans if plans else None, (br / plans < 0.02) if plans else False, '< 0.02')
r8 = sets.get('r8_adversarial') 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: if r8:
add('R8 对抗集 3 s 内恢复率', r8['recoveryRate'], r8['recoveryRate'] >= 0.95, '≥ 0.95') add('R8 对抗集 3 s 内恢复率', r8['recoveryRate'], r8['recoveryRate'] >= 0.95, '≥ 0.95')
add('R8 活锁', r8['livelock'], r8['livelock'] == 0, '= 0') add('R8 活锁', r8['livelock'], r8['livelock'] == 0, '= 0')
@@ -73,7 +82,7 @@ def main():
# 3) 数据 / 训练 / 后训练门禁 # 3) 数据 / 训练 / 后训练门禁
r2 = jload('ml/data/r2.json') 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 sps = None
for f in sorted(glob.glob('ml/runs/bench_*/log.jsonl')): 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] recs = [json.loads(l) for l in open(f) if '"samples_per_s"' in l]
+18 -4
View File
@@ -3,6 +3,10 @@
做法:特征逐维标准化;随机取 Q 个查询样本,在 P 个样本池里找最近邻(排除同一任务,避免同轨迹相邻帧冒充"近邻"); 做法:特征逐维标准化;随机取 Q 个查询样本,在 P 个样本池里找最近邻(排除同一任务,避免同轨迹相邻帧冒充"近邻");
取距离最近的 5% 近邻对,比较前 3 步(实际提交的动作)标签:std ≈ RMS(ΔY)/√2,单位为每步最大步长 STEP 的比例。 取距离最近的 5% 近邻对,比较前 3 步(实际提交的动作)标签:std ≈ RMS(ΔY)/√2,单位为每步最大步长 STEP 的比例。
判据:label_std < 0.10(即 < 10% 步长)。起步失败率在闭环仿真结果的 start_fail 字段(acceptance 汇总)。 判据: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 而不报错。""" 2026-10-18 新增(审查:R2 无执行点)。默认 5000 查询 × 50000 池,分块 256:GPU 峰值 < 0.5 GB,CPU 约 1–2 分钟;近邻对为空时输出 pass=null 而不报错。"""
import argparse, json, math import argparse, json, math
import torch 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 v, j = D.min(1); best_d[s:s + 256] = v; best_j[s:s + 256] = j
fin = torch.isfinite(best_d) fin = torch.isfinite(best_d)
if fin.sum() < 20: 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 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) thr = torch.quantile(best_d[fin], a.frac)
m = fin & (best_d <= thr) m = fin & (best_d <= thr)
Yq, Yp = d.Y[qi][m, :3].float(), d.Y[pi][best_j[m], :3].float() 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 步平均 rms = (Yq - Yp).pow(2).mean(-1).mean(-1).sqrt() # 每对、6 轴 × 3 步平均
std = (rms / math.sqrt(2)) std = (rms / math.sqrt(2))
out = {'pairs': int(m.sum()), 'nn_dist_threshold': thr.item(), 'label_std_mean': std.mean().item(), out = {'pairs': int(m.sum()), 'pool': P, 'queries': Q, 'nn_dist_threshold': thr.item(),
'label_std_p90': torch.quantile(std, 0.9).item(), 'pass': std.mean().item() < 0.10, 'label_std_norm': std_norm, 'label_std_slope': coef[1].item(),
'rule': 'label_std_mean < 0.10(10% 步长)'} '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)) print(json.dumps(out, indent=1))
json.dump(out, open(a.out, 'w'), indent=1) json.dump(out, open(a.out, 'w'), indent=1)
+119
View File
@@ -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()
+13 -7
View File
@@ -1,7 +1,9 @@
"""导出部署包(方案 §8.4): """导出部署包(方案 §8.4):
python -m ml.export_onnx --ckpt ml/runs/current.pt --out webapp/models/traj_policy.onnx [--fp16] 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.onnx(输入 feat[1,83]、z[K,16,6];输出 act[K,16,6] 归一化增量、value[K]、sub[K,13])
与 traj_policy.meta.json(碰撞模型指纹、特征版本、维度)。导出后用 onnxruntime 与 PyTorch 对拍。""" 与 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 argparse, json, os
import numpy as np import numpy as np
import torch import torch
@@ -16,7 +18,7 @@ class Wrap(torch.nn.Module):
super().__init__(); self.m = m super().__init__(); self.m = m
def forward(self, feat, z): def forward(self, feat, z):
return self.m.sample(feat, z) return self.m.sample_shared(feat, z)
def main(): def main():
@@ -34,9 +36,9 @@ def main():
kin = Kin() kin = Kin()
if ck.get('fp') and ck['fp'] != kin.fp: raise SystemExit(f"checkpoint 指纹 {ck['fp']} ≠ 当前碰撞模型 {kin.fp}:须重训") 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) 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'], 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) opset_version=a.opset, dynamo=False)
if a.fp16: if a.fp16:
from onnxconverter_common import float16 from onnxconverter_common import float16
@@ -46,10 +48,14 @@ def main():
import onnxruntime as ort import onnxruntime as ort
s = ort.InferenceSession(a.out, providers=['CPUExecutionProvider']) s = ort.InferenceSession(a.out, providers=['CPUExecutionProvider'])
o = s.run(None, {'feat': feat.numpy(), 'z': z.numpy()}) 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)) 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, 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) 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})) print(json.dumps({'onnx': a.out, 'MB': os.path.getsize(a.out) / 1e6, 'max_abs_err_vs_torch': err}))
+17 -5
View File
@@ -3,7 +3,9 @@
* --consensus k:邻域共识过滤。起点不变、目标位置各扰动 5 mm 造 k 个邻居跑老师,原任务路径若与多数邻居 * --consensus k:邻域共识过滤。起点不变、目标位置各扰动 5 mm 造 k 个邻居跑老师,原任务路径若与多数邻居
* 不在同一绕行类(pathdev.sameClass),整条样本 keep=false(why=consensus)。成本约 (1+k) 倍。 * 不在同一绕行类(pathdev.sameClass),整条样本 keep=false(why=consensus)。成本约 (1+k) 倍。
* 每行一条任务:采样 → 老师 → 距离下界认证过滤 → 局部时间律(50 Hz)→ 写出。 * 每行一条任务:采样 → 老师 → 距离下界认证过滤 → 局部时间律(50 Hz)→ 写出。
* 认证不过的样本也写出(keep=false + 原因),供 stats 统计丢弃率;训练只用 keep=true。 */ * 认证不过的样本也写出(keep=false + 原因),供 stats 统计丢弃率;训练只用 keep=true。
* 2026-10-18 断点续写:第 i 条任务的随机数只由 (seed, i) 决定(sampler.reseed);输出文件已存在时保留其中完整的行、
* 截掉写了一半的最后一行,从下一条接着写 —— 生成几小时被中断,重跑只补剩下的,不重来、不重复。 */
'use strict'; 'use strict';
const fs = require('fs'), path = require('path'); 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]; 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 { timeParam } = require('./timelaw.cjs');
const fp = S.modelFingerprint ? S.modelFingerprint() : null; const fp = S.modelFingerprint ? S.modelFingerprint() : null;
fs.mkdirSync(path.dirname(OUT), { recursive: true }); 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 r3 = v => Math.round(v * 1000) / 1000;
const t00 = performance.now(); 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 task = GIVEN ? Object.assign({ cat: 'E2' }, GIVEN[i], { mode: GIVEN[i].R ? 'pose' : 'pos' }) : sampler.sample();
const t = teach(task); const t = teach(task);
const rec = { id: 's' + String(SEED).padStart(4, '0') + '_' + String(i).padStart(6, '0'), cat: task.cat, mode: task.mode, 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 (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); } 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'); 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); 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`);
+29 -2
View File
@@ -1,11 +1,38 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# 并行生成:bash ml/gen/run_parallel.sh <每进程条数> <进程数> [起始种子=1] ["--consensus 4"] # 并行生成:bash ml/gen/run_parallel.sh <每进程条数> <进程数> [起始种子=1] ["--consensus 4"]
# 例:8 核机器 5 万条 → bash ml/gen/run_parallel.sh 6250 8 # 例: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 set -e
N=${1:-500}; P=${2:-2}; S0=${3:-1}; EXTRA=${4:-} N=${1:-500}; P=${2:-2}; S0=${3:-1}; EXTRA=${4:-}
cd "$(dirname "$0")/../.." cd "$(dirname "$0")/../.."
mkdir -p ml/data mkdir -p ml/data
for ((i=0;i<P;i++)); do s=$((S0+i)); node ml/gen/gen_dataset.cjs --n $N --seed $s --out ml/data/shard_$(printf %04d $s).jsonl $EXTRA 2> ml/data/log_$s.txt & done pids=(); seeds=()
wait for ((i=0;i<P;i++)); do
s=$((S0+i)); f=ml/data/shard_$(printf %04d $s).jsonl
if [ -f "$f" ]; then echo "[gen] 种子 $s 已完成,跳过"; continue; fi
( node ml/gen/gen_dataset.cjs --n $N --seed $s --out "$f.part" $EXTRA 2>> 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 node ml/gen/stats.cjs ml/data/shard_*.jsonl > ml/data/stats.json
cat ml/data/stats.json cat ml/data/stats.json
+10 -5
View File
@@ -1,5 +1,8 @@
/* 任务采样(方案 §4.3):A 均匀 45% / B 直线被挡 30% / C 贴立柱 15% / D 目标不可达 10%;pos/pose 各半(D 只做 pos) */ /* 任务采样(方案 §4.3):A 均匀 45% / B 直线被挡 30% / C 贴立柱 15% / D 目标不可达 10%;pos/pose 各半(D 只做 pos) */
'use strict'; '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) { module.exports = function makeSampler(S, seed) {
const L = S.LIM, O = S.ORDER, w = S.wrap180; 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; }; 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 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 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 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() { function sample() {
const u = rnd(); const u = rnd();
const cat = u < 0.45 ? 'A' : u < 0.75 ? 'B' : u < 0.90 ? 'C' : 'D'; const cat = u < 0.45 ? 'A' : u < 0.75 ? 'B' : u < 0.90 ? 'C' : 'D';
let a, b, T, R = null; let a, b, T, R = null;
if (cat === 'A') { a = freeQ(); b = freeQ(); } 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') { 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; } for (let t = 0; t < 200; t++) { const c = rq(); if (S.collideFree(c) && !segOK(c, b)) { a = c; break; } } if (a) break; }
} else { } else {
a = freeQ(); 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); } // 工作空间外 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'); 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); if (mode === 'pose') R = S.orient(b);
return { cat, mode, q0: a, T, R, qb: 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 };
}; };
+2 -2
View File
@@ -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]) 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]) det = torch.tensor([c in ('B', 'C') for c in cat])
# 2026-10-18:左右比例按全部 B/C 题的首次 J1 方向统计,不以到达为条件(APG 把一侧绕行全做失败时,到达子集比例可纹丝不动) # 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) 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 both = ok_o & ok_n
tro = torch.cat([p['travel'] for _, p in po]); trn = torch.cat([p['travel'] for _, p in pn]) 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 travel_ratio = (trn[both].median() / tro[both].median().clamp(min=1e-6)).item() if both.any() else None
+21 -3
View File
@@ -3,6 +3,8 @@
# bash ml/mining/loop.sh [轮数=6] [每轮 APG 步数=1000] [每轮出题数=50000] # bash ml/mining/loop.sh [轮数=6] [每轮 APG 步数=1000] [每轮出题数=50000]
# 2026-10-18:APG 采样池只用本轮错题(此前累积所有历史轮,后期分布偏向老错题);老错题的保持由门禁"上轮修好仍对"负责。 # 2026-10-18:APG 采样池只用本轮错题(此前累积所有历史轮,后期分布偏向老错题);老错题的保持由门禁"上轮修好仍对"负责。
# 统一 $PYTHON;门禁加 R7(左右比例 / 行程)。 # 统一 $PYTHON;门禁加 R7(左右比例 / 行程)。
# 2026-10-18 第五轮(审查 N-11 / 缺口 1):可续跑——已有 gate.json 的轮次直接跳过(读回 gain 与 fixed 链),
# 中途断在某轮的 APG 时用该轮 last.pt 续跑;current.pt 只在门禁通过时由 gate.py 更新,本身就是断点。
set -e set -e
cd "$(dirname "$0")/../.." cd "$(dirname "$0")/../.."
PY=${PYTHON:-python} PY=${PYTHON:-python}
@@ -13,14 +15,30 @@ mkdir -p ml/runs/mine
SAMPLES="ml/data/samples"; FIXED=""; small=0 SAMPLES="ml/data/samples"; FIXED=""; small=0
for ((r=1; r<=ROUNDS; r++)); do for ((r=1; r<=ROUNDS; r++)); do
D=ml/runs/mine/r$r; mkdir -p $D 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(局部极小)请老师补少量路径(唯一保留的后训练老师用途) # E2(局部极小)请老师补少量路径(唯一保留的后训练老师用途)
if [ -s $D/e2_tasks.jsonl ]; then 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 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" SAMPLES="ml/data/samples ml/data/samples_e2"
fi 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 $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" FIXED="--fixed $D/gate_fixed.jsonl"
gain=$($PY -c "import json;print(json.load(open('$D/gate.json'))['gain_pp'])") gain=$($PY -c "import json;print(json.load(open('$D/gate.json'))['gain_pp'])")
+15 -3
View File
@@ -7,8 +7,19 @@ import torch
from ml.common.features import wrap180 from ml.common.features import wrap180
def min_gap_chunked(kin, q, cap=0.5, chunk=512): def auto_chunk(kin, mb_per_q, lo=256, hi=8192):
"""分块算最小间隙:随机位形每个约 2800 个幸存胶囊对、峰值约 1.5 MB/位形,一次 4 万位形会爆内存/显存(2026-10-18 实测出题 OOM),每块 512""" """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)]) 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) 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 = [] out = []
for s in range(0, q.shape[0], chunk): # 与 min_gap_chunked 一样分块防 OOM for s in range(0, q.shape[0], chunk): # 与 min_gap_chunked 一样分块防 OOM
gm = kin.gaps(q[s:s + chunk], 40)['gm'] gm = kin.gaps(q[s:s + chunk], 40)['gm']
+16
View File
@@ -126,6 +126,22 @@ class Policy(nn.Module):
x = z - self.decode(mem, z, zero, one) x = z - self.decode(mem, z, zero, one)
return x, v, s 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): def sample_k(self, feat, K, z=None):
"""仿真 / APG 用:每个观测 K 条候选 → act [B,K,16,6], value [B], sub [B,13]""" """仿真 / APG 用:每个观测 K 条候选 → act [B,K,16,6], value [B], sub [B,13]"""
mem = self.encode(feat) mem = self.encode(feat)
+53 -42
View File
@@ -141,6 +141,8 @@
.form label { display: flex; flex-direction: column; gap: 5px; font-size: 12px; color: var(--dim); } .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 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; } .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 .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; } .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; } .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 @@
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<div class="modal" id="modal"><div class="dlg"> <div class="modal" id="modal"><div class="dlg">
<h3>开始训练</h3> <h3>开始训练</h3>
<div class="d">后台执行 <code>bash ml/pipeline.sh all</code>,从起始阶段一路跑到结束阶段。网页可以关,训练不受影响;关闭控制台窗口会结束训练。</div> <div class="d">后台执行 <code>bash ml/pipeline.sh all</code>,从对拍一路跑到验收。已完成的步骤自动跳过;训练每 10 分钟存档、生成按条续写,断了再点一次就从断点接着跑。网页和控制台窗口都可以关,训练不受影响。</div>
<div class="form"> <div class="form">
<label>起始阶段<select id="fFrom"></select></label> <label>运行方式<select id="fMode"><option value="resume">续跑:跳过已完成的步骤</option><option value="fresh">从头重跑(旧产物移到 ml/archive/)</option></select></label>
<label>结束阶段<select id="fTo"></select></label> <label>动作头 CFG<select id="fCfg"><option value="ml/configs/base.json">MeanFlow(base,M5 不过自动转 WTA)</option><option value="ml/configs/wta.json">WTA(wta)</option><option value="ml/configs/small.json">小模型 d=128(R4 不过时用)</option></select></label>
<label>动作头 CFG<select id="fCfg"><option value="ml/configs/base.json">MeanFlow(base)</option><option value="ml/configs/wta.json">WTA 回退(wta)</option></select></label>
<label>共识过滤 CONSENSUS<select id="fCons"><option value="">按老师门禁自动</option><option value="0">关</option><option value="1">开(生成约慢 5 倍)</option></select></label> <label>共识过滤 CONSENSUS<select id="fCons"><option value="">按老师门禁自动</option><option value="0">关</option><option value="1">开(生成约慢 5 倍)</option></select></label>
<label>任务数 N_TASKS<input id="fN" value="50000"></label> <label>任务数 N_TASKS<input id="fN" value="50000"></label>
<label>生成进程数 NPROC<input id="fP" placeholder="默认 = CPU 核数"></label> <label>生成进程数 NPROC<input id="fP" placeholder="默认 = CPU 核数"></label>
<label>目标样本 TARGET_SAMPLES<input id="fT" value="1700000"></label> <label>目标样本 TARGET_SAMPLES<input id="fT" value="1700000"></label>
<label>挖错轮数 ROUNDS<input id="fR" value="6"></label> <label>挖错轮数 ROUNDS<input id="fR" value="6"></label>
<label>老师间隙下限 MIN_GAP_MM<input id="fG" value="3"></label>
</div> </div>
<div class="warnbox" id="fWarn"></div> <div class="warnbox" id="fWarn"></div>
<div class="acts"><button id="fCancel">取消</button><button id="fGo" class="primary">▶ 开始</button></div> <div class="acts"><button id="fCancel">取消</button><button id="fGo" class="primary">▶ 开始</button></div>
@@ -336,12 +338,20 @@ const thin = (pts, n = 400) => { if (pts.length <= n) return pts; const k = pts.
/* ===================== 状态 ===================== */ /* ===================== 状态 ===================== */
let cur = null, recs = [], evals = [], offset = 0, timer = null, charts = null, lastMtime = 0, lastStatus = null, runsCache = []; let cur = null, recs = [], evals = [], offset = 0, timer = null, charts = null, lastMtime = 0, lastStatus = null, runsCache = [];
let logyOn = false, job = null, userPicked = false, conOff = 0; let logyOn = false, job = null, userPicked = false, conOff = 0;
/* 阶段表:名称、说明、粗估耗时(小时,RTX 3090 + 28 核;只用于全流程百分比的权重) */ /* 阶段表:名称、粗估耗时(小时,作全流程百分比权重)、超时上限。
const STAGE_META = [ 2026-10-18 第五轮(审查 N-14):真实模式下由 /api/status 的 stage_meta 下发(来源 ml/pstate.py STAGES,与超时同源);
['setup', '安装依赖', 0.15], ['kin', '对拍', 0.02], ['gate1', '老师门禁', 0.45], ['gen', '生成数据', 1.0], ['prep', '打包样本', 0.4], 下面这份只给示例数据用,数值与 pstate.py 一致。 */
['train', '预训练', 3.0], ['m5', 'M5 门禁', 0.25], ['apg', 'APG 微调', 3.0], ['mine', '挖错循环', 9.0], ['export', '导出 ONNX', 0.02], let STAGE_META = [
['bench', '部署评测', 1.0], ['accept', '验收汇总', 0.25]]; ['setup', '安装依赖', 0.2], ['kin', '对拍', 0.2], ['r4', '推理延迟', 0.2], ['gate1', '一致性门禁', 0.5], ['gen', '生成老师轨迹', 5.0],
const SM = Object.fromEntries(STAGE_META.map(([k, n, w]) => [k, { n, w }])); ['prep', '打包样本', 0.5], ['train', '预训练', 3.0], ['m5', '头门禁', 0.5], ['apg', 'APG 微调', 4.0], ['mine', '挖错循环', 9.0],
['export', '导出', 0.1], ['bench', '部署评测', 1.0], ['accept', '验收汇总', 0.5]];
let SM = Object.fromEntries(STAGE_META.map(([k, n, w]) => [k, { n, w, to: Math.max(1, 3 * w) }]));
function applyStageMeta(m) {
if (!m || !m.length) return;
STAGE_META = m.map(x => [x.id, x.name, x.est_h]); SM = Object.fromEntries(m.map(x => [x.id, { n: x.name, w: x.est_h, to: x.timeout_h }]));
}
/* 开始 / 停止请求带自定义头:跨站网页发不出这种请求(会触发 CORS 预检,控制台不应答),见 monitor.py 审查 N-8 */
const post = (path, body) => fetch(path, { method: 'POST', headers: { 'X-Console': '1', 'Content-Type': 'application/json' }, body: body || '{}' }).then(x => x.json()).catch(e => ({ error: String(e) }));
const smoothv = () => +$('smooth').value; const smoothv = () => +$('smooth').value;
const ema = (pts, a) => { if (!a) return pts; let m = null; return pts.map(([x, y]) => { if (!isNum(y)) return [x, y]; m = m == null ? y : a * m + (1 - a) * y; return [x, m]; }); }; const ema = (pts, a) => { if (!a) return pts; let m = null; return pts.map(([x, y]) => { if (!isNum(y)) return [x, y]; m = m == null ? y : a * m + (1 - a) * y; return [x, m]; }); };
const xOf = r => r.step ?? r.it ?? 0; const xOf = r => r.step ?? r.it ?? 0;
@@ -454,7 +464,7 @@ async function loadRuns() {
async function poll() { async function poll() {
try { try {
lastStatus = await api('/api/status'); lastStatus = await api('/api/status'); applyStageMeta(lastStatus.stage_meta);
job = await api('/api/job').catch(() => null); job = await api('/api/job').catch(() => null);
await loadRuns(); await loadRuns();
if (cur) { if (cur) {
@@ -467,7 +477,7 @@ async function poll() {
render(); render();
} }
const P = lastStatus.pipeline, busy = (job && job.running) || (P && P.current); const P = lastStatus.pipeline, busy = (job && job.running) || (P && P.current);
if (DEMO) setPill('demo', '示例数据'); if (DEMO) { setPill('demo', '示例数据'); if (!$('demobar')) $('pipe').insertAdjacentHTML('beforebegin', `<div class="demobar" id="demobar">这是示例数据,不是你的训练。${location.protocol === 'file:' ? '直接双击 monitor.html 只能看示例;' : ''}看真实训练请双击 ml/console.bat(或 python -m ml.monitor --open)。</div>`); }
else if (busy) setPill('live', `运行中 · ${P && P.current ? SM[P.current]?.n || P.current : '启动中'}`); else if (busy) setPill('live', `运行中 · ${P && P.current ? SM[P.current]?.n || P.current : '启动中'}`);
else if (cur && Date.now() / 1000 - lastMtime < 120) setPill('live', `运行中 · ${ago(lastMtime)}更新`); else if (cur && Date.now() / 1000 - lastMtime < 120) setPill('live', `运行中 · ${ago(lastMtime)}更新`);
else setPill('', '空闲'); else setPill('', '空闲');
@@ -577,9 +587,14 @@ function renderStatus(s) {
/* ===================== 全流程卡片 / 开始停止 / 终端 ===================== */ /* ===================== 全流程卡片 / 开始停止 / 终端 ===================== */
function renderPipe(s) { function renderPipe(s) {
const P = s.pipeline, running = job && job.running; const P = s.pipeline, running = job && job.running;
const btns = `<div class="ctlrow">${running || (P && P.current) const ck = s.check || {}, lk = s.lock;
? `<button class="danger" onclick="stopJob()">■ 停止</button><span class="hint">${running ? `PID ${job.pid} · 从「${SM[job.from]?.n}」到「${SM[job.to]?.n}」` : '由终端启动,需在终端里结束'}</span>` const ckline = ck.pending ? '<div class="ckline">环境自检中…</div>'
: `<button class="primary" onclick="openStart()">▶ ${P ? '继续 / 重新开始' : '开始训练'}</button><span class="hint">${job && job.rc != null ? (job.stopped ? '上次已手动停止' : job.rc === 0 ? '上次运行已全部完成' : `上次运行失败(返回码 ${job.rc}),看终端输出`) : '点击后选择起止阶段与参数'}</span>`}</div>`; : ck.problems && ck.problems.length ? `<div class="ckline bad">✕ ${ck.problems.join(';')}</div>`
: ck.python ? `<div class="ckline">Python ${ck.python} · torch ${ck.torch}${ck.gpu ? ' · ' + ck.gpu : ''}${ck.warn ? ` · <span class="warn">${ck.warn}</span>` : ''}</div>` : '';
const who = job && job.external ? `由${(lk && lk.owner || '').startsWith('网页') ? '上一个控制台窗口' : '终端'}启动(pid ${lk && lk.pid},${Math.round(lk && lk.age_s || 0)} 秒前心跳)` : job && job.pid ? `PID ${job.pid}` : '';
const btns = `${ckline}<div class="ctlrow">${running || (P && P.current)
? `<button class="danger" onclick="stopJob()">■ 停止</button><span class="hint">${who}</span>`
: `<button class="primary" onclick="openStart()" ${ck.problems && ck.problems.length ? 'disabled' : ''}>▶ ${P ? '继续训练' : '开始训练'}</button><span class="hint">${job && job.rc != null ? (job.stopped ? '上次已手动停止,再点即从停下的那步继续' : job.rc === 0 ? '上次运行已全部完成' : `上次运行失败(返回码 ${job.rc}),看终端输出;修好后再点即续跑`) : '已完成的步骤会自动跳过'}</span>`}</div>`;
if (!P) { $('pipe').innerHTML = `<h2><span>全流程</span><span class="r">尚未开始</span></h2>${empty('还没运行过流水线。点「开始训练」从对拍一直跑到验收;中途每一步的进度都显示在这里。')}${btns}`; return; } if (!P) { $('pipe').innerHTML = `<h2><span>全流程</span><span class="r">尚未开始</span></h2>${empty('还没运行过流水线。点「开始训练」从对拍一直跑到验收;中途每一步的进度都显示在这里。')}${btns}`; return; }
const plan = P.plan && P.plan.length ? P.plan : Object.keys(P.stages); const plan = P.plan && P.plan.length ? P.plan : Object.keys(P.stages);
let tot = 0, got = 0; let tot = 0, got = 0;
@@ -592,7 +607,7 @@ function renderPipe(s) {
let remain = 0; for (const k of plan) { const r = P.stages[k]; if (!r || r.rc == null) remain += (SM[k]?.w || 0.1) * (k === cur ? 1 - (pr.frac || 0) : 1); } let remain = 0; for (const k of plan) { const r = P.stages[k]; if (!r || r.rc == null) remain += (SM[k]?.w || 0.1) * (k === cur ? 1 - (pr.frac || 0) : 1); }
const head = cur ? `<div class="now">正在运行<b>${SM[cur]?.n || cur}</b>${pr.label || ''}</div>` const head = cur ? `<div class="now">正在运行<b>${SM[cur]?.n || cur}</b>${pr.label || ''}</div>`
: allDone ? `<div class="now">状态<b class="ok">全部完成</b>共 ${plan.length} 步</div>` : allDone ? `<div class="now">状态<b class="ok">全部完成</b>共 ${plan.length} 步</div>`
: failed ? `<div class="now">状态<b class="bad">停在「${SM[failed].n}」</b>${P.stages[failed].rc === -15 || P.stages[failed].rc === 143 ? '已手动停止' : '失败,看终端输出'}</div>` : failed ? `<div class="now">状态<b class="bad">停在「${SM[failed]?.n || failed}」</b>${(P.stages[failed].info || {}).timed_out || P.stages[failed].rc === 124 ? '超过时间上限,判定卡死' : P.stages[failed].rc === -15 || P.stages[failed].rc === 143 ? '已停止' : '失败,看终端输出'}</div>`
: `<div class="now">状态<b>未在运行</b>已完成 ${doneN} / ${plan.length} 步</div>`; : `<div class="now">状态<b>未在运行</b>已完成 ${doneN} / ${plan.length} 步</div>`;
$('pipe').innerHTML = `<h2><span>全流程</span><span class="r">${doneN} / ${plan.length} 步完成</span></h2> $('pipe').innerHTML = `<h2><span>全流程</span><span class="r">${doneN} / ${plan.length} 步完成</span></h2>
<div class="overall">${head}<div class="bigpct">${(100 * frac).toFixed(1)}<small>%</small></div></div> <div class="overall">${head}<div class="bigpct">${(100 * frac).toFixed(1)}<small>%</small></div></div>
@@ -602,7 +617,7 @@ function renderPipe(s) {
<div class="facts"> <div class="facts">
<div class="fact"><div class="k">总用时</div><div class="v">${hms((cur ? now : Math.max(...Object.values(P.stages).map(r => r.end || r.start))) - P.started)}</div></div> <div class="fact"><div class="k">总用时</div><div class="v">${hms((cur ? now : Math.max(...Object.values(P.stages).map(r => r.end || r.start))) - P.started)}</div></div>
<div class="fact"><div class="k">本步用时</div><div class="v">${cr ? hms(now - cr.start) : '—'}</div></div> <div class="fact"><div class="k">本步用时</div><div class="v">${cr ? hms(now - cr.start) : '—'}</div></div>
<div class="fact"><div class="k">本步粗估</div><div class="v">${cur ? hmsShort((SM[cur]?.w || 0) * 3600) : '—'}</div></div> <div class="fact"><div class="k">本步粗估 / 上限</div><div class="v">${cur ? hmsShort((SM[cur]?.w || 0) * 3600) + ' / ' + hmsShort((cr && cr.timeout_h || SM[cur]?.to || 0) * 3600) : '—'}</div></div>
<div class="fact"><div class="k">全流程剩余(粗估)</div><div class="v">${allDone ? '—' : '约 ' + hmsShort(remain * 3600)}</div></div> <div class="fact"><div class="k">全流程剩余(粗估)</div><div class="v">${allDone ? '—' : '约 ' + hmsShort(remain * 3600)}</div></div>
</div>${btns}`; </div>${btns}`;
} }
@@ -624,31 +639,27 @@ async function pollConsole() {
} }
function toast(msg) { const t = $('toast'); t.textContent = msg; t.style.display = 'block'; clearTimeout(toast.h); toast.h = setTimeout(() => t.style.display = 'none', 3500); } function toast(msg) { const t = $('toast'); t.textContent = msg; t.style.display = 'block'; clearTimeout(toast.h); toast.h = setTimeout(() => t.style.display = 'none', 3500); }
function openStart() { function openStart() {
const P = lastStatus && lastStatus.pipeline, keys = STAGE_META.map(x => x[0]); const P = lastStatus && lastStatus.pipeline, ck = lastStatus && lastStatus.check;
const opt = keys.map(k => `<option value="${k}">${SM[k].n}(${k})</option>`).join(''); if (ck && ck.problems && ck.problems.length) { toast('环境自检未通过:' + ck.problems.join(';')); return; }
$('fFrom').innerHTML = opt; $('fTo').innerHTML = opt; const w = $('fWarn'); const upd = () => { const msgs = [];
// 默认从第一个还没成功完成的阶段开始,到验收结束 const done = P ? (P.plan || []).filter(k => P.stages[k] && P.stages[k].rc === 0) : [];
let from = 'kin'; if ($('fMode').value === 'resume' && done.length) msgs.push(`将跳过已完成的 ${done.length} 步:${done.map(k => SM[k]?.n || k).join('、')}。改了参数会自动改为从头重跑。`);
if (P) { const k = keys.slice(1).find(k => !(P.stages[k] && P.stages[k].rc === 0)); if (k) from = k; } if ($('fMode').value === 'fresh' && P) msgs.push('从头重跑:上次的数据、模型、评测结果整体移到 ml/archive/<时间>/(不删除)。');
$('fFrom').value = from; $('fTo').value = 'accept';
const w = $('fWarn'); const upd = () => { const f = $('fFrom').value; const msgs = [];
if (P && P.stages[f] && P.stages[f].rc === 0) msgs.push(`「${SM[f].n}」上次已成功完成,重跑会覆盖它的产物。`);
if (keys.indexOf(f) > keys.indexOf('gen') && !(lastStatus.data && lastStatus.data.stats)) msgs.push('还没有生成数据,建议从「老师门禁」或「生成数据」开始。');
if (keys.indexOf(f) > keys.indexOf($('fTo').value)) msgs.push('起始阶段在结束阶段之后。');
w.style.display = msgs.length ? 'block' : 'none'; w.innerHTML = msgs.join('<br>'); }; w.style.display = msgs.length ? 'block' : 'none'; w.innerHTML = msgs.join('<br>'); };
$('fFrom').onchange = upd; $('fTo').onchange = upd; upd(); $('fMode').onchange = upd; upd();
$('modal').classList.add('show'); $('modal').classList.add('show');
} }
async function startJob() { async function startJob() {
const env = { CFG: $('fCfg').value, CONSENSUS: $('fCons').value, N_TASKS: $('fN').value.trim(), NPROC: $('fP').value.trim(), TARGET_SAMPLES: $('fT').value.trim(), ROUNDS: $('fR').value.trim() }; const env = { CFG: $('fCfg').value, CONSENSUS: $('fCons').value, N_TASKS: $('fN').value.trim(), NPROC: $('fP').value.trim(), TARGET_SAMPLES: $('fT').value.trim(), ROUNDS: $('fR').value.trim(), MIN_GAP_MM: $('fG').value.trim() };
const body = JSON.stringify({ from: $('fFrom').value, to: $('fTo').value, env }); const body = JSON.stringify({ fresh: $('fMode').value === 'fresh', env });
const r = DEMO ? await api('/api/start', body) : await fetch('/api/start', { method: 'POST', body }).then(x => x.json()).catch(e => ({ error: String(e) })); const r = DEMO ? await api('/api/start', body) : await post('/api/start', body);
if (r.error) { toast('没能开始:' + r.error); return; } if (r.error) { toast('没能开始:' + r.error); return; }
$('modal').classList.remove('show'); toast('已开始'); conOff = 0; userPicked = false; poll(); $('modal').classList.remove('show'); toast('已开始'); conOff = 0; userPicked = false; poll();
} }
async function stopJob() { async function stopJob() {
if (!confirm('确定停止?正在运行的这一步会中断,已完成的步骤保留,之后可以从这一步继续。')) return; if (!confirm('确定停止?训练会先存档再退出(最多等 90 秒),生成/打包按已写完的部分保留。之后点「续跑」从断点接着来。')) return;
const r = DEMO ? await api('/api/stop', '{}') : await fetch('/api/stop', { method: 'POST' }).then(x => x.json()).catch(e => ({ error: String(e) })); toast('正在停止…训练阶段会先存档');
const r = DEMO ? await api('/api/stop', '{}') : await post('/api/stop');
toast(r.error ? '没能停止:' + r.error : '已停止'); poll(); toast(r.error ? '没能停止:' + r.error : '已停止'); poll();
} }
$('btnStart').onclick = openStart; $('btnStop').onclick = stopJob; $('btnStart').onclick = openStart; $('btnStop').onclick = stopJob;
@@ -704,10 +715,10 @@ function demoApi() {
const mkPipe = (done, cur, curStartH) => { const t = now(); const st = {}; let tt = t - curStartH * 3600; const mkPipe = (done, cur, curStartH) => { const t = now(); const st = {}; let tt = t - curStartH * 3600;
for (let i = done.length - 1; i >= 0; i--) { const [k, dur] = done[i]; st[k] = { start: tt - dur * 3600, end: tt, rc: 0, info: {} }; tt -= dur * 3600; } for (let i = done.length - 1; i >= 0; i--) { const [k, dur] = done[i]; st[k] = { start: tt - dur * 3600, end: tt, rc: 0, info: {} }; tt -= dur * 3600; }
if (cur) st[cur] = { start: t - curStartH * 3600, end: null, rc: null, info: {} }; if (cur) st[cur] = { start: t - curStartH * 3600, end: null, rc: null, info: {} };
return { started: tt, plan: ['kin', 'gate1', 'gen', 'prep', 'train', 'm5', 'apg', 'mine', 'export', 'bench', 'accept'], stages: st, current: cur }; }; return { started: tt, plan: ['kin', 'r4', 'gate1', 'gen', 'prep', 'train', 'm5', 'apg', 'mine', 'export', 'bench', 'accept'], stages: st, current: cur }; };
const CON = { const CON = {
train: '$ FROM=kin TO=accept bash ml/pipeline.sh all\n计划:kin gate1 gen prep train m5 apg mine export bench accept\n== node ml/prep/export_kin.cjs\n== node ml/eval/dump_parity.cjs 2000\n== python -m ml.eval.check_parity\n{"PASS": true, "fk_mm": 0.0004, "gap_mm": 0.0011}\n== node ml/gen/consistency.cjs 300 4242\n{"consistentByGateRule": 0.86}\n一致性门禁结果 → CONSENSUS=1\n== bash ml/gen/run_parallel.sh 1786 28 1 --consensus 4\n样本数 1702000 ≥ 目标,完成\n== bash ml/prep/run_prep.sh 28\n== python -m ml.eval.check_r2 --samples ml/data/samples --out ml/data/r2.json\n{"pairs": 250, "label_std_mean": 0.071, "pass": true}\n== python -m ml.train --samples ml/data/samples --out ml/runs/bench_base --steps 2000 --no-eval\nstep 2000 loss 1.912 6150 samples/s\n== python -m ml.train --samples ml/data/samples --out ml/runs/base\n', train: '$ FROM=kin TO=accept bash ml/pipeline.sh all\n计划:kin r4 gate1 gen prep train m5 apg mine export bench accept\n== node ml/prep/export_kin.cjs\n== node ml/eval/dump_parity.cjs 2000\n== python -m ml.eval.check_parity\n{"PASS": true, "fk_mm": 0.0004, "gap_mm": 0.0011}\n== node ml/gen/consistency.cjs 300 4242\n{"consistentByGateRule": 0.86}\n一致性门禁结果 → CONSENSUS=1\n== bash ml/gen/run_parallel.sh 1786 28 1 --consensus 4\n样本数 1702000 ≥ 目标,完成\n== bash ml/prep/run_prep.sh 28\n== python -m ml.eval.check_r2 --samples ml/data/samples --out ml/data/r2.json\n{"pairs": 250, "label_std_mean": 0.071, "pass": true}\n== python -m ml.train --samples ml/data/samples --out ml/runs/bench_base --steps 2000 --no-eval\nstep 2000 loss 1.912 6150 samples/s\n== python -m ml.train --samples ml/data/samples --out ml/runs/base\n',
gen: '$ FROM=kin TO=accept bash ml/pipeline.sh all\n计划:kin gate1 gen prep train m5 apg mine export bench accept\n== node ml/prep/export_kin.cjs\n== python -m ml.eval.check_parity\n{"PASS": true}\n== node ml/gen/consistency.cjs 300 4242\n{"consistentByGateRule": 0.86}\n一致性门禁结果 → CONSENSUS=1\n== bash ml/gen/run_parallel.sh 1786 28 1 --consensus 4\n', gen: '$ FROM=kin TO=accept bash ml/pipeline.sh all\n计划:kin r4 gate1 gen prep train m5 apg mine export bench accept\n== node ml/prep/export_kin.cjs\n== python -m ml.eval.check_parity\n{"PASS": true}\n== node ml/gen/consistency.cjs 300 4242\n{"consistentByGateRule": 0.86}\n一致性门禁结果 → CONSENSUS=1\n== bash ml/gen/run_parallel.sh 1786 28 1 --consensus 4\n',
late: '... 挖错第 2 轮\nround 2 gain 1.1 pp\n== python -m ml.mining.mine_round --ckpt ml/runs/current.pt --seed 1003 --n 50000 --out ml/runs/mine/r3\n{"n": 50000, "E1": 0.009, "E2": 0.019, "ok": 0.938}\n== python -m ml.apg.apg_finetune --init ml/runs/current.pt --out ml/runs/apg_r3 --steps 1000\n', idle: '' }; late: '... 挖错第 2 轮\nround 2 gain 1.1 pp\n== python -m ml.mining.mine_round --ckpt ml/runs/current.pt --seed 1003 --n 50000 --out ml/runs/mine/r3\n{"n": 50000, "E1": 0.009, "E2": 0.019, "ok": 0.938}\n== python -m ml.apg.apg_finetune --init ml/runs/current.pt --out ml/runs/apg_r3 --steps 1000\n', idle: '' };
const GPU = () => [{ name: 'NVIDIA GeForce RTX 3090', util: 93 + Math.round(5 * rnd()), mem_used: 15872 + Math.round(500 * rnd()), mem_total: 24576, temp: 71 + Math.round(3 * rnd()), power: 318 + Math.round(24 * rnd()) }]; const GPU = () => [{ name: 'NVIDIA GeForce RTX 3090', util: 93 + Math.round(5 * rnd()), mem_used: 15872 + Math.round(500 * rnd()), mem_total: 24576, temp: 71 + Math.round(3 * rnd()), power: 318 + Math.round(24 * rnd()) }];
const DATA = { kin_pass: true, consistency: 0.86, gen_tasks_written: 56412, shards: 56, const DATA = { kin_pass: true, consistency: 0.86, gen_tasks_written: 56412, shards: 56,
@@ -716,12 +727,12 @@ function demoApi() {
function setup(name) { function setup(name) {
sc = name; seed = 7; con = CON[name] || ''; jobS = { running: name !== 'idle', pid: 21844, from: 'kin', to: 'accept', started: now() - 5 * 3600, rc: null, bash: true }; genAt = 31200; sc = name; seed = 7; con = CON[name] || ''; jobS = { running: name !== 'idle', pid: 21844, from: 'kin', to: 'accept', started: now() - 5 * 3600, rc: null, bash: true }; genAt = 31200;
if (name === 'idle') { runs = {}; logs = {}; pipe = null; jobS = { running: false, bash: true }; return; } if (name === 'idle') { runs = {}; logs = {}; pipe = null; jobS = { running: false, bash: true }; return; }
if (name === 'gen') { runs = {}; logs = {}; pipe = mkPipe([['kin', 0.02], ['gate1', 0.47]], 'gen', 2.7); pipe.stages.gen.info.target_tasks = 50008; return; } if (name === 'gen') { runs = {}; logs = {}; pipe = mkPipe([['kin', 0.02], ['r4', 0.05], ['gate1', 0.47]], 'gen', 2.7); pipe.stages.gen.info.target_tasks = 50008; return; }
if (name === 'train') { pipe = mkPipe([['kin', 0.02], ['gate1', 0.47], ['gen', 4.6], ['prep', 0.38]], 'train', 2.15); if (name === 'train') { pipe = mkPipe([['kin', 0.02], ['r4', 0.05], ['gate1', 0.47], ['gen', 4.6], ['prep', 0.38]], 'train', 2.15);
runs = { base: { kind: 'train', at: 36000, total: 60000, sps: 6208, t0: T0, live: true }, runs = { base: { kind: 'train', at: 36000, total: 60000, sps: 6208, t0: T0, live: true },
bench_base: { kind: 'bench', at: 2000, total: 2000, sps: 6150, t0: T0 - 700 } }; bench_base: { kind: 'bench', at: 2000, total: 2000, sps: 6150, t0: T0 - 700 } };
logs = { base: trainLog(60000, 0, 36000, 6208, T0), bench_base: trainLog(2000, 0, 2000, 6150, T0 - 700, 0) }; logs = { base: trainLog(60000, 0, 36000, 6208, T0), bench_base: trainLog(2000, 0, 2000, 6150, T0 - 700, 0) };
} else { pipe = mkPipe([['kin', 0.02], ['gate1', 0.47], ['gen', 4.6], ['prep', 0.38], ['train', 2.9], ['m5', 0.2], ['apg', 2.6]], 'mine', 3.4); pipe.stages.mine.info.rounds = 6; } else { pipe = mkPipe([['kin', 0.02], ['r4', 0.05], ['gate1', 0.47], ['gen', 4.6], ['prep', 0.38], ['train', 2.9], ['m5', 0.2], ['apg', 2.6]], 'mine', 3.4); pipe.stages.mine.info.rounds = 6;
pipe.stages.export = pipe.stages.bench = pipe.stages.accept = undefined; delete pipe.stages.export; delete pipe.stages.bench; delete pipe.stages.accept; pipe.stages.export = pipe.stages.bench = pipe.stages.accept = undefined; delete pipe.stages.export; delete pipe.stages.bench; delete pipe.stages.accept;
const t1 = now() - 9 * 3600; const t1 = now() - 9 * 3600;
runs = { base: { kind: 'train', at: 60000, total: 60000, sps: 6208, t0: t1 - 3 * 3600 }, runs = { base: { kind: 'train', at: 60000, total: 60000, sps: 6208, t0: t1 - 3 * 3600 },
@@ -779,8 +790,8 @@ function demoApi() {
if (u.pathname === '/api/status') return status(); if (u.pathname === '/api/status') return status();
if (u.pathname === '/api/job') return jobS; if (u.pathname === '/api/job') return jobS;
if (u.pathname === '/api/console') { const off = +u.searchParams.get('offset'); return { text: con.slice(off), offset: con.length, reset: off === 0 || off > con.length }; } if (u.pathname === '/api/console') { const off = +u.searchParams.get('offset'); return { text: con.slice(off), offset: con.length, reset: off === 0 || off > con.length }; }
if (u.pathname === '/api/start') { const b = JSON.parse(body || '{}'); jobS = { running: true, pid: 23001, from: b.from, to: b.to, started: now(), rc: null, bash: true }; if (u.pathname === '/api/start') { const b = JSON.parse(body || '{}'); b.from = b.from || 'kin'; b.to = b.to || 'accept'; jobS = { running: true, pid: 23001, from: b.from, to: b.to, started: now(), rc: null, bash: true };
con = `$ FROM=${b.from} TO=${b.to} bash ml/pipeline.sh all\n(示例数据:真实使用时这里是命令行输出)\n`; con = `$ ${b.fresh ? 'FRESH=1 ' : ''}bash ml/pipeline.sh all\n(示例数据:真实使用时这里是命令行输出)\n`;
if (!pipe) pipe = mkPipe([], b.from, 0.001); else { pipe.current = b.from; pipe.stages[b.from] = { start: now(), end: null, rc: null, info: {} }; } if (!pipe) pipe = mkPipe([], b.from, 0.001); else { pipe.current = b.from; pipe.stages[b.from] = { start: now(), end: null, rc: null, info: {} }; }
return { ok: true, pid: 23001 }; } return { ok: true, pid: 23001 }; }
if (u.pathname === '/api/stop') { jobS = { ...jobS, running: false, rc: -15, stopped: true }; if (u.pathname === '/api/stop') { jobS = { ...jobS, running: false, rc: -15, stopped: true };
+160 -41
View File
@@ -1,16 +1,25 @@
"""训练控制台(本地网页,零第三方依赖,离线可用):一键开始全流程、看进度、可停止。 """训练控制台(本地网页,零第三方依赖,离线可用):一键开始全流程、看进度、可停止。
双击 ml/console.bat(Windows)或运行 bash ml/console.sh(Linux/WSL)→ 自动打开 http://localhost:8100 双击 ml/console.bat(Windows)或运行 bash ml/console.sh(Linux/WSL)→ 自动打开 http://localhost:8100
也可:python -m ml.monitor [--port 8100] [--host 0.0.0.0] [--open] 也可:python -m ml.monitor [--port 8100] [--host 0.0.0.0] [--open]
网页上点「开始训练」→ 选起止阶段与参数 → 后台执行 bash ml/pipeline.sh all(FROM/TO 环境变量), 网页上点「开始训练」→ 选参数 → 后台执行 bash ml/pipeline.sh all(已完成的阶段自动跳过 = 续跑;勾「从头重跑」= FRESH=1),
输出写 ml/runs/console.log,网页实时显示;「停止」结束整个进程树。网页关掉不影响训练,重新打开即可继续看。 输出写 ml/runs/console.log,网页实时显示;「停止」结束整个进程树。网页和控制台窗口关掉都不影响训练,重新打开即可继续看。
只读数据来源: 只读数据来源:
ml/runs/pipeline.json 各阶段开始/结束/返回码(pipeline.sh 自动写)→ 全流程进度 ml/runs/pipeline.json 各阶段开始/结束/返回码(pipeline.sh 自动写)→ 全流程进度
ml/runs/*/log.jsonl 预训练(base / wta / bench_*)与 APG(apg / apg_r*)逐步日志与闭环评估 ml/runs/*/log.jsonl 预训练(base / wta / bench_*)与 APG(apg / apg_r*)逐步日志与闭环评估
ml/runs/m5_gate_*.json、mine/r*/report.json + gate.json、policy_bench.json、acceptance.json ml/runs/m5_gate_*.json、mine/r*/report.json + gate.json、policy_bench.json、acceptance.json
ml/data/stats.json / consistency.json / r2.json / samples/index.json、nvidia-smi ml/data/stats.json / consistency.json / r2.json / samples/index.json、nvidia-smi
安全:默认只监听 127.0.0.1;--host 0.0.0.0 时其他机器只能看,开始/停止只接受本机请求(--remote-control 放开)。 安全:默认只监听 127.0.0.1;--host 0.0.0.0 时其他机器只能看,开始/停止只接受本机请求(--remote-control 放开)。
2026-10-18 新增(用户要求:训练全程可视化,并在网页上点按钮一键开始)。""" 2026-10-18 新增(用户要求:训练全程可视化,并在网页上点按钮一键开始)。
2026-10-18 第五轮(审查第四轮):
N-7 是否在跑看 ml/runs/pipeline.lock(与终端同一把锁),不再靠"日志 120 s 内有更新"去猜;
N-8 开始/停止要求自定义头 X-Console + 同源 Origin/Sec-Fetch-Site,外站网页发不进来;
N-10 启动自检(训练用 Python 能否 import torch、GPU、node、bash),不过就拒绝「开始训练」并在顶栏标红;
N-11 Windows 子进程用 CREATE_NO_WINDOW 自带隐藏控制台:关掉控制台黑窗口训练照跑,重开控制台可接着看、可停止;
N-9 生成阶段的行数按文件增量统计(只读新增字节),其他阶段不扫 shard;
N-12 停止后的状态改写走 ml.pstate(单一写者、原子替换);N-14 阶段名/估时/权重由 ml.pstate.STAGES 下发。"""
import argparse, glob, json, os, re, shutil, signal, subprocess, sys, threading, time, webbrowser import argparse, glob, json, os, re, shutil, signal, subprocess, sys, threading, time, webbrowser
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from ml import pstate
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs from urllib.parse import urlparse, parse_qs
@@ -88,6 +97,30 @@ def last_rec(name):
return None return None
class LineCount:
"""按文件增量数行:记住每个文件已读到的偏移与行数,只读新增字节(审查 N-9:此前每 5 s 全量重读全部 shard)。"""
def __init__(self): self.c = {}; self.lock = threading.Lock()
def count(self, f):
with self.lock:
try: size = os.path.getsize(f)
except OSError: return 0
off, n = self.c.get(f, (0, 0))
if size < off: off, n = 0, 0
if size > off:
with open(f, 'rb') as fh:
fh.seek(off)
while True:
b = fh.read(1 << 22)
if not b: break
n += b.count(b'\n'); off += len(b)
self.c[f] = (off, n)
return n
LC = LineCount()
def newer(pattern, t0): def newer(pattern, t0):
return [f for f in glob.glob(os.path.join(ROOT, pattern)) if os.path.getmtime(f) >= t0 - 1] return [f for f in glob.glob(os.path.join(ROOT, pattern)) if os.path.getmtime(f) >= t0 - 1]
@@ -96,9 +129,7 @@ def stage_progress(st, s):
"""当前阶段的进度:返回 {'frac': 0..1 或 None(无法计量), 'label': 文字}。""" """当前阶段的进度:返回 {'frac': 0..1 或 None(无法计量), 'label': 文字}。"""
t0, info = s['start'], s.get('info') or {} t0, info = s['start'], s.get('info') or {}
if st == 'gen': if st == 'gen':
n = 0 n = sum(LC.count(f) for f in newer(f'{DATA}/shard_*.jsonl', t0) + newer(f'{DATA}/shard_*.jsonl.part', t0))
for f in newer(f'{DATA}/shard_*.jsonl', t0):
with open(f, 'rb') as fh: n += sum(1 for _ in fh)
tgt = info.get('target_tasks') or 0 tgt = info.get('target_tasks') or 0
return {'frac': min(1, n / tgt) if tgt else None, 'label': f'已生成 {n:,} / {tgt:,} 条任务'} return {'frac': min(1, n / tgt) if tgt else None, 'label': f'已生成 {n:,} / {tgt:,} 条任务'}
if st == 'prep': if st == 'prep':
@@ -154,15 +185,16 @@ def status():
stats = jload(P(DATA, 'stats.json')) stats = jload(P(DATA, 'stats.json'))
if stats: stats = {k: stats.get(k) for k in ('tasks', 'keep', 'gapDrop', 'minGapFilterMm', 'keepRate', 'samplesEvery3', 'samplesPerTask', 'projectedSamplesFor50k', 'consensusDrop', 'vertexMinGapMm', 'execS', 'travelDeg')} if stats: stats = {k: stats.get(k) for k in ('tasks', 'keep', 'gapDrop', 'minGapFilterMm', 'keepRate', 'samplesEvery3', 'samplesPerTask', 'projectedSamplesFor50k', 'consensusDrop', 'vertexMinGapMm', 'execS', 'travelDeg')}
shards = glob.glob(P(DATA, 'shard_*.jsonl')) shards = glob.glob(P(DATA, 'shard_*.jsonl'))
gen_lines = 0 pl = pipeline()
for s in shards: if pl and pl.get('current') == 'gen': # 只在生成阶段数行(增量);其他阶段用 stats.json 的 tasks
try: gen_lines = sum(LC.count(f) for f in shards + glob.glob(P(DATA, 'shard_*.jsonl.part')))
with open(s, 'rb') as fh: gen_lines += sum(1 for _ in fh) else: gen_lines = (stats or {}).get('tasks') or 0
except Exception: pass
pb = jload(P(RUNS, 'policy_bench.json')) pb = jload(P(RUNS, 'policy_bench.json'))
if pb: pb = {k: {kk: v.get(kk) for kk in ('n', 'ok', 'reached', 'collisionFrames', 'brakeRate', 'recoveryRate', 'livelock', 'inferMsP99', 'safetyMsP99')} if pb: pb = {k: {kk: v.get(kk) for kk in ('n', 'ok', 'reached', 'collisionFrames', 'brakeRate', 'recoveryRate', 'livelock', 'inferMsP99', 'safetyMsP99')}
for k, v in pb.items() if isinstance(v, dict) and 'n' in v} for k, v in pb.items() if isinstance(v, dict) and 'n' in v}
return {'now': time.time(), 'gpu': gpu(), 'pipeline': pipeline(), return {'now': time.time(), 'gpu': gpu(), 'pipeline': pl, 'check': CHECK.get(), 'lock': lock_info(),
'stage_meta': [{'id': k, 'name': n, 'est_h': h, 'timeout_h': pstate.TIMEOUT_H[k]} for k, n, h in pstate.STAGES],
'r4': jload(P(RUNS, 'r4.json')),
'data': {'consistency': (jload(P(DATA, 'consistency.json')) or {}).get('consistentByGateRule'), 'data': {'consistency': (jload(P(DATA, 'consistency.json')) or {}).get('consistentByGateRule'),
'gen_tasks_written': gen_lines, 'shards': len(shards), 'stats': stats, 'gen_tasks_written': gen_lines, 'shards': len(shards), 'stats': stats,
'index': jload(P(DATA, 'samples', 'index.json')), 'r2': jload(P(DATA, 'r2.json'))}, 'index': jload(P(DATA, 'samples', 'index.json')), 'r2': jload(P(DATA, 'r2.json'))},
@@ -171,9 +203,10 @@ def status():
'mine': mine, 'bench': pb, 'acceptance': jload(P(RUNS, 'acceptance.json'))} 'mine': mine, 'bench': pb, 'acceptance': jload(P(RUNS, 'acceptance.json'))}
STAGES = ['setup', 'kin', 'gate1', 'gen', 'prep', 'train', 'm5', 'apg', 'mine', 'export', 'bench', 'accept'] STAGES = pstate.ORDER
ENV_KEYS = {'N_TASKS': r'\d+', 'NPROC': r'\d+', 'TARGET_SAMPLES': r'\d+', 'CFG': r'ml/configs/(base|wta)\.json', ENV_KEYS = {'N_TASKS': r'\d+', 'NPROC': r'\d+', 'TARGET_SAMPLES': r'\d+', 'CFG': r'ml/configs/(base|wta|small)\.json',
'CONSENSUS': r'[01]?', 'ROUNDS': r'\d+', 'APG_STEPS': r'\d+', 'MINE_N': r'\d+'} 'CONSENSUS': r'[01]?', 'ROUNDS': r'\d+', 'APG_STEPS': r'\d+', 'MINE_N': r'\d+', 'MIN_GAP_MM': r'\d+(\.\d+)?',
'FRESH': r'[01]', 'R2_STRICT': r'[01]', 'AUTO_WTA': r'[01]'}
CONSOLE = os.path.join(ROOT, RUNS, 'console.log') CONSOLE = os.path.join(ROOT, RUNS, 'console.log')
@@ -191,6 +224,56 @@ def find_bash():
return shutil.which('bash') return shutil.which('bash')
def train_python():
"""流水线用的 Python:尊重 PYTHON 环境变量(与 console.sh / console.bat 一致),否则用控制台自己的解释器。"""
return os.environ.get('PYTHON') or sys.executable
class SelfCheck:
"""启动自检(审查 N-10):在后台线程跑一次,结果缓存;「开始训练」前不过就拒绝,网页顶栏显示。"""
def __init__(self): self.r = {'pending': True}; threading.Thread(target=self.run, daemon=True).start()
def run(self):
py = train_python(); r = {'python': py, 'bash': find_bash(), 'node': shutil.which('node')}
try:
o = subprocess.run([py, '-c', 'import json,torch;c=torch.cuda.is_available();print(json.dumps({"torch":torch.__version__,"cuda":c,"gpu":torch.cuda.get_device_name(0) if c else None}))'],
capture_output=True, text=True, timeout=120)
r.update(json.loads(o.stdout.strip().splitlines()[-1]) if o.returncode == 0 else {'torch': None, 'torch_error': (o.stderr or o.stdout).strip()[-300:]})
except Exception as e: r.update(torch=None, torch_error=str(e))
probs = []
if not r.get('torch'): probs.append(f'这个 Python 没有 torch:{py}(先激活训练环境再启动控制台,或设 PYTHON=...)')
if not r['bash']: probs.append('找不到 bash:Windows 请安装 Git for Windows(自带 Git Bash)')
if not r['node']: probs.append('找不到 node')
if r.get('torch') and not r.get('cuda'): r['warn'] = 'torch 看不到 GPU,训练会极慢'
r['problems'] = probs; r['ok'] = not probs
self.r = r
def get(self): return self.r
def lock_info():
lk = pstate.lock_read()
if not lk: return None
return dict(lk, alive=pstate.lock_alive(lk), age_s=time.time() - lk.get('beat', 0))
def kill_session(sid):
"""POSIX:结束会话 sid 里的所有进程(含 timeout 另起的进程组)。"""
pids = []
for d in os.listdir('/proc') if os.path.isdir('/proc') else []:
if d.isdigit():
try:
if os.getsid(int(d)) == sid: pids.append(int(d))
except Exception: pass
if not pids:
try: os.killpg(sid, signal.SIGTERM)
except Exception: pass
return
for p in pids:
try: os.kill(p, signal.SIGTERM)
except Exception: pass
class Job: class Job:
def __init__(self): def __init__(self):
self.p = None; self.info = {}; self.lock = threading.Lock() self.p = None; self.info = {}; self.lock = threading.Lock()
@@ -200,55 +283,80 @@ class Job:
run = self.p is not None and self.p.poll() is None run = self.p is not None and self.p.poll() is None
if self.p is not None and not run and self.info.get('rc') is None: if self.p is not None and not run and self.info.get('rc') is None:
self.info['rc'] = self.p.returncode; self.info['ended'] = time.time() self.info['rc'] = self.p.returncode; self.info['ended'] = time.time()
return {'running': run, **self.info, 'bash': find_bash() is not None} lk = lock_info()
ext = bool(lk and lk['alive']) and not run # 终端或上一个控制台窗口起的流水线
return {'running': run or ext, 'external': ext, 'lock': lk, **self.info, 'bash': find_bash() is not None}
def start(self, frm, to, env): def start(self, frm, to, env):
with self.lock: with self.lock:
if self.p is not None and self.p.poll() is None: return {'error': '已经在运行'} if self.p is not None and self.p.poll() is None: return {'error': '已经在运行'}
pj = jload(os.path.join(ROOT, RUNS, 'pipeline.json')) or {} lk = lock_info()
cur = pj.get('current') if lk and lk['alive']:
if cur and time.time() - (pj['stages'].get(cur, {}).get('start') or 0) < 7 * 86400 and not self.p: return {'error': f"已有一条流水线在运行({lk.get('owner')},pid {lk.get('pid')},{lk['age_s']:.0f} s 前心跳);先停止它"}
# 终端里另起的流水线还在跑(或上次被强杀没写结束):看有没有最近更新的日志 ck = CHECK.get()
fresh = [r for r in list_runs() if time.time() - r['mtime'] < 120] if ck.get('pending'): return {'error': '环境自检还没完成,稍等几秒再点'}
if fresh: return {'error': f'检测到终端里正在运行阶段 {cur}(日志 {fresh[0]["name"]} 2 分钟内有更新),请先在终端结束它'} if not ck.get('ok'): return {'error': '环境自检未通过:' + ';'.join(ck['problems'])}
if frm not in STAGES or to not in STAGES or STAGES.index(frm) > STAGES.index(to): return {'error': '起止阶段无效'} if frm not in STAGES or to not in STAGES or STAGES.index(frm) > STAGES.index(to): return {'error': '起止阶段无效'}
bash = find_bash() bash = find_bash()
if not bash: return {'error': '找不到 bash:Windows 请安装 Git for Windows(自带 Git Bash)'} e = dict(os.environ, FROM=frm, TO=to, MONITOR='0', PYTHONUNBUFFERED='1', PYTHONIOENCODING='utf-8', PIPE_OWNER='网页')
e = dict(os.environ, FROM=frm, TO=to, MONITOR='0', PYTHONUNBUFFERED='1', PYTHONIOENCODING='utf-8') py = train_python()
if ' ' not in sys.executable: e['PYTHON'] = sys.executable.replace('\\', '/') # 与控制台同一个 Python if ' ' not in py: e['PYTHON'] = py.replace('\\', '/')
for k, v in (env or {}).items(): for k, v in (env or {}).items():
if k in ENV_KEYS and v not in (None, '') and re.fullmatch(ENV_KEYS[k], str(v)): e[k] = str(v) if k in ENV_KEYS and v not in (None, '') and re.fullmatch(ENV_KEYS[k], str(v)): e[k] = str(v)
os.makedirs(os.path.dirname(CONSOLE), exist_ok=True) os.makedirs(os.path.dirname(CONSOLE), exist_ok=True)
with open(CONSOLE, 'w', encoding='utf-8') as f: f.write(f'$ FROM={frm} TO={to} bash ml/pipeline.sh all [{time.strftime("%Y-%m-%d %H:%M:%S")}]\n') shown = ' '.join(f'{k}={e[k]}' for k in ('FRESH', 'FROM', 'TO') if k in e)
with open(CONSOLE, 'w', encoding='utf-8') as f: f.write(f'$ {shown} bash ml/pipeline.sh all [{time.strftime("%Y-%m-%d %H:%M:%S")}]\n')
log = open(CONSOLE, 'a', encoding='utf-8') # 追加模式:网页写入的"已停止"提示不会被子进程覆盖 log = open(CONSOLE, 'a', encoding='utf-8') # 追加模式:网页写入的"已停止"提示不会被子进程覆盖
kw = {'creationflags': subprocess.CREATE_NEW_PROCESS_GROUP} if os.name == 'nt' else {'start_new_session': True} # Windows:CREATE_NO_WINDOW 给子进程树一个隐藏的独立控制台 → 关掉控制台黑窗口不会连带杀训练(审查 N-11)
kw = {'creationflags': subprocess.CREATE_NEW_PROCESS_GROUP | 0x08000000} if os.name == 'nt' else {'start_new_session': True}
self.p = subprocess.Popen([bash, 'ml/pipeline.sh', 'all'], cwd=ROOT, env=e, stdout=log, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, **kw) self.p = subprocess.Popen([bash, 'ml/pipeline.sh', 'all'], cwd=ROOT, env=e, stdout=log, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, **kw)
self.info = {'pid': self.p.pid, 'from': frm, 'to': to, 'started': time.time(), 'rc': None, 'ended': None, self.info = {'pid': self.p.pid, 'from': frm, 'to': to, 'started': time.time(), 'rc': None, 'ended': None,
'env': {k: e[k] for k in ENV_KEYS if k in e}} 'env': {k: e[k] for k in ENV_KEYS if k in e}}
return {'ok': True, 'pid': self.p.pid} return {'ok': True, 'pid': self.p.pid}
def stop(self): def stop(self):
# 2026-10-18 体面停止:训练类阶段先写 ml/runs/STOP,等训练进程自己存 last.pt 并退出(最多 90 s),再硬杀剩下的。
# Windows 的 taskkill /F 收不到信号,不这样做会丢掉上次存档之后的进度。生成/打包本身按 shard/条断点续,直接杀。
with self.lock:
ours = self.p is not None and self.p.poll() is None
lk = lock_info()
if not ours and not (lk and lk['alive']): return {'error': '没有在运行'}
pid = self.p.pid if ours else int(lk.get('pid') or 0)
if not pid: return {'error': '锁里没有 pid,无法停止'}
cur = (pstate.load().get('current') or '')
if cur in ('train', 'm5', 'apg', 'mine'):
open(STOPF, 'w').write(time.strftime('%H:%M:%S'))
with open(CONSOLE, 'a', encoding='utf-8') as f: f.write(f'\n[停止请求已发出,等待 {cur} 存档 last.pt…]\n')
for _ in range(180):
time.sleep(0.5)
l2 = pstate.lock_read()
if (self.p is not None and self.p.poll() is not None) if ours else not (l2 and pstate.lock_alive(l2)): break
with self.lock: with self.lock:
if self.p is None or self.p.poll() is not None: return {'error': '没有在运行'}
PJ = os.path.join(ROOT, RUNS, 'pipeline.json')
cur = (jload(PJ) or {}).get('current') # 先记下正在跑的阶段:被杀的子进程可能以返回码 0 写结束
if os.name == 'nt': if os.name == 'nt':
subprocess.run(['taskkill', '/T', '/F', '/PID', str(self.p.pid)], capture_output=True) subprocess.run(['taskkill', '/T', '/F', '/PID', str(pid)], capture_output=True)
else: else:
try: os.killpg(self.p.pid, signal.SIGTERM) if ours:
except ProcessLookupError: pass kill_session(pid)
try: self.p.wait(10) try: self.p.wait(15)
except subprocess.TimeoutExpired: os.killpg(self.p.pid, signal.SIGKILL) except subprocess.TimeoutExpired: kill_session(pid); self.p.kill()
else: # 只动它自己的会话 / 进程组,绝不按会话杀到用户的终端
try:
if os.getsid(pid) == pid: kill_session(pid)
elif os.getpgid(pid) == pid: os.killpg(pid, signal.SIGTERM)
else: os.kill(pid, signal.SIGTERM)
except ProcessLookupError: pass
self.info['stopped'] = True self.info['stopped'] = True
with open(CONSOLE, 'a', encoding='utf-8') as f: f.write(f'\n[已在网页上停止 {time.strftime("%H:%M:%S")}]\n') with open(CONSOLE, 'a', encoding='utf-8') as f: f.write(f'\n[已在网页上停止 {time.strftime("%H:%M:%S")}]\n')
time.sleep(0.5); pj = jload(PJ) time.sleep(0.5)
if pj and cur and cur in pj['stages']: # 该阶段记为"已停止"(返回码 -15) pstate.mark_stopped(-15) # 当前阶段记为"已停止"、释放锁(单一写者)
pj['stages'][cur].update(end=time.time(), rc=-15); pj['current'] = None try: os.remove(STOPF)
with open(PJ, 'w', encoding='utf-8') as f: json.dump(pj, f, ensure_ascii=False, indent=1) except FileNotFoundError: pass
return {'ok': True} return {'ok': True}
STOPF = os.path.join(ROOT, 'ml', 'runs', 'STOP') # 与 ml/common/ckpt.py 的 STOP 同一文件
JOB = Job() JOB = Job()
CHECK = None # main() 里创建(自检要等 find_bash 定义完)
def console(offset): def console(offset):
@@ -287,9 +395,18 @@ class H(BaseHTTPRequestHandler):
u = urlparse(self.path) u = urlparse(self.path)
if not (self.server.remote_control or self.client_address[0] in ('127.0.0.1', '::1')): if not (self.server.remote_control or self.client_address[0] in ('127.0.0.1', '::1')):
return self.send(403, json.dumps({'error': '开始/停止只接受本机操作'}), 'application/json') return self.send(403, json.dumps({'error': '开始/停止只接受本机操作'}), 'application/json')
# 审查 N-8:本机任意网页都能向 localhost 发 POST(源地址同为 127.0.0.1)。要求自定义头(跨站请求会触发 CORS 预检,
# 本服务不应答 OPTIONS → 浏览器不发),并且 Origin / Sec-Fetch-Site 若存在必须同源。
org, sfs, host = self.headers.get('Origin'), self.headers.get('Sec-Fetch-Site'), self.headers.get('Host', '')
if self.headers.get('X-Console') != '1' or (sfs and sfs not in ('same-origin', 'none')) or \
(org and urlparse(org).netloc not in (host, f'localhost:{self.server.server_port}', f'127.0.0.1:{self.server.server_port}')):
return self.send(403, json.dumps({'error': '拒绝:非本控制台页面发起的请求'}), 'application/json')
try: try:
n = int(self.headers.get('Content-Length') or 0); body = json.loads(self.rfile.read(n) or b'{}') n = int(self.headers.get('Content-Length') or 0); body = json.loads(self.rfile.read(n) or b'{}')
if u.path == '/api/start': r = JOB.start(body.get('from', 'kin'), body.get('to', 'accept'), body.get('env') or {}) if u.path == '/api/start':
env = dict(body.get('env') or {})
if body.get('fresh'): env['FRESH'] = '1'
r = JOB.start(body.get('from', 'kin'), body.get('to', 'accept'), env)
elif u.path == '/api/stop': r = JOB.stop() elif u.path == '/api/stop': r = JOB.stop()
else: return self.send(404, 'not found', 'text/plain') else: return self.send(404, 'not found', 'text/plain')
self.send(200 if 'ok' in r else 409, json.dumps(r, ensure_ascii=False), 'application/json') self.send(200 if 'ok' in r else 409, json.dumps(r, ensure_ascii=False), 'application/json')
@@ -303,9 +420,11 @@ def main():
ap.add_argument('--open', action='store_true', help='启动后自动打开浏览器') ap.add_argument('--open', action='store_true', help='启动后自动打开浏览器')
ap.add_argument('--remote-control', action='store_true', help='允许其他机器点开始/停止(默认只能看)') ap.add_argument('--remote-control', action='store_true', help='允许其他机器点开始/停止(默认只能看)')
a = ap.parse_args() a = ap.parse_args()
global CHECK
CHECK = SelfCheck()
srv = ThreadingHTTPServer((a.host, a.port), H); srv.remote_control = a.remote_control srv = ThreadingHTTPServer((a.host, a.port), H); srv.remote_control = a.remote_control
url = f'http://localhost:{a.port}/' url = f'http://localhost:{a.port}/'
print(f'训练控制台:{url} (关闭本窗口会停止控制台;已开始的训练会一并结束,先在网页上确认跑完)', flush=True) print(f'训练控制台:{url} (关闭本窗口只关控制台,已开始的训练继续跑;重新打开控制台即可接着看、可停止)', flush=True)
if a.open: threading.Timer(0.8, lambda: webbrowser.open(url)).start() if a.open: threading.Timer(0.8, lambda: webbrowser.open(url)).start()
try: srv.serve_forever() try: srv.serve_forever()
except KeyboardInterrupt: pass except KeyboardInterrupt: pass
+134 -63
View File
@@ -2,81 +2,89 @@
# 一条龙:bash ml/pipeline.sh <阶段> ;阶段按顺序: # 一条龙:bash ml/pipeline.sh <阶段> ;阶段按顺序:
# setup 安装 Python/Node 依赖(torch 需先按 CUDA 版本装好) # setup 安装 Python/Node 依赖(torch 需先按 CUDA 版本装好)
# kin 导出运动学/碰撞模型 + JS↔PyTorch 对拍(必须 PASS) # kin 导出运动学/碰撞模型 + JS↔PyTorch 对拍(必须 PASS)
# r4 浏览器推理延迟摸底(随机初始化模型导出 ONNX;训练前做,不过就改模型规模,不必等训完) # r4 浏览器推理延迟(随机初始化模型导出到 ml/runs/r4/,无头 Edge/Chrome 自动测 → ml/runs/r4.json;不过就停,改模型规模)
# gate1 老师一致性门禁(决定生成时是否加 --consensus 4) # gate1 老师一致性门禁(决定生成时是否加 --consensus 4)
# gen 生成老师轨迹(NPROC 进程;CONSENSUS=1 时加共识过滤;样本不足 TARGET_SAMPLES 自动补生成) # gen 生成老师轨迹(NPROC 进程;CONSENSUS=1 时加共识过滤;样本不足 TARGET_SAMPLES 自动补生成)
# prep 打包训练样本 + R2 标签一致性检查 # prep 打包训练样本 + R2 标签一致性检查
# train 预训练(先 2000 步摸吞吐,再正式训练);输出目录随头分开:ml/runs/<CFG 名> # train 预训练(先 2000 步摸吞吐,再正式训练);输出目录随头分开:ml/runs/<CFG 名>
# m5 头门禁:贴立柱集穿柱率 ≤ 2%(纯策略口径),同时报仿真安全层口径;通过则记为选定模型 # m5 头门禁:贴立柱集穿柱率 ≤ 2%(纯策略口径),同时报仿真安全层口径;通过则记为选定模型,不过自动训 WTA 头再判
# apg APG 首轮微调(从选定模型开始) # apg APG 首轮微调(从选定模型开始)
# mine 挖错循环(3–6 轮,含门禁回滚) # mine 挖错循环(3–6 轮,含门禁回滚)
# export 导出 ONNX(fp32)到 webapp/models/ # export 导出 ONNX(fp32)到 webapp/models/
# bench 部署形态评测(Node,策略 + 安全层,含 R8 对抗集) # bench 部署形态评测(Node,策略 + 安全层,含 R8 对抗集)
# accept 验收汇总(方案 §9.2 各项 + R2/R7/R8/R10)→ ml/runs/acceptance.json # accept 验收汇总(方案 §9.2 各项 + R2/R7/R8/R10)→ ml/runs/acceptance.json
# all 一键依次执行 kin → accept(FROM=gen TO=train 可只跑一段),并在后台自动打开训练监控(r4 需在浏览器里看,不含在 all 里;R2、M5 不通过会停下) # all 一键无人值守 kin → accept:已完成阶段自动跳过(断了再跑一次即续跑)、阶段超时、门槛自动分支,并在后台打开训练监控
# monitor 训练控制台网页 http://localhost:8100:点「开始训练」一键跑全流程、看进度、可停止(也可双击 ml/console.bat / ml/console.sh) # monitor 训练控制台网页 http://localhost:8100:点「开始训练」一键跑全流程、看进度、可停止(也可双击 ml/console.bat / ml/console.sh)
# 可调环境变量:PYTHON(默认 python;Linux 无 python 时设 PYTHON=python3)、NPROC、N_TASKS、TARGET_SAMPLES、 # 可调环境变量:PYTHON(默认 python;Linux 无 python 时设 PYTHON=python3)、NPROC、N_TASKS、TARGET_SAMPLES、
# CFG(ml/configs/base.json | wta.json)、CKPT(覆盖后续阶段的起点模型)、CONSENSUS # CFG(ml/configs/base.json | wta.json)、CKPT(覆盖后续阶段的起点模型)、CONSENSUS
# 可调(第五轮新增):FRESH=1 从头重跑(旧产物移到 ml/archive/<时间>/,不删;改了生成/模型/挖错参数时只作废受影响的阶段);MIN_GAP_MM(默认 3,gen/prep 共用);
# R2_STRICT=1 R2 不过即停;R4_STRICT=0 R4 不过也继续;AUTO_WTA=0 M5 不过不自动训 WTA;TIMEOUT_SCALE 阶段超时倍数
# 2026-10-18:按另一台机器的审查改——头分目录不互相覆盖、统一 $PYTHON(Windows Git Bash 可跑)、 # 2026-10-18:按另一台机器的审查改——头分目录不互相覆盖、统一 $PYTHON(Windows Git Bash 可跑)、
# gen 自动补量、m5 双口径、export 默认 fp32、新增 accept。 # gen 自动补量、m5 双口径、export 默认 fp32、新增 accept。
set -e -o pipefail # pipefail:`node ... | tee` 里 node 失败也算失败(否则门禁中断会留下残缺的 consistency.json 继续往下跑) # 2026-10-18 第五轮(审查第四轮 N-7/N-10/N-11/N-16 与「无人值守跑完」四个缺口):
# 互斥锁(任何入口同一把)、`all` 自动续跑已完成阶段、阶段超时、R2 默认警告继续、M5 不过自动转 WTA、
# r4 自动探针(独立文件,不碰 webapp/models)、启动前自检 torch。
set -e -o pipefail # pipefail:`node ... | tee` 里 node 失败也算失败
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
PY=${PYTHON:-python} PY=${PYTHON:-python}
command -v "$PY" >/dev/null 2>&1 && "$PY" -c "import sys" 2>/dev/null || { echo "找不到可用的 Python($PY),请设 PYTHON=..."; exit 1; } command -v "$PY" >/dev/null 2>&1 && "$PY" -c "import sys" 2>/dev/null || { echo "找不到可用的 Python($PY),请设 PYTHON=..."; exit 1; }
export PYTHON=$PY export PYTHON=$PY
NPROC=${NPROC:-$(nproc)} export NPROC=${NPROC:-$(nproc)}
N_TASKS=${N_TASKS:-50000} export N_TASKS=${N_TASKS:-50000}
TARGET_SAMPLES=${TARGET_SAMPLES:-1700000} export TARGET_SAMPLES=${TARGET_SAMPLES:-1700000}
CFG=${CFG:-ml/configs/base.json} export CFG=${CFG:-ml/configs/base.json}
export MIN_GAP_MM=${MIN_GAP_MM:-3} # 老师轨迹认证间隙下限:stats.cjs(补量口径)与 build_dataset.cjs(训练集)必须同值(审查 N-1)
export ROUNDS=${ROUNDS:-6} APG_STEPS=${APG_STEPS:-1000} MINE_N=${MINE_N:-50000}
TAG=$(basename "$CFG" .json) TAG=$(basename "$CFG" .json)
SEL=ml/runs/selected_ckpt SEL=ml/runs/selected_ckpt
ckpt() { if [ -n "${CKPT:-}" ]; then echo "$CKPT"; elif [ -f $SEL ]; then cat $SEL; else echo ml/runs/$TAG/best.pt; fi; } ckpt() { if [ -n "${CKPT:-}" ]; then echo "$CKPT"; elif [ -f $SEL ]; then cat $SEL; else echo ml/runs/$TAG/best.pt; fi; }
stage=${1:-help} stage=${1:-help}
run() { echo "== $*"; "$@"; } run() { echo "== $*"; "$@"; }
# 2026-10-18:每个阶段开始/结束写 ml/runs/pipeline.json(监控网页据此显示全流程进度与各步用时) ALL_STAGES="setup kin r4 gate1 gen prep train m5 apg mine export bench accept"
stage_info() { case $1 in stage_info() { case $1 in
gen) echo "target_tasks=$(( (N_TASKS + NPROC - 1) / NPROC * NPROC ))" ;; gen) echo "target_tasks=$(( (N_TASKS + NPROC - 1) / NPROC * NPROC ))" ;;
train) echo "tag=$TAG bench_steps=2000" ;; train) echo "tag=$TAG bench_steps=2000" ;;
m5) echo "tag=$TAG" ;; m5) echo "tag=$TAG" ;;
mine) echo "rounds=${ROUNDS:-6} apg_steps=${APG_STEPS:-1000}" ;; mine) echo "rounds=$ROUNDS apg_steps=$APG_STEPS" ;;
esac; } esac; }
case " setup kin gate1 gen prep train m5 apg mine export bench accept r4 " in *" $stage "*) # ---- 互斥锁(审查 N-7):网页、终端、`all` 同一把锁;`all` 内部调用的子阶段继承(PIPE_LOCKED)
$PY -m ml.pstate begin $stage $(stage_info $stage) || true OWN_LOCK=0; BEAT=; IN_STAGE=0
trap 'rc=$?; $PY -m ml.pstate end '"$stage"' $rc >/dev/null 2>&1 || true' EXIT ;; case " $ALL_STAGES all " in *" $stage "*)
if [ -z "${PIPE_LOCKED:-}" ]; then
# Windows Git Bash 的 $$ 是 msys pid,taskkill 要 Windows pid(/proc/$$/winpid)
$PY -m ml.pstate lock acquire "${PIPE_OWNER:-终端}:$stage" "$(cat /proc/$$/winpid 2>/dev/null || echo $$)" || exit 9
export PIPE_LOCKED=1; OWN_LOCK=1
PARENT=$$
( while sleep 60; do kill -0 $PARENT 2>/dev/null || exit 0; $PY -m ml.pstate lock beat >/dev/null 2>&1 || true; done ) &
BEAT=$!
fi ;;
esac esac
case $stage in case " $ALL_STAGES " in *" $stage "*)
setup) run $PY -m pip install -r ml/requirements.txt; (cd ml && npm install --no-audit --no-fund) ;; $PY -m ml.pstate begin $stage $(stage_info $stage); IN_STAGE=1 ;;
kin) run node ml/prep/export_kin.cjs; run node ml/eval/dump_parity.cjs 2000; run $PY -m ml.eval.check_parity ;; esac
gate1) mkdir -p ml/data; run node ml/gen/consistency.cjs 300 4242 | tee ml/data/consistency.json.tmp; mv ml/data/consistency.json.tmp ml/data/consistency.json ;; # 中断时不留残缺结果 on_exit() {
gen) per=$(( (N_TASKS + NPROC - 1) / NPROC )) rc=$?
if [ -z "${CONSENSUS:-}" ] && [ -f ml/data/consistency.json ]; then [ $IN_STAGE = 1 ] && { $PY -m ml.pstate end $stage $rc >/dev/null 2>&1 || true; }
CONSENSUS=$($PY -c "import json;print(1 if json.load(open('ml/data/consistency.json'))['consistentByGateRule']<0.9 else 0)") if [ $OWN_LOCK = 1 ]; then [ -n "$BEAT" ] && kill $BEAT 2>/dev/null; $PY -m ml.pstate lock release || true; fi
echo "一致性门禁结果 → CONSENSUS=$CONSENSUS" exit $rc
fi }
extra=""; [ "${CONSENSUS:-0}" = "1" ] && extra="--consensus 4" trap on_exit EXIT
run bash ml/gen/run_parallel.sh $per $NPROC 1 "$extra" trap 'exit 143' TERM INT HUP # 被停止 / 超时:先走 on_exit 记返回码、放锁
# 补量:共识过滤会降低保留率,样本不足 TARGET_SAMPLES 时按实测"样本/任务"补生成(最多 2 次) # ---- 训练与门禁的单元(m5 自动分支要对两种头各调一次)
s0=$((1 + NPROC)) train_one() { # $1 = 配置文件
for k in 1 2; do local cfg=$1 tag; tag=$(basename "$1" .json)
need=$($PY -c "import json,math;s=json.load(open('ml/data/stats.json'));d=$TARGET_SAMPLES-s['samplesEvery3'];print(0 if d<=0 else math.ceil(d/(s['samplesEvery3']/s['tasks'])*1.05))") if [ -f ml/runs/bench_$tag/last.pt ]; then echo "吞吐摸底 ml/runs/bench_$tag 已有,跳过"
[ "$need" = "0" ] && break else run $PY -m ml.train --samples ml/data/samples --out ml/runs/bench_$tag --steps 2000 --no-eval --config $cfg; fi
echo "样本不足目标 $TARGET_SAMPLES,补生成 $need 条任务" local res=""; [ -f ml/runs/$tag/last.pt ] && { res="--resume ml/runs/$tag/last.pt"; echo "发现 ml/runs/$tag/last.pt → 续训"; }
per2=$(( (need + NPROC - 1) / NPROC )) run $PY -m ml.train --samples ml/data/samples --out ml/runs/$tag --config $cfg $res
$PY -m ml.pstate set gen target_tasks=$(( $($PY -c "import json;print(json.load(open('ml/runs/pipeline.json'))['stages']['gen']['info']['target_tasks'])") + per2 * NPROC )) || true }
run bash ml/gen/run_parallel.sh $per2 $NPROC $s0 "$extra" m5_one() { # $1 = 头标签;返回 0 通过 / 3 不通过
s0=$((s0 + NPROC)) local tag=$1 ck=${CKPT:-ml/runs/$1/best.pt} o
done ;; [ -f "$ck" ] || { echo "M5:$ck 不存在(训练发散或没训完)→ 按不通过处理"; return 3; }
prep) run bash ml/prep/run_prep.sh $NPROC # 断点续跑:结果文件比检查点新 → 这一半已经评过,跳过
run $PY -m ml.eval.check_r2 --samples ml/data/samples --out ml/data/r2.json o=ml/runs/m5_${tag}_policy.json; [ -f $o ] && [ $o -nt "$ck" ] && echo "M5:$o 已有,跳过" || run $PY -m ml.sim.closed_loop --ckpt $ck --tasks ml/runs/nc_gate.jsonl --n 1000 --out $o
# R2 明确不通过(pass=false)就停在这里,不让一键流程白训 3 小时;近邻对不足(pass=null)不拦 o=ml/runs/m5_${tag}_safety.json; [ -f $o ] && [ $o -nt "$ck" ] && echo "M5:$o 已有,跳过" || run $PY -m ml.sim.closed_loop --ckpt $ck --tasks ml/runs/nc_gate.jsonl --n 1000 --safety --out $o
$PY -c "import json,sys;sys.exit(4 if json.load(open('ml/data/r2.json')).get('pass') is False else 0)" || { echo "R2 不通过:近邻样本标签不自洽,先把 ml/data/r2.json 发我;确认要继续就从「预训练」开始(FROM=train)"; exit 4; } ;; $PY - "$tag" "$ck" <<'PY'
train) run $PY -m ml.train --samples ml/data/samples --out ml/runs/bench_$TAG --steps 2000 --no-eval --config $CFG
run $PY -m ml.train --samples ml/data/samples --out ml/runs/$TAG --config $CFG ;;
m5) [ -f ml/runs/nc_gate.jsonl ] || run $PY -m ml.mining.mine_round --make-gate 1000 --seed 4321 --mix 0,0,1,0 --name nc_gate.jsonl --out ml/runs
ck=${CKPT:-ml/runs/$TAG/best.pt}
run $PY -m ml.sim.closed_loop --ckpt $ck --tasks ml/runs/nc_gate.jsonl --n 1000 --out ml/runs/m5_${TAG}_policy.json
run $PY -m ml.sim.closed_loop --ckpt $ck --tasks ml/runs/nc_gate.jsonl --n 1000 --safety --out ml/runs/m5_${TAG}_safety.json
$PY - "$TAG" "$ck" <<'PY'
import json, sys import json, sys
tag, ck = sys.argv[1], sys.argv[2] tag, ck = sys.argv[1], sys.argv[2]
p = json.load(open(f'ml/runs/m5_{tag}_policy.json')); s = json.load(open(f'ml/runs/m5_{tag}_safety.json')) p = json.load(open(f'ml/runs/m5_{tag}_policy.json')); s = json.load(open(f'ml/runs/m5_{tag}_safety.json'))
@@ -88,36 +96,99 @@ out = {'head': tag, 'ckpt': ck, 'pass': ok,
json.dump(out, open(f'ml/runs/m5_gate_{tag}.json', 'w'), indent=1) json.dump(out, open(f'ml/runs/m5_gate_{tag}.json', 'w'), indent=1)
print('[%s] 纯策略穿柱率 %.2f%%、到达 %.2f%%;加仿真安全层 穿柱 %.2f%%、到达 %.2f%%' % ( print('[%s] 纯策略穿柱率 %.2f%%、到达 %.2f%%;加仿真安全层 穿柱 %.2f%%、到达 %.2f%%' % (
tag, 100 * p['collision_rate'], 100 * p['success'], 100 * s['collision_rate'], 100 * s['success'])) tag, 100 * p['collision_rate'], 100 * p['success'], 100 * s['collision_rate'], 100 * s['success']))
if ok: if ok: open('ml/runs/selected_ckpt', 'w').write(ck); print('M5 门禁:通过 → 后续阶段使用', ck)
open('ml/runs/selected_ckpt', 'w').write(ck); print('M5 门禁:通过 → 后续阶段使用', ck) sys.exit(0 if ok else 3)
else:
print('M5 门禁:不通过' + (' → CFG=ml/configs/wta.json bash ml/pipeline.sh train && CFG=ml/configs/wta.json bash ml/pipeline.sh m5' if tag != 'wta' else '(两种头都未过,把 m5_gate_*.json 发我)'))
sys.exit(3)
PY PY
;; }
apg) ck=$(ckpt); run $PY -m ml.apg.apg_finetune --init $ck --samples ml/data/samples --out ml/runs/apg; cp ml/runs/apg/best.pt ml/runs/current.pt ;; case $stage in
mine) run bash ml/mining/loop.sh ${ROUNDS:-6} ${APG_STEPS:-1000} ${MINE_N:-50000} ;; setup) run $PY -m pip install -r ml/requirements.txt; (cd ml && npm install --no-audit --no-fund) ;;
r4) run $PY -m ml.export_onnx --random $CFG --out webapp/models/traj_policy.onnx kin) run node ml/prep/export_kin.cjs; run node ml/eval/dump_parity.cjs 2000; run $PY -m ml.eval.check_parity ;;
echo "下一步:python ml/serve.py → 浏览器打开 http://localhost:8000/?policy=1 → 拖动黄球 1 分钟 → 控制台 __POLICY.latency()" ;; r4) # 浏览器推理延迟(方案 R4):随机初始化模型导出到独立文件(不碰 webapp/models,审查 N-5),无头浏览器自动测
mkdir -p ml/runs/r4
run $PY -m ml.export_onnx --random $CFG --out ml/runs/r4/traj_policy_r4.onnx
run $PY -m ml.eval.r4_probe --model ml/runs/r4/traj_policy_r4.onnx --out ml/runs/r4.json
r=$($PY -c "import json;p=json.load(open('ml/runs/r4.json')).get('pass');print({True:'pass',False:'fail'}.get(p,'null'))")
if [ "$r" = fail ] && [ "${R4_STRICT:-1}" = 1 ]; then
echo "R4 不通过(见 ml/runs/r4.json):部署延迟超标,先别花几十小时训练这个规模的模型。按方案改 CFG=ml/configs/small.json(d=128)重跑;确认要继续设 R4_STRICT=0"; exit 5
fi
[ "$r" = null ] && echo "R4 未判定(没找到浏览器或页面未回报),继续;验收汇总会标红" ;;
gate1) mkdir -p ml/data; run node ml/gen/consistency.cjs 300 4242 | tee ml/data/consistency.json.tmp; mv ml/data/consistency.json.tmp ml/data/consistency.json ;; # 中断时不留残缺结果
gen) per=$(( (N_TASKS + NPROC - 1) / NPROC ))
if [ -z "${CONSENSUS:-}" ] && [ -f ml/data/consistency.json ]; then
CONSENSUS=$($PY -c "import json;print(1 if json.load(open('ml/data/consistency.json'))['consistentByGateRule']<0.9 else 0)")
echo "一致性门禁结果 → CONSENSUS=$CONSENSUS"
fi
extra=""; [ "${CONSENSUS:-0}" = "1" ] && extra="--consensus 4"
run bash ml/gen/run_parallel.sh $per $NPROC 1 "$extra"
# 补量:共识过滤与间隙过滤都会降低保留率,样本不足 TARGET_SAMPLES 时按实测"样本/任务"补生成。
# 最多 4 次(第五轮由 2 改 4:两种过滤叠加时 2 次可能补不满);已写完的补量 shard 续跑时跳过。
s0=$((1 + NPROC))
for k in 1 2 3 4; do
need=$($PY -c "import json,math;s=json.load(open('ml/data/stats.json'));d=$TARGET_SAMPLES-s['samplesEvery3'];print(0 if d<=0 else math.ceil(d/(s['samplesEvery3']/max(1,s['tasks']))*1.05))")
[ "$need" = "0" ] && break
echo "样本不足目标 $TARGET_SAMPLES,补生成 $need 条任务(第 $k 次)"
per2=$(( (need + NPROC - 1) / NPROC ))
old=$($PY -m ml.pstate get gen target_tasks) # 读不到直接失败(审查 N-13:此前空值会被算成 56 这类数)
$PY -m ml.pstate set gen target_tasks=$(( old + per2 * NPROC ))
run bash ml/gen/run_parallel.sh $per2 $NPROC $s0 "$extra"
s0=$((s0 + NPROC))
done
$PY -c "import json;s=json.load(open('ml/data/stats.json'));import sys;sys.exit(0 if s['samplesEvery3']>=0.9*$TARGET_SAMPLES else 7)" || echo "警告:补 4 次后样本仍不足目标的 90%,按现有样本继续(stats.json 见 samplesEvery3)" ;;
prep) # 间隙过滤口径必须与生成时一致(审查 N-1)
$PY -c "import json,sys;m=json.load(open('ml/data/stats.json')).get('minGapFilterMm');sys.exit(0 if m is None or float(m)==float($MIN_GAP_MM) else 8)" \
|| { echo "MIN_GAP_MM=$MIN_GAP_MM 与生成阶段 stats.json 的 minGapFilterMm 不一致:补量口径与训练集会分叉,请用同一值重跑 gen 或 prep"; exit 8; }
# 已打包完的 shard 跳过(断点续跑,见 run_prep.sh);参数变了由 pstate reset 把旧 samples 移到 ml/archive/
run bash ml/prep/run_prep.sh $NPROC
run $PY -m ml.eval.check_r2 --samples ml/data/samples --out ml/data/r2.json
# 第五轮(审查 N-2):R2 判据与搜索池大小有关、阈值未经实测校准 → 默认只警告继续,R2_STRICT=1 才拦
if ! $PY -c "import json,sys;sys.exit(4 if json.load(open('ml/data/r2.json')).get('pass') is False else 0)"; then
if [ "${R2_STRICT:-0}" = 1 ]; then echo "R2 不通过(R2_STRICT=1):停在这里,把 ml/data/r2.json 发我"; exit 4; fi
echo "警告:R2 不通过(近邻标签不自洽,见 ml/data/r2.json)。按默认继续训练,验收汇总会列出"
fi ;;
train) train_one $CFG ;;
m5) [ -f ml/runs/nc_gate.jsonl ] || run $PY -m ml.mining.mine_round --make-gate 1000 --seed 4321 --mix 0,0,1,0 --name nc_gate.jsonl --out ml/runs
if m5_one $TAG; then :
elif [ "$TAG" != wta ] && [ "${AUTO_WTA:-1}" = 1 ] && [ -z "${CKPT:-}" ]; then
# 第五轮(缺口 3):base 头不过 → 流程内自动训 WTA 头再门禁一次,两种头都不过才停
echo "M5:$TAG 头不通过 → 自动训练 WTA 回退头(约 3 小时)"
$PY -m ml.pstate set m5 branch=wta
train_one ml/configs/wta.json
m5_one wta || { echo "M5:两种头都未通过,把 ml/runs/m5_gate_*.json 发我"; exit 3; }
else echo "M5:不通过,把 ml/runs/m5_gate_*.json 发我"; exit 3; fi ;;
apg) ck=$(ckpt); res=""; [ -f ml/runs/apg/last.pt ] && { res="--resume ml/runs/apg/last.pt"; echo "发现 ml/runs/apg/last.pt → 续跑"; }
run $PY -m ml.apg.apg_finetune --init $ck --samples ml/data/samples --out ml/runs/apg $res; cp ml/runs/apg/best.pt ml/runs/current.pt ;;
mine) run bash ml/mining/loop.sh $ROUNDS $APG_STEPS $MINE_N ;;
monitor) run $PY -m ml.monitor ${MONITOR_ARGS:-} ;; monitor) run $PY -m ml.monitor ${MONITOR_ARGS:-} ;;
export) ck=ml/runs/current.pt; [ -f $ck ] || ck=$(ckpt) export) ck=ml/runs/current.pt; [ -f $ck ] || ck=$(ckpt)
run $PY -m ml.export_onnx --ckpt $ck --out webapp/models/traj_policy.onnx ;; run $PY -m ml.export_onnx --ckpt $ck --out webapp/models/traj_policy.onnx ;;
bench) run node tests/policy_bench.cjs webapp/models/traj_policy.onnx nc,route,r8 --nc1000 --out ml/runs/policy_bench.json ;; bench) run node tests/policy_bench.cjs webapp/models/traj_policy.onnx nc,route,r8 --nc1000 --out ml/runs/policy_bench.json ;;
accept) ck=ml/runs/current.pt; [ -f $ck ] || ck=$(ckpt) accept) ck=ml/runs/current.pt; [ -f $ck ] || ck=$(ckpt)
run $PY -m ml.eval.acceptance --ckpt $ck --samples ml/data/samples --out ml/runs/acceptance.json ;; run $PY -m ml.eval.acceptance --ckpt $ck --samples ml/data/samples --out ml/runs/acceptance.json ;;
all) # FROM / TO 指定起止阶段(默认 kin → accept),网页控制台的「开始训练」也走这里 all) # 默认 kin → accept;已完成(rc=0)的阶段自动跳过,从第一个没完成的继续。FRESH=1 从头来;FROM/TO 可只跑一段
FROM=${FROM:-kin}; TO=${TO:-accept}; PLAN=""; on=0 FROM=${FROM:-kin}; TO=${TO:-accept}; PLAN=""; on=0
for s in setup kin gate1 gen prep train m5 apg mine export bench accept; do for s in $ALL_STAGES; do
[ "$s" = "$FROM" ] && on=1; [ $on = 1 ] && PLAN="$PLAN $s"; [ "$s" = "$TO" ] && break [ "$s" = "$FROM" ] && on=1; [ $on = 1 ] && PLAN="$PLAN $s"; [ "$s" = "$TO" ] && break
done done
PLAN=${PLAN# }; [ -n "$PLAN" ] || { echo "FROM=$FROM / TO=$TO 不是有效阶段"; exit 1; } PLAN=${PLAN# }; [ -n "$PLAN" ] || { echo "FROM=$FROM / TO=$TO 不是有效阶段"; exit 1; }
echo "计划:$PLAN" # 启动前自检(审查 N-10:不能等到 train 才发现这个 Python 没有 torch)
$PY -m ml.pstate reset ${PLAN// /,} $PY -c "import torch" 2>/dev/null || { echo "当前 Python($($PY -c 'import sys;print(sys.executable)'))没有 torch:请先激活训练环境或设 PYTHON=..."; exit 1; }
command -v node >/dev/null || { echo "找不到 node"; exit 1; }
fl=""; [ "${FRESH:-0}" = 1 ] && fl="--fresh"
$PY -m ml.pstate reset ${PLAN// /,} $fl # 续跑 / 参数变了作废受影响阶段(旧产物移到 ml/archive/<时间>/)
TODO=$($PY -m ml.pstate todo)
echo "计划:$PLAN"; echo "待跑:${TODO:-(全部已完成)}"
# 监控网页随 all 自动在后台启动(MONITOR=0 关闭);端口已被占用说明已经开着,不重复启动 # 监控网页随 all 自动在后台启动(MONITOR=0 关闭);端口已被占用说明已经开着,不重复启动
if [ "${MONITOR:-1}" = "1" ] && $PY -c "import socket,sys;s=socket.socket();sys.exit(0 if s.connect_ex(('127.0.0.1',8100)) else 1)"; then if [ "${MONITOR:-1}" = "1" ] && $PY -c "import socket,sys;s=socket.socket();sys.exit(0 if s.connect_ex(('127.0.0.1',8100)) else 1)"; then
mkdir -p ml/runs; $PY -m ml.monitor ${MONITOR_ARGS:-} > ml/runs/monitor.log 2>&1 & mkdir -p ml/runs; $PY -m ml.monitor ${MONITOR_ARGS:-} > ml/runs/monitor.log 2>&1 &
echo "训练监控已在后台启动:http://localhost:8100/ (全部跑完后仍可查看,关闭终端即结束)" echo "训练监控已在后台启动:http://localhost:8100/"
fi fi
for s in $PLAN; do bash ml/pipeline.sh $s; done ;; for s in $TODO; do
*) sed -n '2,22p' "$0" ;; to=$($PY -m ml.pstate timeout $s)
if command -v timeout >/dev/null; then
# 第五轮(缺口 4):每阶段超时 = 估时 ×3;超时整组进程收到 TERM,阶段记失败、锁释放
timeout -k 120 $to bash ml/pipeline.sh $s || { rc=$?; [ $rc = 124 ] && { echo "阶段 $s 超过 $((to/60)) 分钟上限,判定卡死"; $PY -m ml.pstate set $s timed_out=1; }; exit $rc; }
else bash ml/pipeline.sh $s; fi
done
echo "全流程完成:ml/runs/acceptance.json" ;;
*) sed -n '2,30p' "$0" ;;
esac esac
+3 -1
View File
@@ -74,5 +74,7 @@ writeNpy(path.join(OUTDIR, base + '.Y.npy'), cat(Y, H * 6), [n, H, 6]);
writeNpy(path.join(OUTDIR, base + '.V.npy'), Float32Array.from(V), [n]); writeNpy(path.join(OUTDIR, base + '.V.npy'), Float32Array.from(V), [n]);
writeNpy(path.join(OUTDIR, base + '.SG.npy'), cat(SG, 13), [n, 13]); writeNpy(path.join(OUTDIR, base + '.SG.npy'), cat(SG, 13), [n, 13]);
writeNpy(path.join(OUTDIR, base + '.TID.npy'), TID, [n], 'int32'); writeNpy(path.join(OUTDIR, base + '.TID.npy'), TID, [n], 'int32');
fs.writeFileSync(path.join(OUTDIR, base + '.tasks.jsonl'), tasks.map(t => JSON.stringify(t)).join('\n') + '\n'); // 2026-10-18 断点续跑:tasks.jsonl 最后写、先写 .tmp 再改名 —— 它存在就代表这个 shard 的 5 个 npy 都已写完,run_prep.sh 据此跳过
const tf = path.join(OUTDIR, base + '.tasks.jsonl');
fs.writeFileSync(tf + '.tmp', tasks.map(t => JSON.stringify(t)).join('\n') + '\n'); fs.renameSync(tf + '.tmp', tf);
console.log(JSON.stringify({ shard: base, tasks: tasks.length, samples: n, aug: nAug, augRejected: nAugRej, gapDrop: nGapDrop, featVer: POL.FEAT_VER })); console.log(JSON.stringify({ shard: base, tasks: tasks.length, samples: n, aug: nAug, augRejected: nAugRej, gapDrop: nGapDrop, featVer: POL.FEAT_VER }));
+17 -3
View File
@@ -1,8 +1,22 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# 并行打包样本:bash ml/prep/run_prep.sh [进程数=CPU 核数] # 并行打包样本:bash ml/prep/run_prep.sh [进程数=CPU 核数]
# 2026-10-18 断点续跑:samples/<shard>.tasks.jsonl 存在且比 shard 新 → 该 shard 已打包完,跳过;
# shard 已不存在的旧打包结果删掉(不让上一批数据混进来)。中断后重跑只补没打完的 shard。
set -e set -e
cd "$(dirname "$0")/../.." cd "$(dirname "$0")/../.."
P=${1:-$(nproc)} P=${1:-$(nproc)}
mkdir -p ml/data/samples D=ml/data/samples
ls ml/data/shard_*.jsonl | xargs -P "$P" -I{} node ml/prep/build_dataset.cjs {} ml/data/samples mkdir -p $D
${PYTHON:-python} -m ml.data --index ml/data/samples for t in $D/*.tasks.jsonl; do
[ -e "$t" ] || continue; b=$(basename "$t" .tasks.jsonl)
[ -f ml/data/$b.jsonl ] || { echo "[prep] 删除旧打包 $b(对应 shard 已不存在)"; rm -f $D/$b.*; }
done
todo=()
for f in ml/data/shard_*.jsonl; do
b=$(basename "$f" .jsonl)
if [ -f $D/$b.tasks.jsonl ] && [ $D/$b.tasks.jsonl -nt "$f" ]; then continue; fi
todo+=("$f")
done
echo "[prep] 共 $(ls ml/data/shard_*.jsonl | wc -l) 个 shard,待打包 ${#todo[@]} 个"
[ ${#todo[@]} = 0 ] || printf '%s\n' "${todo[@]}" | xargs -P "$P" -I{} node ml/prep/build_dataset.cjs {} $D
${PYTHON:-python} -m ml.data --index $D
+166 -14
View File
@@ -1,25 +1,77 @@
"""流水线状态文件 ml/runs/pipeline.json(供训练监控网页显示"现在在哪一步、每步用了多久")。 """流水线状态与互斥锁(单一写者:所有对 ml/runs/pipeline.json / pipeline.lock 的写都经过本模块,原子替换)。
pipeline.sh 每个阶段开始/结束时自动调用,不需要手动运行: pipeline.sh 自动调用,一般不需要手动运行:
python -m ml.pstate reset <阶段1,阶段2,...> # `all` 开始时:清空并写入计划 python -m ml.pstate reset <阶段1,...> [--fresh] # `all` 开始:保留 rc=0 的已完成阶段(续跑);参数变了只作废受影响的阶段;--fresh 全部作废
python -m ml.pstate begin <阶段> [键=值 ...] # 阶段开始(值可为数字),如 target_tasks=50000 python -m ml.pstate todo # 打印计划中尚未完成的阶段(空格分隔)
python -m ml.pstate set <阶段> 键=值 ... # 阶段中途更新(如补量后的目标条数) python -m ml.pstate begin <阶段> [键=值 ...] # 阶段开始
python -m ml.pstate set <阶段> 键=值 ... # 阶段中途更新
python -m ml.pstate get <阶段> <键> # 读值;不存在则非零退出(不静默回空)
python -m ml.pstate end <阶段> <返回码> python -m ml.pstate end <阶段> <返回码>
2026-10-18 新增(用户要求:从训练开始到结束都能在监控面板看到进度)。""" python -m ml.pstate timeout <阶段> # 该阶段超时秒数(估时 ×3,TIMEOUT_SCALE 可再乘)
import json, os, sys, time python -m ml.pstate lock acquire <所有者> [pid]|release|beat|show
2026-10-18 第五轮:
- 加锁(审查 N-7):网页 / 终端 / `all` 都走同一把锁 ml/runs/pipeline.lock(pid + 心跳),已有一条在跑时立即拒绝;
心跳超过 LOCK_STALE_S 未刷新视为陈旧(进程被强杀),可接管。
- 续跑(缺口 1):reset 默认保留已完成阶段;参数变了只作废受它影响的阶段(AFFECTS),其产物移到 ml/archive/,避免新旧参数混用。
- 阶段表 STAGES 是估时 / 超时 / 网页进度权重的唯一来源(审查 N-14:网页权重与耗时表同源)。
- 停止也走本模块(审查 N-12:此前 monitor 直写 pipeline.json,与子进程竞态)。"""
import glob, json, os, shutil, socket, sys, time
P = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'ml', 'runs', 'pipeline.json') ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
P = os.path.join(ROOT, 'ml', 'runs', 'pipeline.json')
LOCK = os.path.join(ROOT, 'ml', 'runs', 'pipeline.lock')
LOCK_STALE_S = 300 # 心跳每 60 s 一次;5 分钟没刷新 = 持锁进程已死
# 阶段顺序、名称、估时(小时,按 README §耗时表的共识分支上界;网页进度权重即此值)
STAGES = [
('setup', '安装依赖', 0.2), ('kin', '对拍', 0.2), ('r4', '推理延迟', 0.2), ('gate1', '一致性门禁', 0.5),
('gen', '生成老师轨迹', 5.0), ('prep', '打包样本', 0.5), ('train', '预训练', 3.0), ('m5', '头门禁', 0.5),
('apg', 'APG 微调', 4.0), ('mine', '挖错循环', 9.0), ('export', '导出', 0.1), ('bench', '部署评测', 1.0),
('accept', '验收汇总', 0.5)]
ORDER = [s[0] for s in STAGES]
EST_H = {s[0]: s[2] for s in STAGES}
# m5 不过时流程内自动训 WTA 头(训练 3 h + 门禁),超时按分支后的总量给
TIMEOUT_H = {k: max(1.0, 3 * v) for k, v in EST_H.items()}
TIMEOUT_H['m5'] = 3 * (EST_H['train'] + 2 * EST_H['m5'])
# 参数 → 受它影响的阶段(这些阶段即使上次完成也要重跑,其产物先挪进 ml/archive/)。只改挖错轮数不会让 5 小时的生成作废。
_from = lambda st: ORDER[ORDER.index(st):]
AFFECTS = {**{k: _from('gen') for k in ('N_TASKS', 'NPROC', 'TARGET_SAMPLES', 'CONSENSUS', 'MIN_GAP_MM')},
'CFG': ['r4'] + _from('train'), **{k: _from('mine') for k in ('ROUNDS', 'APG_STEPS', 'MINE_N')}}
PARAM_KEYS = list(AFFECTS)
# 各阶段产物(相对仓库根,可含通配);作废 / 从头重跑时整体移到 ml/archive/<时间>/,不删除
PRODUCTS = {
'r4': ['ml/runs/r4', 'ml/runs/r4.json'], 'gate1': ['ml/data/consistency.json'],
'gen': ['ml/data/shard_*', 'ml/data/log_*', 'ml/data/stats.json'], 'prep': ['ml/data/samples', 'ml/data/r2.json'],
'train': ['ml/runs/base', 'ml/runs/wta', 'ml/runs/small', 'ml/runs/bench_*'],
'm5': ['ml/runs/m5_*', 'ml/runs/selected_ckpt'], 'apg': ['ml/runs/apg', 'ml/runs/current.pt'],
'mine': ['ml/runs/mine', 'ml/runs/apg_r*', 'ml/data/e2', 'ml/data/samples_e2'],
'export': ['webapp/models/traj_policy.onnx', 'webapp/models/traj_policy.meta.json'],
'bench': ['ml/runs/policy_bench.json'], 'accept': ['ml/runs/acceptance.json']}
def archive(stages):
"""把这些阶段的产物移到 ml/archive/<时间>/(保持相对路径),返回目标目录或 None。"""
# 2026-10-18:同一秒内多次归档会撞目录、覆盖旧文件,加序号后缀保证唯一
base = os.path.join(ROOT, 'ml', 'archive', time.strftime('%Y%m%d_%H%M%S')); dst, n = base, 1
while os.path.exists(dst): dst = '%s_%d' % (base, n); n += 1
moved = 0
for st in stages:
for pat in PRODUCTS.get(st, []):
for f in glob.glob(os.path.join(ROOT, pat)):
rel = os.path.relpath(f, ROOT); os.makedirs(os.path.dirname(os.path.join(dst, rel)), exist_ok=True)
shutil.move(f, os.path.join(dst, rel)); moved += 1
return os.path.relpath(dst, ROOT) if moved else None
def load(): def load():
try: try:
with open(P, encoding='utf-8') as f: return json.load(f) with open(P, encoding='utf-8') as f: return json.load(f)
except Exception: except Exception:
return {'started': time.time(), 'plan': None, 'stages': {}, 'current': None} return {'started': time.time(), 'plan': None, 'stages': {}, 'current': None, 'params': {}}
def save(d): def save(d):
os.makedirs(os.path.dirname(P), exist_ok=True) os.makedirs(os.path.dirname(P), exist_ok=True)
tmp = P + '.tmp' tmp = P + '.%d.tmp' % os.getpid()
with open(tmp, 'w', encoding='utf-8') as f: json.dump(d, f, ensure_ascii=False, indent=1) with open(tmp, 'w', encoding='utf-8') as f: json.dump(d, f, ensure_ascii=False, indent=1)
os.replace(tmp, P) os.replace(tmp, P)
@@ -34,16 +86,116 @@ def kv(args):
return out return out
def params_now():
return {k: os.environ.get(k, '') for k in PARAM_KEYS}
# ---------------------------------------------------------------- 锁
def lock_read():
try:
with open(LOCK, encoding='utf-8') as f: return json.load(f)
except Exception:
return None
def lock_alive(lk=None):
lk = lk if lk is not None else lock_read()
return bool(lk) and time.time() - lk.get('beat', 0) < LOCK_STALE_S
def lock_acquire(owner, pid=None):
"""原子创建锁文件;已有活锁则返回 (False, 锁内容)。陈旧锁直接接管。"""
os.makedirs(os.path.dirname(LOCK), exist_ok=True)
now = time.time()
rec = {'pid': pid or os.getppid(), 'owner': owner, 'start': now, 'beat': now, 'host': socket.gethostname()}
for _ in range(2):
try:
fd = os.open(LOCK, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
with os.fdopen(fd, 'w', encoding='utf-8') as f: json.dump(rec, f)
try: os.remove(os.path.join(ROOT, 'ml', 'runs', 'STOP')) # 上次的停止请求不能让这次一开训就退出
except FileNotFoundError: pass
return True, rec
except FileExistsError:
lk = lock_read()
if lk is not None and lock_alive(lk): return False, lk
try: os.remove(LOCK) # 陈旧锁(或刚好被写了一半):接管
except FileNotFoundError: pass
return False, lock_read()
def lock_beat():
lk = lock_read()
if not lk: return
lk['beat'] = time.time()
tmp = LOCK + '.%d.tmp' % os.getpid()
with open(tmp, 'w', encoding='utf-8') as f: json.dump(lk, f)
os.replace(tmp, LOCK)
def lock_release():
try: os.remove(LOCK)
except FileNotFoundError: pass
# ---------------------------------------------------------------- 状态
def todo(d=None):
d = d or load()
return [s for s in (d.get('plan') or []) if (d['stages'].get(s) or {}).get('rc') != 0]
def mark_stopped(rc=-15):
"""网页「停止」后调用:当前阶段记为被停止,释放锁。"""
d = load(); st = d.get('current')
if st and st in d['stages'] and d['stages'][st].get('end') is None:
d['stages'][st].update(end=time.time(), rc=rc)
d['current'] = None
save(d); lock_release()
def main(): def main():
cmd, *rest = sys.argv[1:] or ['?'] cmd, *rest = sys.argv[1:] or ['?']
if cmd == 'lock':
sub = rest[0] if rest else 'show'
if sub == 'acquire':
ok, lk = lock_acquire(rest[1] if len(rest) > 1 else 'terminal', int(rest[2]) if len(rest) > 2 else None)
if not ok:
age = time.time() - lk.get('beat', 0)
print(f"已有一条流水线在运行({lk.get('owner')},pid {lk.get('pid')},{age:.0f} s 前心跳)。"
f"锁文件 {os.path.relpath(LOCK, ROOT)};确认它已经不在了,等 {LOCK_STALE_S // 60} 分钟自动失效或手动删除该文件。", file=sys.stderr)
sys.exit(9)
elif sub == 'beat': lock_beat()
elif sub == 'release': lock_release()
else: print(json.dumps(lock_read()))
return
if cmd == 'timeout':
print(int(TIMEOUT_H.get(rest[0], 3.0) * 3600 * float(os.environ.get('TIMEOUT_SCALE') or 1))); return
if cmd == 'get':
v = (load()['stages'].get(rest[0]) or {}).get('info', {}).get(rest[1])
if v is None: print(f'pipeline.json 里没有 {rest[0]}.{rest[1]}', file=sys.stderr); sys.exit(1)
print(v); return
if cmd == 'todo':
print(' '.join(todo())); return
d = load(); now = time.time() d = load(); now = time.time()
if cmd == 'reset': if cmd == 'reset':
plan = rest[0].split(',') if rest else [] plan = rest[0].split(',') if rest and rest[0] else []
# 从中间某步续跑时保留计划之外(之前已跑完)各步的记录,计划内的清空重来 pn = params_now(); prev = d.get('params') or {}
d = {'started': now, 'plan': plan, 'stages': {k: v for k, v in d.get('stages', {}).items() if k not in plan}, 'current': None} if '--fresh' in rest: bad = set(ORDER)
else:
changed = [k for k in PARAM_KEYS if prev and prev.get(k) != pn[k]]
bad = {st for k in changed for st in AFFECTS[k]}
if changed: print('参数与上次不同:' + json.dumps({k: (prev.get(k), pn[k]) for k in changed}, ensure_ascii=False)
+ ';受影响的阶段重跑:' + ' '.join(st for st in ORDER if st in bad), file=sys.stderr)
keep = {k: v for k, v in d.get('stages', {}).items() if v.get('rc') == 0 and k not in bad}
# 受影响 / 未完成的阶段:旧产物挪走,免得续训、跳过 shard 时把旧参数的东西接上
arc = archive([st for st in ORDER if st in bad]) if bad else None
if arc: print(f'旧产物已移到 {arc}/(未删除)', file=sys.stderr)
d = {'started': now, 'plan': plan, 'stages': keep, 'current': None, 'params': pn}
done = [s for s in plan if s in keep]
if done: print('跳过已完成的 %d 个阶段:%s' % (len(done), ' '.join(done)), file=sys.stderr)
save(d); return
elif cmd == 'begin': elif cmd == 'begin':
st = rest[0] st = rest[0]
d['stages'][st] = {'start': now, 'end': None, 'rc': None, 'info': kv(rest[1:])} d['stages'][st] = {'start': now, 'end': None, 'rc': None, 'info': kv(rest[1:]), 'timeout_h': TIMEOUT_H.get(st)}
d['current'] = st d['current'] = st
elif cmd == 'set': elif cmd == 'set':
s = d['stages'].setdefault(rest[0], {'start': now, 'end': None, 'rc': None, 'info': {}}) s = d['stages'].setdefault(rest[0], {'start': now, 'end': None, 'rc': None, 'info': {}})
+8 -2
View File
@@ -13,6 +13,8 @@ from ml.common.features import features, STEP, H, GAP_CAP, D2R, wrap180
DT = 0.02 DT = 0.02
FIRST_DEG = 5.0 # R7 首次方向判定阈值(度):小于它的抖动不算选边
def tasks_to_tensors(tasks, device): def tasks_to_tensors(tasks, device):
q0 = torch.tensor([t['q0'] for t in tasks], dtype=torch.float32, device=device) q0 = torch.tensor([t['q0'] for t in tasks], dtype=torch.float32, device=device)
T = torch.tensor([t['T'] for t in tasks], dtype=torch.float32, device=device) T = torch.tensor([t['T'] for t in tasks], dtype=torch.float32, device=device)
@@ -80,6 +82,7 @@ def rollout(model, kin, tasks, device, K=8, safety=False, seed=0, max_sec=None,
traj = [q.clone()] if return_traj else None traj = [q.clone()] if return_traj else None
n_start = int(round(0.5 / DT)); moved_start = None # R2 起步失败:0.5 s 内关节总行程 < 1° n_start = int(round(0.5 / DT)); moved_start = None # R2 起步失败:0.5 s 内关节总行程 < 1°
j1_acc = torch.zeros(B, device=device); j1_max = torch.zeros(B, device=device); j1_min = torch.zeros(B, device=device) j1_acc = torch.zeros(B, device=device); j1_max = torch.zeros(B, device=device); j1_min = torch.zeros(B, device=device)
j1_first = torch.zeros(B, device=device) # 2026-10-18 第五轮(审查 N-3):J1 累计偏离首次超过 FIRST_DEG 时的方向(真正的"首次方向")
cold = True cold = True
for it in range(nmax): for it in range(nmax):
tnow = it * DT tnow = it * DT
@@ -146,6 +149,7 @@ def rollout(model, kin, tasks, device, K=8, safety=False, seed=0, max_sec=None,
qp = q; q = kin.clamp_box(q + d) qp = q; q = kin.clamp_box(q + d)
travel += d.abs().sum(-1) travel += d.abs().sum(-1)
j1_acc = j1_acc + d[:, 0]; j1_max = torch.maximum(j1_max, j1_acc); j1_min = torch.minimum(j1_min, j1_acc) j1_acc = j1_acc + d[:, 0]; j1_max = torch.maximum(j1_max, j1_acc); j1_min = torch.minimum(j1_min, j1_acc)
j1_first = torch.where((j1_first == 0) & (j1_acc.abs() >= FIRST_DEG), torch.sign(j1_acc), j1_first)
if it + 1 == n_start: moved_start = travel.clone() if it + 1 == n_start: moved_start = travel.clone()
if return_traj: traj.append(q.clone()) if return_traj: traj.append(q.clone())
mid = qp + 0.5 * (q - qp) mid = qp + 0.5 * (q - qp)
@@ -169,7 +173,8 @@ def rollout(model, kin, tasks, device, K=8, safety=False, seed=0, max_sec=None,
'start_fail': start_fail.float().mean().item(), 'start_fail': start_fail.float().mean().item(),
'time_budget': 'teacher×2(≥3 s)' if bool(has_ref.all()) else ('fixed %.0f s' % (max_sec or NOREF_SEC) if not has_ref.any() else 'mixed')} 'time_budget': 'teacher×2(≥3 s)' if bool(has_ref.all()) else ('fixed %.0f s' % (max_sec or NOREF_SEC) if not has_ref.any() else 'mixed')}
# 绕行侧:J1 累计位移的正向/负向极值哪个更大(+1 / −1),供 R7 左右比例门禁 # 绕行侧:J1 累计位移的正向/负向极值哪个更大(+1 / −1),供 R7 左右比例门禁
side = torch.where(j1_max >= -j1_min, torch.ones_like(j1_max), -torch.ones_like(j1_max)) # 首次方向;全程没偏离到 FIRST_DEG 的(基本不绕 J1)退回净方向
side = torch.where(j1_first != 0, j1_first, torch.where(j1_max >= -j1_min, torch.ones_like(j1_max), -torch.ones_like(j1_max)))
per = {'done': done.cpu(), 't_done': t_done.cpu(), 'col': col.cpu(), 'travel': travel.cpu(), 'err': e_end.cpu(), per = {'done': done.cpu(), 't_done': t_done.cpu(), 'col': col.cpu(), 'travel': travel.cpu(), 'err': e_end.cpu(),
'side': side.cpu(), 'start_fail': start_fail.cpu(), 'brakes': brakes.cpu()} 'side': side.cpu(), 'start_fail': start_fail.cpu(), 'brakes': brakes.cpu()}
if return_traj: per['traj'] = torch.stack(traj, 1).cpu() if return_traj: per['traj'] = torch.stack(traj, 1).cpu()
@@ -217,7 +222,8 @@ def main():
k = t.get('cat', '?') + '/' + t.get('mode', '?'); b = by.setdefault(k, [0, 0, 0]); b[0] += 1; b[1] += int(d); b[2] += int(c > 0) k = t.get('cat', '?') + '/' + t.get('mode', '?'); b = by.setdefault(k, [0, 0, 0]); b[0] += 1; b[1] += int(d); b[2] += int(c > 0)
res['by_cat_mode'] = {k: {'n': v[0], 'success': v[1] / v[0], 'collision': v[2] / v[0]} for k, v in by.items()} res['by_cat_mode'] = {k: {'n': v[0], 'success': v[1] / v[0], 'collision': v[2] / v[0]} for k, v in by.items()}
s = json.dumps(res, indent=1); print(s) s = json.dumps(res, indent=1); print(s)
if a.out: open(a.out, 'w').write(s) if a.out: # 先写 .tmp 再改名:中断不会留下半截结果被当成已完成(M5 续跑按文件存在跳过)
open(a.out + '.tmp', 'w').write(s); os.replace(a.out + '.tmp', a.out)
if __name__ == '__main__': if __name__ == '__main__':
+18 -7
View File
@@ -3,11 +3,12 @@
吞吐摸底:python -m ml.train --samples ml/data/samples --out ml/runs/bench_base --steps 2000 --no-eval 吞吐摸底:python -m ml.train --samples ml/data/samples --out ml/runs/bench_base --steps 2000 --no-eval
通过线(方案 R10):全程 60000 步 ≤ 3.5 h,即 samples_per_s ≥ 5000(3090 实测 6.2 k/s ≈ 2.8 h);日志 eta_h 为剩余小时数。 通过线(方案 R10):全程 60000 步 ≤ 3.5 h,即 samples_per_s ≥ 5000(3090 实测 6.2 k/s ≈ 2.8 h);日志 eta_h 为剩余小时数。
每 eval_every 步做一次闭环验证(ml/sim/closed_loop.py,部署节奏),按 success 存 best.pt;连续 patience_evals 次不升即停。""" 每 eval_every 步做一次闭环验证(ml/sim/closed_loop.py,部署节奏),按 success 存 best.pt;连续 patience_evals 次不升即停。"""
import argparse, json, math, os, time import argparse, json, math, os, sys, time
import torch import torch
from ml.common.kin_torch import Kin from ml.common.kin_torch import Kin
from ml.common.features import STEP, D2R from ml.common.features import STEP, D2R
from ml.common.ckpt import Saver, atomic_save
from ml.data import Samples, load_tasks from ml.data import Samples, load_tasks
from ml.model.policy import build, wta_loss from ml.model.policy import build, wta_loss
from ml.model.meanflow import meanflow_loss, one_step from ml.model.meanflow import meanflow_loss, one_step
@@ -45,11 +46,12 @@ def main():
model = build(cfg).to(dev) model = build(cfg).to(dev)
print(f'params {sum(p.numel() for p in model.parameters()) / 1e6:.2f} M', flush=True) print(f'params {sum(p.numel() for p in model.parameters()) / 1e6:.2f} M', flush=True)
opt = torch.optim.AdamW(model.parameters(), lr=tc['lr'], betas=tuple(tc['betas']), weight_decay=tc['wd']) opt = torch.optim.AdamW(model.parameters(), lr=tc['lr'], betas=tuple(tc['betas']), weight_decay=tc['wd'])
step0, best = 0, -1.0 step0, best, bad_evals = 0, -1.0, 0
if a.resume: if a.resume:
ck = torch.load(a.resume, map_location=dev); model.load_state_dict(ck['model']) ck = torch.load(a.resume, map_location=dev); model.load_state_dict(ck['model'])
if 'opt' in ck: opt.load_state_dict(ck['opt']) if 'opt' in ck: opt.load_state_dict(ck['opt'])
step0, best = ck.get('step', 0), ck.get('best', -1.0) step0, best, bad_evals = ck.get('step', 0), ck.get('best', -1.0), ck.get('bad_evals', 0)
print(f'续训:从第 {step0} 步继续(共 {tc["steps"]} 步),当前最好 success {best:.4f}', flush=True)
total = tc['steps'] total = tc['steps']
lr_at = lambda s: tc['lr'] * (s / tc['warmup'] if s < tc['warmup'] else 0.5 * (1 + math.cos(math.pi * (s - tc['warmup']) / max(1, total - tc['warmup'])))) lr_at = lambda s: tc['lr'] * (s / tc['warmup'] if s < tc['warmup'] else 0.5 * (1 + math.cos(math.pi * (s - tc['warmup']) / max(1, total - tc['warmup']))))
amp = dev.startswith('cuda') and tc.get('amp') == 'bf16' amp = dev.startswith('cuda') and tc.get('amp') == 'bf16'
@@ -58,11 +60,14 @@ def main():
log = open(os.path.join(a.out, 'log.jsonl'), 'a' if a.resume else 'w') # 新训练覆盖旧日志,续训才追加 log = open(os.path.join(a.out, 'log.jsonl'), 'a' if a.resume else 'w') # 新训练覆盖旧日志,续训才追加
if not val_tasks and not a.no_eval: if not val_tasks and not a.no_eval:
print('没有 val 任务(样本太少),本次跳过闭环验证', flush=True); a.no_eval = True print('没有 val 任务(样本太少),本次跳过闭环验证', flush=True); a.no_eval = True
bad_evals, t0, seen = 0, time.time(), 0 t0, seen, diverged = time.time(), 0, False
saver = Saver(tc.get('ckpt_min', 10)) # 2026-10-18:每 10 分钟 + 收到停止请求时存 last.pt(原子写)
def save(name, step, extra=None): def save(name, step, extra=None):
d = {'model': model.state_dict(), 'opt': opt.state_dict(), 'cfg': cfg, 'step': step, 'best': best, 'fp': kin.fp} d = {'model': model.state_dict(), 'opt': opt.state_dict(), 'cfg': cfg, 'step': step, 'best': best, 'bad_evals': bad_evals, 'fp': kin.fp}
if extra: d.update(extra) if extra: d.update(extra)
torch.save(d, os.path.join(a.out, name)) atomic_save(d, os.path.join(a.out, name))
if name == 'last.pt': saver.mark()
step = step0 - 1 # 续训时已训满:循环一次不跑,下面照常收尾
for step in range(step0, total): for step in range(step0, total):
for gpar in opt.param_groups: gpar['lr'] = lr_at(step) for gpar in opt.param_groups: gpar['lr'] = lr_at(step)
X, Y, V, SG = data.batch(tc['batch']) X, Y, V, SG = data.batch(tc['batch'])
@@ -93,8 +98,12 @@ def main():
loss.backward() loss.backward()
gn = torch.nn.utils.clip_grad_norm_(model.parameters(), tc['grad_clip']) gn = torch.nn.utils.clip_grad_norm_(model.parameters(), tc['grad_clip'])
if not torch.isfinite(loss): if not torch.isfinite(loss):
print('非有限损失 → 训练发散(M5 门禁:触发 WTA 回退条件)', flush=True); save('diverged.pt', step); break print('非有限损失 → 训练发散(M5 门禁:触发 WTA 回退条件)', flush=True); save('diverged.pt', step); diverged = True; break
opt.step(); seen += X.shape[0] opt.step(); seen += X.shape[0]
if saver.stop():
save('last.pt', step + 1); log.close()
print(f'收到停止请求:已存档 last.pt(第 {step + 1} 步),下次运行自动续训', flush=True); sys.exit(143)
if saver.due(): save('last.pt', step + 1)
if step % tc['log_every'] == 0: if step % tc['log_every'] == 0:
el = time.time() - t0 el = time.time() - t0
rec = {'step': step, 'total': total, 't': time.time(), 'loss': loss.item(), 'mf': mf_raw.item(), 'fk_cm': l_fk.item(), 'smooth': l_sm.item(), rec = {'step': step, 'total': total, 't': time.time(), 'loss': loss.item(), 'mf': mf_raw.item(), 'fk_cm': l_fk.item(), 'smooth': l_sm.item(),
@@ -112,6 +121,8 @@ def main():
else: else:
bad_evals += 1 bad_evals += 1
if bad_evals >= tc['patience_evals']: print('闭环到达率不再上升,停止', flush=True); break if bad_evals >= tc['patience_evals']: print('闭环到达率不再上升,停止', flush=True); break
if diverged: # 发散的权重不能覆盖 last.pt / best.pt:last.pt 保留发散前最后一次存档,M5 门禁会因此判不过 → 自动 WTA
print('训练发散,last.pt / best.pt 保留发散前的存档', flush=True); return
save('last.pt', step + 1) save('last.pt', step + 1)
if a.no_eval: save('best.pt', step + 1) if a.no_eval: save('best.pt', step + 1)
print(f'done, best success {best:.4f}, {(time.time() - t0) / 3600:.2f} h', flush=True) print(f'done, best success {best:.4f}, {(time.time() - t0) / 3600:.2f} h', flush=True)
+9 -2
View File
@@ -25,6 +25,7 @@ const L = S.LIM, O = S.ORDER, w = S.wrap180;
const meta = JSON.parse(fs.readFileSync(MODEL.replace('.onnx', '.meta.json'), 'utf8')); const meta = JSON.parse(fs.readFileSync(MODEL.replace('.onnx', '.meta.json'), 'utf8'));
if (meta.fp !== S.modelFingerprint()) { console.error('模型碰撞指纹', meta.fp, '≠ 当前', S.modelFingerprint(), ':须重训'); process.exit(2); } if (meta.fp !== S.modelFingerprint()) { console.error('模型碰撞指纹', meta.fp, '≠ 当前', S.modelFingerprint(), ':须重训'); process.exit(2); }
if (meta.feat_ver !== POL.FEAT_VER) { console.error('特征版本不符'); process.exit(2); } if (meta.feat_ver !== POL.FEAT_VER) { console.error('特征版本不符'); process.exit(2); }
if (meta.random_init) { console.error('这是 --random 导出的延迟探针模型,不是训练产物,拒绝评测(2026-10-18 第五轮,审查 N-5)'); process.exit(2); }
function lcg(seed) { let s = seed >>> 0; return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; } function lcg(seed) { let s = seed >>> 0; return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; }
function ncCases(N, seed) { function ncCases(N, seed) {
@@ -76,7 +77,7 @@ const pct = (a, p) => { if (!a.length) return null; const s = a.slice().sort((x,
(async () => { (async () => {
const sess = await ort.InferenceSession.create(MODEL, { executionProviders: ['cpu'], intraOpNumThreads: 1 }); const sess = await ort.InferenceSession.create(MODEL, { executionProviders: ['cpu'], intraOpNumThreads: 1 });
const infer = POL.ortInfer(ort, sess); const infer = POL.ortInfer(ort, sess, { encOnce: !!meta.enc_once });
const cert = POL.makeCert(S, {}); const cert = POL.makeCert(S, {});
const out = { model: MODEL, meta: { fp: meta.fp, eval: meta.eval } }; const out = { model: MODEL, meta: { fp: meta.fp, eval: meta.eval } };
const runSet = async (name, cases, mode) => { const runSet = async (name, cases, mode) => {
@@ -112,7 +113,13 @@ const pct = (a, p) => { if (!a.length) return null; const s = a.slice().sort((x,
}; };
if (SETS.includes('nc')) { const c = ncCases(+opt('--nc-n') || 30, 20261017); await runSet('near_column_pos', c, 'pos'); await runSet('near_column_pose', c, 'pose'); } if (SETS.includes('nc')) { const c = ncCases(+opt('--nc-n') || 30, 20261017); await runSet('near_column_pos', c, 'pos'); await runSet('near_column_pose', c, 'pose'); }
if (SETS.includes('route')) await runSet('route40', routeCases(+opt('--route-n') || 40), 'pos'); if (SETS.includes('route')) await runSet('route40', routeCases(+opt('--route-n') || 40), 'pos');
if (SETS.includes('r8')) await runSet('r8_adversarial', r8Cases(+opt('--r8-n') || 50, 8888), 'pos'); if (SETS.includes('r8')) {
/* 2026-10-18 第五轮(审查 N-4):出题不足额不再静默——记下要求题数与出题耗时,acceptance 对 n < requested 判不通过 */
const want = +opt('--r8-n') || 50, tg = Date.now(), cs = r8Cases(want, 8888), genS = (Date.now() - tg) / 1000;
if (cs.length < want) console.error(`R8 出题不足:${cs.length}/${want}(${genS.toFixed(0)} s)`);
await runSet('r8_adversarial', cs, 'pos');
Object.assign(out.r8_adversarial, { requested: want, genS: +genS.toFixed(1) });
}
if (SETS.includes('nc1000')) await runSet('near_column_1000', ncCases(1000, 1), 'pos'); if (SETS.includes('nc1000')) await runSet('near_column_1000', ncCases(1000, 1), 'pos');
const s = JSON.stringify(out, null, 1); const s = JSON.stringify(out, null, 1);
console.log(s); console.log(s);
+23 -6
View File
@@ -3,6 +3,11 @@
* ?policy=1 用 models/traj_policy.onnx * ?policy=1 用 models/traj_policy.onnx
* ?policy=models/xxx.onnx 指定模型 * ?policy=models/xxx.onnx 指定模型
* &ep=webgpu 改用 WebGPU 后端(默认 wasm-SIMD 多线程) * &ep=webgpu 改用 WebGPU 后端(默认 wasm-SIMD 多线程)
* &threads=N wasm 线程数(默认 min(16, ⌊0.6×逻辑核⌋);2026-10-18 R4 实测 28 核机 16 最优、24 反而变慢、4 线程 P99 37 ms)
* &proxy=0 关闭推理代理 Worker(默认开:wasm 推理不占主线程,见下)
* &probe=1 允许加载 --random 导出的延迟探针模型(默认拒绝,防把随机策略当真模型跑)
* 2026-10-18 第五轮:ort.env.wasm.proxy=true 把 wasm 推理挪到 ort 自带的 Worker。此前推理在主线程同步算,
* R4 实测时 CDP 15 s 拿不到执行槽,50 Hz tick 也被它拖住——丢步不只来自推理时长本身。
* 目标变化(松手求解 / 实时追踪)都交给 ArmPolicy.Runner:50 Hz 执行时钟放在 Web Worker(不受 rAF 60 Hz 与后台节流影响), * 目标变化(松手求解 / 实时追踪)都交给 ArmPolicy.Runner:50 Hz 执行时钟放在 Web Worker(不受 rAF 60 Hz 与后台节流影响),
* 每 3 步推理一次,安全层认证后才执行;ik-glue 的逐帧碰撞守卫不参与(策略执行不走 animTo),由 Runner 安全层保证。 * 每 3 步推理一次,安全层认证后才执行;ik-glue 的逐帧碰撞守卫不参与(策略执行不走 animTo),由 Runner 安全层保证。
* onnxruntime-web 默认从 jsDelivr 加载;离线部署把 ort.min.js 与 *.wasm 放到 vendor/ort/ 并加 &ortLocal=1。 * onnxruntime-web 默认从 jsDelivr 加载;离线部署把 ort.min.js 与 *.wasm 放到 vendor/ort/ 并加 &ortLocal=1。
@@ -30,14 +35,24 @@ async function boot() {
if (meta.fp !== S.modelFingerprint()) throw new Error('模型碰撞指纹 ' + meta.fp + ' ≠ 当前 ' + S.modelFingerprint() + ':collide.json 已变,须重训'); if (meta.fp !== S.modelFingerprint()) throw new Error('模型碰撞指纹 ' + meta.fp + ' ≠ 当前 ' + S.modelFingerprint() + ':collide.json 已变,须重训');
if (meta.feat_ver !== ArmPolicy.FEAT_VER) throw new Error('特征版本不符:模型 ' + meta.feat_ver + ' / 运行时 ' + ArmPolicy.FEAT_VER); if (meta.feat_ver !== ArmPolicy.FEAT_VER) throw new Error('特征版本不符:模型 ' + meta.feat_ver + ' / 运行时 ' + ArmPolicy.FEAT_VER);
if (meta.tcp0 && meta.tcp0.some((v, i) => Math.abs(v - S.TCP0[i]) > 0.01)) throw new Error('TCP 与训练时不同:' + S.TCP0 + ' vs ' + meta.tcp0); if (meta.tcp0 && meta.tcp0.some((v, i) => Math.abs(v - S.TCP0[i]) > 0.01)) throw new Error('TCP 与训练时不同:' + S.TCP0 + ' vs ' + meta.tcp0);
const probe = !!meta.random_init;
if (probe && qs.get('probe') !== '1') throw new Error('这是随机初始化的延迟探针模型(meta.random_init),不是训练产物;仅测延迟时加 &probe=1');
if (!window.ort) await loadScript(ORT_BASE + 'ort.min.js'); if (!window.ort) await loadScript(ORT_BASE + 'ort.min.js');
ort.env.wasm.wasmPaths = ORT_BASE; ort.env.wasm.wasmPaths = ORT_BASE;
ort.env.wasm.numThreads = self.crossOriginIsolated ? Math.min(4, navigator.hardwareConcurrency || 1) : 1; const hc = navigator.hardwareConcurrency || 1;
ort.env.wasm.numThreads = !self.crossOriginIsolated ? 1 : (+qs.get('threads') || Math.max(1, Math.min(16, Math.floor(hc * 0.6))));
const ep = qs.get('ep') === 'webgpu' ? ['webgpu', 'wasm'] : ['wasm']; const ep = qs.get('ep') === 'webgpu' ? ['webgpu', 'wasm'] : ['wasm'];
const sess = await ort.InferenceSession.create(MODEL, { executionProviders: ep, graphOptimizationLevel: 'all' }); let sess = null, proxy = ep[0] === 'wasm' && qs.get('proxy') !== '0'; // proxy 只支持 wasm EP
runner = new ArmPolicy.Runner(S, ArmPolicy.ortInfer(ort, sess), {}); if (proxy) {
ort.env.wasm.proxy = true;
try { sess = await ort.InferenceSession.create(MODEL, { executionProviders: ep, graphOptimizationLevel: 'all' }); }
catch (e) { console.warn('[policy] 代理 Worker 不可用,退回主线程推理:', e); proxy = false; ort.env.wasm.proxy = false; sess = null; }
}
if (!sess) sess = await ort.InferenceSession.create(MODEL, { executionProviders: ep, graphOptimizationLevel: 'all' });
const infer = ArmPolicy.ortInfer(ort, sess, { encOnce: !!meta.enc_once });
runner = new ArmPolicy.Runner(S, infer, {});
/* 预热:首次推理有编译开销 */ /* 预热:首次推理有编译开销 */
await ArmPolicy.ortInfer(ort, sess)(new Float32Array(8 * ArmPolicy.F_DIM), new Float32Array(8 * ArmPolicy.H * 6), 8); await infer(new Float32Array(8 * ArmPolicy.F_DIM), new Float32Array(8 * ArmPolicy.H * 6), 8);
const tick = URL.createObjectURL(new Blob(['let h=null;onmessage=e=>{clearInterval(h);if(e.data>0)h=setInterval(()=>postMessage(0),e.data)}'], { type: 'text/javascript' })); const tick = URL.createObjectURL(new Blob(['let h=null;onmessage=e=>{clearInterval(h);if(e.data>0)h=setInterval(()=>postMessage(0),e.data)}'], { type: 'text/javascript' }));
worker = new Worker(tick); worker = new Worker(tick);
worker.onmessage = onTick; worker.onmessage = onTick;
@@ -50,10 +65,12 @@ async function boot() {
}, },
stop, stats: () => runner.stats, runner: () => runner, stop, stats: () => runner.stats, runner: () => runner,
latency: () => { const p = (a, x) => { const s = a.slice().sort((u, v) => u - v); return s.length ? s[Math.min(s.length - 1, Math.floor(s.length * x))] : null; }; latency: () => { const p = (a, x) => { const s = a.slice().sort((u, v) => u - v); return s.length ? s[Math.min(s.length - 1, Math.floor(s.length * x))] : null; };
return { inferP50: p(runner.stats.inferMs, .5), inferP99: p(runner.stats.inferMs, .99), safetyP50: p(runner.stats.safetyMs, .5), safetyP99: p(runner.stats.safetyMs, .99), threads: ort.env.wasm.numThreads, ep: ep[0] }; } return { inferP50: p(runner.stats.inferMs, .5), inferP99: p(runner.stats.inferMs, .99), safetyP50: p(runner.stats.safetyMs, .5), safetyP99: p(runner.stats.safetyMs, .99), threads: ort.env.wasm.numThreads, ep: ep[0], proxy, encOnce: !!meta.enc_once, probe }; }
}; };
ready = true; ready = true;
say('策略模式就绪(' + ep[0] + ',' + ort.env.wasm.numThreads + ' 线程):拖动目标即由策略执行', 'ok'); const how = ep[0] + ',' + ort.env.wasm.numThreads + ' 线程' + (proxy ? ',推理在 Worker' : ',推理在主线程');
if (probe) say('随机探针模型(仅测延迟,动作无意义):' + how, 'badb');
else say('策略模式就绪(' + how + '):拖动目标即由策略执行', 'ok');
} catch (e) { say('策略模式不可用:' + e.message, 'badb'); console.error(e); } } catch (e) { say('策略模式不可用:' + e.message, 'badb'); console.error(e); }
} }
function start() { if (running) return; running = true; worker.postMessage(ArmPolicy.DT * 1000); } function start() { if (running) return; running = true; worker.postMessage(ArmPolicy.DT * 1000); }
+6 -2
View File
@@ -336,9 +336,13 @@ class Runner {
const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now()); const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
/* ---------------- onnxruntime 适配(web: ort = window.ort;node: require('onnxruntime-node')) ---------------- */ /* ---------------- onnxruntime 适配(web: ort = window.ort;node: require('onnxruntime-node')) ---------------- */
function ortInfer(ort, session) { /* 2026-10-18 第五轮:模型 meta.enc_once=true(编码一次、解码 K 次)时只送第 1 行特征 [1,F];
Runner 仍按 K 行组织特征(K 行本就相同),旧模型(enc_once 缺省)照旧送 [B,F]。 */
function ortInfer(ort, session, opt) {
const encOnce = !!(opt && opt.encOnce);
return async (feat, z, B) => { return async (feat, z, B) => {
const r = await session.run({ feat: new ort.Tensor('float32', feat, [B, F_DIM]), z: new ort.Tensor('float32', z, [B, H, 6]) }); const ft = encOnce ? new ort.Tensor('float32', feat.subarray(0, F_DIM), [1, F_DIM]) : new ort.Tensor('float32', feat, [B, F_DIM]);
const r = await session.run({ feat: ft, z: new ort.Tensor('float32', z, [B, H, 6]) });
return { act: r.act.data, value: r.value.data, sub: r.sub.data }; return { act: r.act.data, value: r.value.data, sub: r.sub.data };
}; };
} }
+42
View File
@@ -0,0 +1,42 @@
<!doctype html>
<meta charset="utf-8">
<title>R4 推理延迟探针</title>
<!-- 2026-10-18 第五轮:由 ml/eval/r4_probe.py 在无头浏览器里打开,测部署形态的单次推理(1 行特征 + K=8 条候选)。
参数:model= 模型 URL;ep=wasm|webgpu;threads=auto|N;proxy=0|1;n= 次数;ortLocal=1 用 vendor/ort/。
结果 POST 到 /r4/result;同时记录 20 ms 节拍的最大间隔(推理是否卡主线程)。也可手动打开看页面上的结果。 -->
<pre id="o">运行中…</pre>
<script>
(async () => {
const qs = new URLSearchParams(location.search), o = document.getElementById('o');
const res = { ep: qs.get('ep') || 'wasm', threadsArg: qs.get('threads') || 'auto', hc: navigator.hardwareConcurrency, coi: self.crossOriginIsolated, ua: navigator.userAgent };
const post = async () => { o.textContent = JSON.stringify(res, null, 1); try { await fetch('/r4/result', { method: 'POST', body: JSON.stringify(res) }); } catch (e) {} };
try {
const MODEL = qs.get('model'), N = +qs.get('n') || 150, K = 8, F = 83, H = 16;
const BASE = qs.get('ortLocal') === '1' ? '/webapp/vendor/ort/' : 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.20.1/dist/';
await new Promise((ok, no) => { const s = document.createElement('script'); s.src = BASE + 'ort.min.js'; s.onload = ok; s.onerror = () => no(new Error('加载 ort 失败:' + s.src)); document.head.appendChild(s); });
const meta = await (await fetch(MODEL.replace('.onnx', '.meta.json'))).json();
ort.env.wasm.wasmPaths = BASE;
// 与 ik-policy-glue.js 同一套默认值
ort.env.wasm.numThreads = !self.crossOriginIsolated ? 1 : (res.threadsArg === 'auto' ? Math.max(1, Math.min(16, Math.floor(res.hc * 0.6))) : +res.threadsArg);
res.threads = ort.env.wasm.numThreads;
res.proxy = res.ep === 'wasm' && qs.get('proxy') !== '0';
const opts = { executionProviders: res.ep === 'webgpu' ? ['webgpu'] : ['wasm'], graphOptimizationLevel: 'all' };
let sess = null;
if (res.proxy) { ort.env.wasm.proxy = true; try { sess = await ort.InferenceSession.create(MODEL, opts); } catch (e) { res.proxyError = String(e); res.proxy = false; ort.env.wasm.proxy = false; } }
if (!sess) sess = await ort.InferenceSession.create(MODEL, opts);
res.encOnce = !!meta.enc_once;
const feat = new Float32Array((res.encOnce ? 1 : K) * F).map(() => Math.random() - 0.5);
const z = () => new Float32Array(K * H * 6).map(() => Math.random() * 2 - 1);
const run = () => sess.run({ feat: new ort.Tensor('float32', feat, [res.encOnce ? 1 : K, F]), z: new ort.Tensor('float32', z(), [K, H, 6]) });
for (let i = 0; i < 10; i++) await run();
let last = performance.now(); const gaps = [];
const tk = setInterval(() => { const t = performance.now(); gaps.push(t - last); last = t; }, 20);
const ts = [];
for (let i = 0; i < N; i++) { const t = performance.now(); await run(); ts.push(performance.now() - t); await new Promise(r => setTimeout(r, 40)); }
clearInterval(tk);
const p = (a, x) => { const s = a.slice().sort((u, v) => u - v); return +s[Math.min(s.length - 1, Math.floor(s.length * x))].toFixed(2); };
Object.assign(res, { n: N, K, p50: p(ts, .5), p95: p(ts, .95), p99: p(ts, .99), tickGapP99: p(gaps, .99), tickGapMax: +Math.max(...gaps).toFixed(1) });
} catch (e) { res.error = String(e && e.message || e); }
await post();
})();
</script>