第五轮:锁/续跑/超时/自动分支/R4 探针与 encode-once/控制台加固;阶段内断点续跑(gen 按条、prep 按 shard、训练与 APG 定时原子存档+体面停止);分歧以实现方意见为准
This commit is contained in:
@@ -43,3 +43,6 @@ webapp/generated/
|
||||
# 策略模型产物(由 python -m ml.export_onnx 生成,20 MB 级,不入库)
|
||||
webapp/models/*.onnx
|
||||
webapp/models/*.meta.json
|
||||
|
||||
# 从头重跑时旧产物挪到这里(不入库)
|
||||
ml/archive/
|
||||
|
||||
@@ -556,3 +556,103 @@ batch 缩放(4 线程,同一会话):B=1 → 6.2/7.2;B=2 → 10.3;B=4
|
||||
3. **周期账**:部署每 3 步(60 ms)一次推理,安全层单次预算 `safetyMs:30`。4 线程下推理 33 ms 已吃掉一半以上——安全层只要超过 27 ms 就必然丢步;16 线程下 13.7 + ≤30 才谈得上有余量。也就是说 R4 与 R9(贴柱处安全层全程 P99 < 5 ms)是同一个 60 ms 周期的两半,必须一起测。
|
||||
4. **没拿到的数(如实)**:闭环实际倍率/丢步率没采到——我挂的探针一运行,CDP 连 15 s 都取不到执行槽(连 `list_console_messages` 都超时),标签页要到切走才恢复。这本身就是主线程(投影有限差分 + 认证递归都在主线程)在 4 线程 wasm 下已经很挤的证据。R9 因此仍无数据。
|
||||
5. 残留:`webapp/models/traj_policy_r4.onnx`(+`.meta.json`,共 19.8 MB) 是未跟踪文件,`meta.random_init=true`;serve.py 已停,浏览器标签已释放。它同时验证了 N-5 的隐患是真的——这个文件与第 10 步 `export` 的默认输出同名同目录,只差文件名后缀。
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
# 第五轮:对第四轮的处置(实现方回复,2026-10-18)
|
||||
|
||||
处置对象:第四轮 N-1~N-16、第六节「无人值守跑完」四个缺口、第七节 R4 实测。
|
||||
结论先说:**第四轮全部认可,按第六节的目标一次改完**(不走 A/B/C 分批)。三道门槛(运行时 5 mm 护栏、M5 ≤ 2%、`collisionFrames = 0`)不动;「跑通 ≠ 达标」原样保留。
|
||||
|
||||
## 〇、分歧裁决规则(用户 2026-10-18 确定)
|
||||
|
||||
审查方与实现方意见不一致、讨论后仍定不下来时,**以实现方(Hark)的意见为准**,按它落地,不再停下来等第三方拍板。
|
||||
审查方的异议照常写进下一轮文档,附上判据;下一轮如果实测数据证明实现方错了,按数据改,不另起争论。
|
||||
本轮实际只有两处分歧:N-6 和 R4 的改法(见第二节)。按上述规则,两处均按实现方意见落地。
|
||||
|
||||
## 一、逐条处置
|
||||
|
||||
| 条目 | 处置(代码落点) | 自测 |
|
||||
|---|---|---|
|
||||
| N-7 并发无锁 | `ml/pstate.py` 新增互斥锁 `ml/runs/pipeline.lock`(`O_EXCL` 原子创建;pid + 每 60 s 心跳;5 分钟无心跳视为失效可接管)。`pipeline.sh` 在**任何**阶段入口先拿锁,`all` 的子阶段继承;监控页判断是否在跑只看锁,不再看日志新鲜度。生成改为先写 `shard_XXXX.jsonl.part`、写完才改名 | 先起 gate1,再起 gen → 立即拒绝(返回码 9,报出持锁 pid);TERM 后阶段记 143、锁释放 |
|
||||
| N-8 控制口无校验 | `/api/start`、`/api/stop` 要求自定义头 `X-Console: 1`,`Origin` 和 `Sec-Fetch-Site` 有值时必须同源。跨站页面加自定义头会触发 CORS 预检,本服务不应答 OPTIONS,浏览器就不会发出请求 | 不带头 → 403;带头但 `Origin: https://evil.com` → 403;同源 → 200 |
|
||||
| N-9 刷新全量读 shard | 行数按文件增量统计(记住偏移,只读新增字节),只在 gen 阶段做;其他阶段用 `stats.json` 的 `tasks` | — |
|
||||
| N-10 启动器 Python | `console.bat` 尊重 `%PYTHON%`,判据改为 `import torch`;`console.sh` 依次试 `PYTHON` / `python` / `python3`,判据相同。monitor 启动后后台自检(训练用 Python、torch 版本、GPU、node、bash),不过则网页「开始训练」不可点、`/api/start` 拒绝;`all` 开头再查一次 `import torch` | 沙箱自检输出正确(CPU torch 给出「看不到 GPU」警告) |
|
||||
| N-11 生命周期 | Windows 子进程用 `CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP`(自带隐藏控制台,关掉控制台黑窗口不连带结束);锁里记 Windows pid(Git Bash 的 `/proc/$$/winpid`),重开的控制台可按锁停止。APG 新增 `--resume`(`last.pt` 带优化器状态);`loop.sh` 跳过已有 `gate.json` 的轮次、本轮 APG 断了从该轮 `last.pt` 续跑;`train` 自动带 `--resume`,测速 2000 步做过就跳过 | APG 2 步 → `--resume` 续到 4 步,日志「从第 2 步继续」,`last.pt` step=4 含 opt |
|
||||
| N-16 gen 无节拍 / 死循环 | `run_parallel.sh` 每 60 s 往控制台打一行节拍(已写条数 + 各进程最新进度),逐个 `wait` 子进程查返回码(原来的裸 `wait` 恒为 0),任一种子失败即返回码 6 并打印该种子日志尾部。`sampler.cjs` 所有拒绝采样设次数上限,超限抛错并写明「哪一类位形连续 N 次未命中」 | 4 条任务小跑:重跑时已完成种子跳过;补量第 1 次触发并写对 `target_tasks` |
|
||||
| N-5 随机模型踩真模型 | `r4` 导出到 `ml/runs/r4/traj_policy_r4.onnx`,不再写 `webapp/models/`;查看器遇到 `random_init` 默认拒绝(`&probe=1` 才放行,徽标标红「随机探针模型」);`policy_bench.cjs` 拒绝;`acceptance` 新增「部署模型是训练产物」一项 | bench 加载随机模型 → 拒绝退出 |
|
||||
| N-2 R2 与池耦合 | 判据改为 `label_std_norm`:在距离较近的一半近邻对上做「标签差 ~ 近邻距离」线性回归,取距离 → 0 的截距。池变大只会让近邻更近、点更多,截距估的都是同一输入下的固有分歧;旧口径 `label_std_mean` 保留为参考,并记录 `pool`。不通过默认**警告并继续**,`R2_STRICT=1` 才停;验收里 R2 改为参考项 | 小样本可跑通(数值无意义);池无关性要用 1.7 M 样本的 `--pool 50000` 与 `--pool 200000` 两跑验证(差 < 0.02 为成立) |
|
||||
| N-3 R7 口径 | `closed_loop.py` 新增 `j1_first`:J1 累计偏离首次超过 5° 时的方向,即真正的首次方向;全程不到 5° 的退回净方向。`gate.py` 去掉无用形参 | 语法检查 |
|
||||
| N-4 r8 不足额静默 | bench 记录 `requested` 与出题耗时 `genS`,不足额时打印警告;acceptance 对 `n < requested` 判不通过 | 见第三节 |
|
||||
| N-12 双写者 | 停止后的状态改写走 `pstate.mark_stopped()`(原子替换);`pipeline.json` 只有 pstate 一个写者 | 网页停止 → 阶段记 −15、`current` 清空、锁删除 |
|
||||
| N-13 target 读失败 | 新增 `pstate get`,读不到时非零退出;`set -e` 下直接失败,不再算出 56 这类值 | `get gen target_tasks` 缺失 → rc 1 |
|
||||
| N-14 权重不同源 | 阶段名、估时、超时上限统一在 `pstate.STAGES`,由 `/api/status` 下发给网页;gen 权重 1.0 → 5.0(共识分支上界) | 示例页 4 个场景无报错 |
|
||||
| N-1 `MIN_GAP_MM` | `pipeline.sh` 统一 `export MIN_GAP_MM=3`,并作为参数记进 `pipeline.json`(改了会使生成及之后阶段作废);prep 开头比对 `stats.json` 的 `minGapFilterMm`,不一致返回码 8;网页可设 | `MIN_GAP_MM=4` 跑 prep → 报错退出 |
|
||||
| N-6 分块 | **分歧,见第二节**。改为按空闲显存自动定块(用 1/4,256~8192) | — |
|
||||
| N-15 file:// demo | 示例模式在页面顶部显示黄色提示条;README 写明不要直接双击 html | — |
|
||||
|
||||
第六节四个缺口的对应:
|
||||
|
||||
| 缺口 | 落地 |
|
||||
|---|---|
|
||||
| 1 状态机闭环 | `pstate reset` 默认保留 rc=0 的阶段;`all` 只跑 `pstate todo` 列出的阶段。**参数变了只作废受影响的阶段**(`AFFECTS` 表:生成参数 → gen 起;`CFG` → r4 和 train 起;挖错参数 → mine 起),被作废阶段的产物移到 `ml/archive/<时间>/`,不删除;`FRESH=1` 全部作废。网页不再问起始阶段,只选「续跑 / 从头重跑」 |
|
||||
| 2 互斥 + 脱离控制台 | 见 N-7、N-11 |
|
||||
| 3 门槛自动分支 | R2 默认警告继续;M5 base 不过 → 流程内自动训 WTA 再判,两头都不过才停(`AUTO_WTA=0` 关闭);补量上限 2 → 4 次,补完仍不足 90% 目标时警告并继续;R4 自动探针(第二节) |
|
||||
| 4 心跳与超时 | 每阶段 `timeout_h` = 估时 ×3(m5 按含 WTA 分支计),`all` 用 `timeout -k 120` 包住每个阶段,超时整组进程收到 TERM,阶段记 `timed_out`、放锁;网页显示「本步粗估 / 上限」与「超过时间上限,判定卡死」 |
|
||||
|
||||
自测(沙箱、秒级):用 `TIMEOUT_SCALE=0.002` 把 gate1 上限压到 10 s → 返回码 124,阶段标 `timed_out`,node 子进程全部结束,锁释放。
|
||||
|
||||
## 二、两处分歧(按第〇节规则以实现方意见落地)
|
||||
|
||||
### N-6:不是自相矛盾
|
||||
|
||||
`kin.gaps` 按 `d² ≤ (胶囊对下限 + cap)²` 筛候选对。`min_gap_chunked` 用 `cap = 0.5 mm`,每个随机位形幸存约 2800 对(≈ 1.5 MB);`column_gap` 用 `cap = 40 mm`,40 mm 以内幸存的对多一个量级(≈ 20 MB)。两处的峰值差 13 倍是 cap 不同造成的,两个默认块大小在各自的 cap 下都对。
|
||||
第四轮说得对的是「24 GB 显存上 256 偏小、拖慢出题」,所以块大小改为按空闲显存自动计算(`tasks.auto_chunk`),两处只需传入各自的每位形峰值。
|
||||
|
||||
### R4:33 ms 里不全是模型本身的开销
|
||||
|
||||
第四轮的结论是「必须动结构,二选一:去掉重复 encoder,或 K=8 → 4」。我的处置不同:
|
||||
|
||||
1. **推理移出主线程(第四轮没提,最重要)**:原来 `ik-policy-glue.js` 没开 `ort.env.wasm.proxy`,wasm 推理在主线程同步执行。第七节第 4 条里「CDP 15 s 拿不到执行槽」就是这个原因;50 Hz 的 `tick` 也被一起拖住,所以丢步不只来自推理时长本身。现在默认开代理 Worker(`&proxy=0` 关),创建失败时自动退回主线程并在徽标上写明。
|
||||
2. **编码一次**:同意第四轮的方向 ①。ONNX 接口改为 `feat[1,83]`、`z[K,16,6]`(`Policy.sample_shared`,`meta.enc_once`);导出时断言与旧接口逐元素一致(误差 < 1e-4);运行时按 `meta.enc_once` 只送 1 行,旧模型照常可用。按 base.json 粗算,encoder 约占总计算 29%,可省约 25%;**沙箱 CPU 实测**(onnxruntime 2 vCPU,200 次):旧接口 P50 10.03 / P99 10.34 ms → 新接口 8.03 / 8.18 ms,省约 20%。
|
||||
3. **线程数**:默认 `min(16, ⌊0.6×逻辑核⌋)`(28 核机 = 16,即第七节实测最优),`&threads=N` 可覆盖。
|
||||
4. **K=8 不降**:降 K 损失的是选优质量,要有 M5 / 闭环数字才能判,现在没有依据。
|
||||
5. **WebGPU 必须测**:方案 R4 行本来就写了要同测 WebGPU,第七节没测。探针现在 4 种配置都测:默认线程 + Worker(部署口径)、4 线程(对照)、默认线程不开 Worker(看 `tickGapMax`,即推理是否卡住 20 ms 节拍)、WebGPU。
|
||||
|
||||
**判定顺序**:部署口径 P99 < 15 ms 即通过;wasm 不过而 WebGPU 过,也判通过并记 `deploy_ep: webgpu`;两者都不过,流程在训练前停下(返回码 5),改 `CFG=ml/configs/small.json`(d=128)重跑。
|
||||
**预估(未实测,不当已解决)**:16 线程旧接口 P99 19.5 ms × (1 − 0.2~0.25) ≈ 14.6~15.6 ms,正好踩在线上;WebGPU 在 3090 上的数字是这一轮最关键的未知数。
|
||||
**R9**:探针只测纯推理。贴柱处安全层耗时(R9)仍以 bench 的 `safetyMsP99` 为准;推理移出主线程后,安全层独占主线程,R4 与 R9 合起来要小于 60 ms 周期这笔账,等真机数据再核。
|
||||
|
||||
## 三、断点续跑(用户 2026-10-18 追加要求)
|
||||
|
||||
要求:长时间的步骤意外中断(断电、蓝屏、误关、手动停止)后能接着跑,不从头来。阶段级跳过上一版已有,这一版把**阶段内部**也做成可续:
|
||||
|
||||
| 阶段 | 续跑粒度 | 做法 |
|
||||
|---|---|---|
|
||||
| gen | 每条任务 | 第 i 条任务的随机数只由(种子, i)决定(`sampler.reseed`);`.part` 里保留完整的行、截掉写了一半的最后一行,从下一条接着写。沙箱验证:截断后续写的结果与一次写完逐条一致(id、q0、keep 全同)。 |
|
||||
| prep | 每个 shard | `tasks.jsonl` 最后写且先写 `.tmp` 再改名,它存在且比 shard 新就跳过;shard 已不存在的旧打包删掉。 |
|
||||
| train / WTA | 最多 10 分钟 | 每 10 分钟原子写 `last.pt`(`ckpt_min`,先写 `.tmp` 再 `os.replace`,写到一半断电旧档仍完整),含优化器、步数、最好成绩、早停计数;发散时不再用发散权重覆盖 `last.pt` / `best.pt`。 |
|
||||
| apg / 挖错 | 最多 10 分钟 | 同上;挖错已过门禁的轮次跳过。 |
|
||||
| m5 | 半个门禁 | 比检查点新的 `m5_<头>_policy.json` / `_safety.json` 跳过;结果文件改为原子写;检查点不存在按不通过处理(直接进入 WTA 分支)。 |
|
||||
|
||||
**停止也不丢进度**:网页「停止」在训练类阶段先写 `ml/runs/STOP`,训练进程 2 秒内看到后存档、以 143 退出,最多等 90 秒才硬杀(Windows 的 `taskkill /F` 收不到信号,所以靠文件)。终端 Ctrl+C / `kill` 走信号,同样先存档。新一次运行拿到锁时清掉旧的 STOP。
|
||||
沙箱验证:STOP 文件 → rc 143、`last.pt` 在第 180 步,续训从 180 到 200 正常收尾;`kill -TERM` → rc 143 并存档;`kill -9`(模拟断电)→ 定时存档的 `last.pt` 完好可读;打包第二次运行「待打包 0 个」。
|
||||
不续的只有几分钟级的步骤(kin、gate1、r4、export、bench、accept),整步重跑。
|
||||
|
||||
## 四、仍未验证(如实)
|
||||
|
||||
- **Windows 侧全部没有实测**:`CREATE_NO_WINDOW` 下关掉黑窗口训练能否继续跑;`/proc/$$/winpid` 与 `taskkill /T` 能否结束整棵树;Git Bash 自带的 `timeout` 能否把 TERM 送到 python.exe / node.exe 子进程(Linux 下已验证会结束整个进程组)。
|
||||
- **R4 探针没在真浏览器跑过**(沙箱没有 Chromium):onnxruntime-web 1.20 的 proxy 从 jsDelivr 加载能否建出 Worker(建不出会自动退回并写明 `proxyError`)、无头模式下 WebGPU 是否可用。
|
||||
- R2 新判据的池无关性只能在 1.7 M 样本上验证(判据见第一节表)。
|
||||
- R8 出题耗时:沙箱 2 vCPU 出 2 题 9 分钟仍未出完,已停掉,改在本机测;`policy_bench.json` 的 r8 段会记录请求题数、实际题数和出题耗时。
|
||||
- 训练 / APG / 挖错的机时仍然零实测,第七节之后的结论不变。
|
||||
|
||||
## 五、请在本机按顺序验证
|
||||
|
||||
1. `git apply review5_fixes.patch`(基于 `e3bb783`)。
|
||||
2. 激活训练环境,双击 `ml/console.bat`:页面「全流程」卡片底部应显示 Python / torch / GPU;用 `set PYTHON=C:\不存在\python.exe` 再双击,应直接提示「不能 import torch」。
|
||||
3. 终端单跑 `bash ml/pipeline.sh r4`,把 `ml/runs/r4.json` 发我:4 种配置的 P50/P99、`proxy` 是否为 true、`tickGapMax`、WebGPU 数字。
|
||||
4. 网页「开始训练」(续跑),在 gen 节拍出现后:① 另开 Git Bash 跑 `bash ml/pipeline.sh gen`,应被拒绝(返回码 9);② 关掉控制台黑窗口,任务管理器里 node 进程应仍在;③ 重新双击 `console.bat`,页面应显示「由上一个控制台窗口启动」,点「停止」应结束全部 node 进程。
|
||||
5. 其余照常跑到 prep,把 `stats.json`(`gapDrop`)与 `r2.json`(`label_std_norm` 和 `label_std_mean` 两个口径)发我。
|
||||
6. 断点续跑:gen 跑到一半点「停止」再点「续跑」,`ml/data/log_*.txt` 应出现「续写:已有 N 条」;train 跑过 10 分钟后点「停止」,终端应出现「已存档 last.pt(第 N 步)」,续跑后出现「续训:从第 N 步继续」。
|
||||
|
||||
@@ -519,9 +519,11 @@ MeanFlow
|
||||
</tr>
|
||||
<tr class="odd">
|
||||
<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>小模型下 WebGPU 调度开销占主导</td>
|
||||
<td>小模型下 WebGPU 调度开销通常占主导,以 R4 实测为准</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -882,7 +884,9 @@ WTA 谁更好</strong>;因此 WTA 只作为 M5
|
||||
</tr>
|
||||
<tr class="odd">
|
||||
<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>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -1515,10 +1519,15 @@ held-out 闭环仿真(开仿真安全层)测量,由
|
||||
<tr class="even">
|
||||
<td>R4</td>
|
||||
<td>浏览器推理延迟</td>
|
||||
<td>训练前用随机初始化模型导出 ONNX(延迟与权重无关),wasm-SIMD
|
||||
多线程测 batch=8 P99 < 15 ms</td>
|
||||
<td>减小模型(d=128,约 1.8M 参数);同时测 WebGPU,更快则换</td>
|
||||
<td><strong>M1 前(最大不确定项,训练前结掉)</strong></td>
|
||||
<td>训练前用随机初始化模型导出
|
||||
ONNX(延迟与权重无关),<code>pipeline.sh r4</code>
|
||||
无头浏览器自动测部署口径 K=8 P99 < 15 ms(2026-10-18 本机实测旧接口 4
|
||||
线程 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 class="odd">
|
||||
<td>R5</td>
|
||||
@@ -1596,20 +1605,23 @@ a_max、插补频率与提交步数。</li>
|
||||
</ol>
|
||||
<hr />
|
||||
<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/
|
||||
pipeline.sh 阶段脚本:setup / kin / gate1 / gen / prep / train / m5 / apg / mine / export / bench
|
||||
gen/ sampler / teacher(确定性 PRM A*)/ timelaw / gen_dataset / consistency / stats / run_parallel.sh
|
||||
prep/ export_kin.cjs → assets/kin.json;build_dataset.cjs → data/samples/*.npy;run_prep.sh
|
||||
data/ (不入库)shard_*.jsonl stats.json consistency.json samples/
|
||||
common/ kin_torch.py(可微运动学 + 胶囊间隙) features.py(83 维特征)
|
||||
common/ kin_torch.py(可微运动学 + 胶囊间隙) features.py(83 维特征) ckpt.py(原子存档、定时存档、体面停止)
|
||||
model/ policy.py(MeanFlow 头;WTA K=8 回退头) meanflow.py
|
||||
data.py train.py 样本加载、预训练
|
||||
sim/closed_loop.py 闭环仿真(验证、挖错、门禁共用)
|
||||
apg/apg_finetune.py APG 可微微调
|
||||
mining/ tasks.py mine_round.py gate.py loop.sh
|
||||
eval/ dump_parity.cjs check_parity.py(JS ↔ PyTorch 对拍)
|
||||
configs/ base.json wta.json apg.json
|
||||
configs/ base.json wta.json small.json apg.json
|
||||
export_onnx.py serve.py README.md
|
||||
webapp/js/ik-policy.js 运行时:特征、距离下界认证、刹停、投影、精修、Runner(浏览器与 Node 共用)
|
||||
webapp/js/ik-policy-glue.js 查看器接入(?policy=1)
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
| 安全层 | 距离下界认证 + 可刹停不变式 | A | Schwarzer 2005 连续认证;Thumm & Althoff 2022 failsafe |
|
||||
| 30/30 手段 | 子目标头、价值头、冷启动/卡死时短前瞻、卡死换向 | B / C | AWE、SHAC、BID |
|
||||
| 老师 | PRM 路网 A* + 捷径平滑(确定性);A* 失败才用 `solveDyn` | B | MπNets:老师不一致碰撞率 13.78%,一致老师 0.94% |
|
||||
| 推理后端 | onnxruntime-web wasm-SIMD 多线程 | C | 小模型下 WebGPU 调度开销占主导 |
|
||||
| 推理后端 | onnxruntime-web wasm-SIMD 多线程,推理放代理 Worker(不占主线程),线程数 min(16, ⌊0.6×逻辑核⌋);R4 探针同测 WebGPU,wasm 不达标而 WebGPU 达标则部署用 WebGPU | C | 小模型下 WebGPU 调度开销通常占主导,以 R4 实测为准 |
|
||||
|
||||
### 1.1 取舍决定(每项只有一个答案)
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
|---|---|---|
|
||||
| 数据生成 | Hark 沙箱(2 vCPU / 7 GB,Node)或用户机器 | 纯 JS,无需 GPU |
|
||||
| 预训练 + 后训练 | 用户 RTX 3090 24 GB | batch 1024–2048 |
|
||||
| 部署推理 | 浏览器 onnxruntime-web wasm-SIMD 多线程;后续真机控制器 | 与现有查看器同页 |
|
||||
| 部署推理 | 浏览器 onnxruntime-web wasm-SIMD 多线程(代理 Worker);ONNX 接口 feat[1,83]、z[K,16,6]:观测编码一次、K=8 条候选共享编码再解码;后续真机控制器 | 与现有查看器同页 |
|
||||
|
||||
```bash
|
||||
conda create -n armtf python=3.11 -y && conda activate armtf
|
||||
@@ -444,7 +444,7 @@ for 每个 batch(随机采样 q0 和目标,不需要老师):
|
||||
| R1 | 老师在相邻任务上左右绕行跳变,标签自相矛盾 | 起终点扰动 ±5 mm 的邻居任务同伦类一致率 ≥ 90% | 对 A* 边代价加左右偏好项,强制同区域选同侧 | M1 |
|
||||
| R2 | 时间律仍有不可观测成分,闭环起不了步 | 近邻样本标签方差 < 10% 步长(`ml/eval/check_r2.py`);起步失败率 = 0(闭环 `start_fail`) | 进一步把剩余路程估计加入输入 | M1 / M5 |
|
||||
| R3 | 距离认证过于保守,贴柱处拒绝率高,影响 30/30 | near_column 上认证拒绝率统计;离线 10⁵ 段 0.01° 审计漏检 = 0 | 训练余量提高到 4–6 mm,接受行程变长;收紧杠杆界(按位形分区) | M2 / M7 |
|
||||
| R4 | 浏览器推理延迟 | 训练前用随机初始化模型导出 ONNX(延迟与权重无关),wasm-SIMD 多线程测 batch=8 P99 < 15 ms | 减小模型(d=128,约 1.8M 参数);同时测 WebGPU,更快则换 | **M1 前(最大不确定项,训练前结掉)** |
|
||||
| R4 | 浏览器推理延迟 | 训练前用随机初始化模型导出 ONNX(延迟与权重无关),`pipeline.sh r4` 无头浏览器自动测部署口径 K=8 P99 < 15 ms(2026-10-18 本机实测旧接口 4 线程 37.0 ms、16 线程 19.5 ms,均不达标) | 先做三项不改模型的改动:编码一次(沙箱 CPU 实测省约 20%)、默认线程 16、推理移出主线程;仍不达标则 WebGPU 达标即用 WebGPU,否则 d=128(`configs/small.json`,约 1.8M 参数)。K=8 不降 | **M1 前(最大不确定项,训练前结掉;不过流程自动停)** |
|
||||
| R5 | MeanFlow 头在 6 轴、位姿目标下穿柱或训练不稳 | near_column 穿柱率(关安全层)≤ 2%;损失不发散 | 同骨干换 WTA K=8 头 | M5 |
|
||||
| R6 | 位姿目标的多 IK 分支带来额外多峰 | 不同肘/腕分支子集到达率 ≥ 主集 − 2 个百分点 | 加目标分支标志 | M5 |
|
||||
| R7 | APG 压缩多模态或拉长路径(ELMP 中路径 +23%) | 每轮门禁:B/C 类左右绕行比例变化 ≤ 5 个百分点、行程中位数比 ≤ 1.05(`ml/mining/gate.py`) | 加大 BC 锚定权重;行程项 | M6 |
|
||||
@@ -469,7 +469,7 @@ for 每个 batch(随机采样 q0 和目标,不需要老师):
|
||||
|
||||
## 11. 目录规划
|
||||
|
||||
运行步骤、命令与耗时见 `ml/README.md`。
|
||||
运行步骤、命令与耗时见 `ml/README.md`。所有长步骤都能断点续跑,意外中断最多损失几分钟:生成按条续写(每条任务的随机数只由种子和序号决定),打包按 shard 跳过,预训练与 APG 每 10 分钟原子存一次 `last.pt`,停止时先存档再退出。
|
||||
|
||||
```
|
||||
ml/
|
||||
@@ -477,14 +477,14 @@ ml/
|
||||
gen/ sampler / teacher(确定性 PRM A*)/ timelaw / gen_dataset / consistency / stats / run_parallel.sh
|
||||
prep/ export_kin.cjs → assets/kin.json;build_dataset.cjs → data/samples/*.npy;run_prep.sh
|
||||
data/ (不入库)shard_*.jsonl stats.json consistency.json samples/
|
||||
common/ kin_torch.py(可微运动学 + 胶囊间隙) features.py(83 维特征)
|
||||
common/ kin_torch.py(可微运动学 + 胶囊间隙) features.py(83 维特征) ckpt.py(原子存档、定时存档、体面停止)
|
||||
model/ policy.py(MeanFlow 头;WTA K=8 回退头) meanflow.py
|
||||
data.py train.py 样本加载、预训练
|
||||
sim/closed_loop.py 闭环仿真(验证、挖错、门禁共用)
|
||||
apg/apg_finetune.py APG 可微微调
|
||||
mining/ tasks.py mine_round.py gate.py loop.sh
|
||||
eval/ dump_parity.cjs check_parity.py(JS ↔ PyTorch 对拍)
|
||||
configs/ base.json wta.json apg.json
|
||||
configs/ base.json wta.json small.json apg.json
|
||||
export_onnx.py serve.py README.md
|
||||
webapp/js/ik-policy.js 运行时:特征、距离下界认证、刹停、投影、精修、Runner(浏览器与 Node 共用)
|
||||
webapp/js/ik-policy-glue.js 查看器接入(?policy=1)
|
||||
|
||||
+54
-36
@@ -3,14 +3,24 @@
|
||||
目标:在你的机器上从零跑出 Transformer 避障策略,并在查看器里直接用上。
|
||||
方案依据见 `docs/Transformer轨迹拟合训练方案.md`。所有命令在**仓库根目录**执行。
|
||||
|
||||
**一键运行**:Windows 双击 `ml/console.bat`(Linux/WSL 运行 `bash ml/console.sh`),浏览器自动打开训练控制台 `http://localhost:8100/`,点「开始训练」选好起止阶段即从对拍一路跑到验收,全程进度、曲线、终端输出都在网页上,可随时停止、从停下的那一步继续(见「训练控制台」)。
|
||||
也可以在终端按阶段执行 `bash ml/pipeline.sh <阶段>`,或 `bash ml/pipeline.sh all` 一次跑完;每一阶段完成后看本手册对应小节的「检查」。R2(第 5 步)或 M5(第 7 步)不通过时一键流程会停下,按对应小节处理。
|
||||
**一键运行(无人值守)**:先激活训练用的 Python 环境,Windows 双击 `ml/console.bat`(Linux/WSL 运行 `bash ml/console.sh`),浏览器自动打开训练控制台 `http://localhost:8100/`,点「开始训练」即从对拍一路跑到验收。等价的终端命令是 `bash ml/pipeline.sh all`。
|
||||
- **断了再点一次就是续跑**(断电、蓝屏、误关、手动停止都一样),损失不超过几分钟:
|
||||
- 已成功的阶段自动跳过;
|
||||
- 生成:每条任务的随机数只由(种子, 序号)决定,没写完的 `shard_XXXX.jsonl.part` 保留完整的行、从下一条接着写;
|
||||
- 打包:已打包完的 shard 跳过;
|
||||
- 预训练 / WTA / APG:每 10 分钟原子存一次 `last.pt`(含优化器、步数、最好成绩、早停计数;配置项 `ckpt_min`),续跑时自动 `--resume`;
|
||||
- M5:已评完的那一半(比检查点新的结果文件)跳过;挖错:已过门禁的轮次跳过,断在某轮的 APG 用该轮 `last.pt` 续。
|
||||
- 不续的只有几分钟级的短步骤(对拍、一致性门禁、R4、导出、bench、验收),它们整步重跑。
|
||||
- **同一时间只能跑一条**:网页、终端、`all` 共用一把锁 `ml/runs/pipeline.lock`,第二个入口会被直接拒绝。
|
||||
- **关窗口不影响训练**:网页和控制台窗口都可以关,重新打开即可接着看、可停止。
|
||||
- **门槛在流程内自动处理**:R4 推理延迟自动测(不过就在训练前停下);R2 只警告;M5 不过自动改训 WTA 头,两种头都不过才停;每个阶段有超时上限(估时 ×3),卡死会判失败并放锁。
|
||||
也可以按阶段单独执行 `bash ml/pipeline.sh <阶段>`;每一阶段完成后看本手册对应小节的「检查」。
|
||||
|
||||
| 阶段 | 命令 | 预计耗时(28 核 CPU + RTX 3090) | 产出 |
|
||||
|---|---|---|---|
|
||||
| 1 环境 | `bash ml/pipeline.sh setup` | 10 分钟 | 依赖 |
|
||||
| 2 对拍 | `bash ml/pipeline.sh kin` | 约 1 分钟(估算:300 条对拍实测 7.6 s,流程跑 2000 条) | `ml/assets/kin.json`、对拍 PASS |
|
||||
| 2′ R4 延迟 | `bash ml/pipeline.sh r4` → 浏览器测 | 10 分钟 | 浏览器 `__POLICY.latency()` 输出 |
|
||||
| 2′ R4 延迟 | `bash ml/pipeline.sh r4`(无头 Edge/Chrome 自动测) | 约 5 分钟 | `ml/runs/r4.json` |
|
||||
| 3 老师门禁 | `bash ml/pipeline.sh gate1` | 25–30 分钟(单核) | `ml/data/consistency.json` |
|
||||
| 4 生成数据 | `bash ml/pipeline.sh gen` | 不共识约 1 小时;共识约 4–5 小时 | `ml/data/shard_*.jsonl`、`stats.json` |
|
||||
| 5 打包样本 | `bash ml/pipeline.sh prep` | 15–30 分钟 | `ml/data/samples/`、`ml/data/r2.json` |
|
||||
@@ -24,10 +34,12 @@
|
||||
| 13 使用 | `python ml/serve.py` → 浏览器 `?policy=1` | — | — |
|
||||
|
||||
第 4/5/6 步耗时为 RTX 3090 + 28 核实测换算;标「估算」「未实测」的不要当预算,以每步首次输出的速度为准。
|
||||
第 1 步(安装依赖)和第 2′ 步(R4,需要在浏览器里看延迟)建议先手动做;一键流程默认从第 2 步开始。
|
||||
第 1 步(安装依赖)建议先手动做;一键流程默认从第 2 步开始,含 2′。
|
||||
|
||||
---
|
||||
|
||||
> 分歧规则:审查方与实现方意见讨论后仍不一致时,以实现方(Hark)的意见为准先落地;下一轮用实测数据说话(详见 docs/ML运行手册审查.md 第五轮第〇节)。
|
||||
|
||||
## 1. 环境
|
||||
|
||||
- Linux / WSL2,或 **Windows 原生 + Git Bash**(实测第 2/4/5/6 步与闭环仿真可跑);NVIDIA 驱动 + CUDA(`nvidia-smi` 能看到 3090)
|
||||
@@ -49,15 +61,18 @@ bash ml/pipeline.sh kin
|
||||
检查:最后打印的 JSON 里 `"PASS": true`(fk、姿态、组对间隙、83 维特征、间隙梯度与 JS 一致)。
|
||||
`docs/collide.json` 以后只要重新拟合或重生成 `pair2d`/`roadmap`,这一步、数据、模型都要重来(指纹不符时各脚本会拒绝运行)。
|
||||
|
||||
**R4 延迟摸底(训练前做)**:推理延迟只取决于模型结构,与权重无关,所以用随机初始化模型先测,不过就在训练前改结构,不必等训完。
|
||||
**R4 延迟(训练前做,自动)**:推理延迟只取决于模型结构,与权重无关,所以用随机初始化模型先测,不过就在训练前改结构,不必等训完。
|
||||
|
||||
```bash
|
||||
bash ml/pipeline.sh r4 # 随机初始化 4.92 M 模型导出 fp32 ONNX 到 webapp/models/
|
||||
python ml/serve.py # 浏览器打开 http://localhost:8000/?policy=1 ,来回拖动黄球约 1 分钟
|
||||
bash ml/pipeline.sh r4 # 随机初始化模型导出到 ml/runs/r4/traj_policy_r4.onnx(不碰 webapp/models/),无头浏览器自动测 → ml/runs/r4.json
|
||||
```
|
||||
|
||||
控制台运行 `__POLICY.latency()`:推理 batch=8 P99 < 15 ms、安全层 P99 < 15 ms 为通过(随机权重的动作是乱的,手臂会乱动或频繁刹停,这一步只看延迟)。
|
||||
不通过:把 `ml/configs/base.json` 的 `model.d` 从 192 改为 128(`ff` 改 512)后重跑 `r4`;仍不通过再加 `&ep=webgpu` 对比。把输出发我。
|
||||
部署形态:模型每次只编码 1 行观测、解码 K=8 条候选(`meta.enc_once`);wasm 线程默认 `min(16, ⌊0.6×逻辑核⌋)`,推理放在 onnxruntime 的代理 Worker 里不占主线程。
|
||||
探针测 4 种配置:默认线程 + Worker(部署口径)、4 线程(对照旧默认)、默认线程不开 Worker(看 `tickGapMax`:推理是否卡住 20 ms 节拍)、WebGPU。
|
||||
**通过线:部署口径 batch=8 P99 < 15 ms**;wasm 不过而 WebGPU 过也算通过,`r4.json` 记 `deploy_ep: webgpu`,查看器用 `&ep=webgpu`。
|
||||
不通过时流程停在这里(返回码 5):改用 `CFG=ml/configs/small.json`(d=128、ff=512,约 1.8 M 参数)重跑。确认要继续设 `R4_STRICT=0`。
|
||||
找不到浏览器时 `pass` 为 null、流程继续、验收标红;可用 `R4_BROWSER=浏览器路径` 指定。离线时设 `ORT_LOCAL=1` 并把 onnxruntime-web 的 dist 文件放到 `webapp/vendor/ort/`。
|
||||
手动看:`python ml/serve.py` 后打开 `http://localhost:8000/?policy=../ml/runs/r4/traj_policy_r4.onnx&probe=1`(随机模型默认会被查看器拒绝,`&probe=1` 才放行,徽标标红「随机探针模型」)。
|
||||
|
||||
## 3. 老师一致性门禁
|
||||
|
||||
@@ -97,7 +112,9 @@ bash ml/pipeline.sh prep
|
||||
每条轨迹按 50 Hz 时间律重放,每 3 步取一个样本(特征与部署同一份 JS 代码计算),30% 样本加 σ = 1° 噪声增广(加噪后间隙 < 3 mm 的不用)。
|
||||
认证最小间隙 < 3 mm 的老师轨迹不进训练集(`MIN_GAP_MM=3`,与 `stats.json` 的 `gapDrop` 同一口径):模型只学离障碍 ≥ 3 mm 的走法,运行时投影护栏仍保持 5 mm。
|
||||
检查 `ml/data/samples/index.json`:`train_samples` 约 170 万、`val_samples` 约 9 万(5% 任务划为 val)。
|
||||
随后自动跑 R2 检查(`ml/data/r2.json`):近邻样本前 3 步标签差 `label_std_mean < 0.10`(10% 步长)为通过;不通过时这一步以返回码 4 结束、一键流程停下,先把 `r2.json` 发我。小样本时近邻对不足,`pass` 为 null,不拦。
|
||||
`MIN_GAP_MM` 必须与生成阶段相同(`stats.json` 的 `minGapFilterMm`),不一致时这一步直接报错(返回码 8)。
|
||||
随后自动跑 R2 检查(`ml/data/r2.json`):判据是 `label_std_norm < 0.10`(近邻距离 → 0 的回归截距,与搜索池大小无关);`label_std_mean` 是旧口径(取最近 5% 近邻,随池大小变),只作参考。
|
||||
不通过时默认**只警告、继续训练**(阈值尚未经实测校准),验收汇总列为参考项;`R2_STRICT=1` 才在这里停(返回码 4)。小样本时近邻对不足,`pass` 为 null。
|
||||
|
||||
## 6. 预训练
|
||||
|
||||
@@ -109,11 +126,7 @@ bash ml/pipeline.sh train # MeanFlow 头 → ml/runs/b
|
||||
|
||||
- **通过线(R10):`samples_per_s` ≥ 5000,即 60000 步 ≤ 3.5 小时**。参考:3090、batch 1024 实测 6.2 k 样本/s ≈ 2.8 小时。MeanFlow 的 JVP 双前向使训练受算力限制,加大 batch 不会提高样本/s;低于通过线时把 `configs/base.json` 的 `fk_caps` 从 96 降到 48,仍不够再把 `steps` 降到 40000
|
||||
- 训练中每 2000 步打印一行 `EVAL {...}`:val 任务闭环(部署节奏、不开安全层)`success`、`collision_rate`、`time_ok_1p3`、`start_fail`。时限 = 老师时长 × 2(至少 3 s)
|
||||
- 显存不够把 `batch` 改成 512。中断后续训(日志追加;新训练会覆盖旧日志):
|
||||
|
||||
```bash
|
||||
python -m ml.train --samples ml/data/samples --out ml/runs/base --resume ml/runs/base/last.pt
|
||||
```
|
||||
- 显存不够把 `batch` 改成 512。中断后再跑 `bash ml/pipeline.sh train`(或 `all`)会自动从 `ml/runs/<头>/last.pt` 续训(每 10 分钟存一次),测速那 2000 步做过就跳过。训练发散时不会用发散的权重覆盖 `last.pt` / `best.pt`。
|
||||
|
||||
## 7. M5 门禁:选定动作头
|
||||
|
||||
@@ -128,12 +141,8 @@ bash ml/pipeline.sh m5
|
||||
|
||||
三种口径的关系:M5 是纯策略能力;仿真安全层是近似;**部署能不能过只看第 11 步**(与浏览器同一份运行时,含投影、距离认证、刹停、预算、换向锁)。三者互不替代。
|
||||
|
||||
不通过(或训练日志出现「训练发散」)则换 WTA K=8 头。WTA 的产物在独立目录,不会覆盖 MeanFlow:
|
||||
|
||||
```bash
|
||||
CFG=ml/configs/wta.json bash ml/pipeline.sh train # → ml/runs/bench_wta/、ml/runs/wta/
|
||||
CFG=ml/configs/wta.json bash ml/pipeline.sh m5 # → ml/runs/m5_gate_wta.json;通过则选定 wta
|
||||
```
|
||||
不通过时**流程内自动**改训 WTA K=8 头(约 3 小时,产物在 `ml/runs/bench_wta/`、`ml/runs/wta/`,不覆盖 MeanFlow),再判一次 → `ml/runs/m5_gate_wta.json`;两种头都不过才停(返回码 3)。`AUTO_WTA=0` 关闭自动分支。
|
||||
单独跑 WTA:`CFG=ml/configs/wta.json bash ml/pipeline.sh train` 再 `CFG=ml/configs/wta.json bash ml/pipeline.sh m5`。
|
||||
|
||||
需要强制指定后续阶段的起点模型时用 `CKPT=路径 bash ml/pipeline.sh apg`。
|
||||
|
||||
@@ -210,33 +219,40 @@ python ml/serve.py # 带跨源隔离头的本地服务,wasm
|
||||
|
||||
| 步骤 | 文件 |
|
||||
|---|---|
|
||||
| 2 | 对拍输出(终端 JSON)、R4 的 `__POLICY.latency()` 输出 |
|
||||
| 2 | 对拍输出(终端 JSON)、`ml/runs/r4.json` |
|
||||
| 3–5 | `ml/data/consistency.json`、`ml/data/stats.json`、`ml/data/samples/index.json`、`ml/data/r2.json` |
|
||||
| 6 | `ml/runs/bench_base/log.jsonl`(测速)、`ml/runs/base/log.jsonl` |
|
||||
| 7 | `ml/runs/m5_gate_*.json` |
|
||||
| 8–9 | `ml/runs/apg/log.jsonl`、`ml/runs/mine/r*/report.json` 与 `gate.json` |
|
||||
| 11–13 | `ml/runs/policy_bench.json`、`ml/runs/acceptance.json`、浏览器 `__POLICY.latency()` 输出 |
|
||||
| 11–13 | `ml/runs/policy_bench.json`、`ml/runs/acceptance.json` |
|
||||
| 任一步出错 | `ml/runs/pipeline.json`、`ml/runs/console.log`(控制台启动时)或终端输出 |
|
||||
|
||||
## 训练控制台
|
||||
|
||||
```text
|
||||
Windows:双击 ml/console.bat Linux / WSL:bash ml/console.sh
|
||||
(等价于 python -m ml.monitor --open,需要训练用的 Python 环境;Windows 另需 Git for Windows 提供的 Git Bash)
|
||||
(等价于 python -m ml.monitor --open。先激活训练用的 Python 环境;或设环境变量 PYTHON=训练环境 python 的完整路径。
|
||||
启动器以「能 import torch」判断 Python 可用;Windows 另需 Git for Windows 提供的 Git Bash)
|
||||
```
|
||||
不要直接双击 `ml/monitor.html`:那样只能看到示例数据(页面顶部有黄色提示)。
|
||||
|
||||
浏览器自动打开 `http://localhost:8100/`,零第三方依赖、不连外网,每 5 秒刷新。
|
||||
|
||||
**开始**:点右上角「▶ 开始训练」→ 选起始/结束阶段(默认从第一个还没成功完成的阶段到「验收汇总」)、动作头、共识过滤、任务数、进程数、目标样本数、挖错轮数 → 「开始」。控制台在后台执行 `bash ml/pipeline.sh all`(`FROM`/`TO` 即起止阶段),用的是启动控制台的同一个 Python。
|
||||
**停止**:「■ 停止」结束整个进程树,这一步记为「已停止」,已完成的步骤保留;再点「开始」默认从这一步继续。
|
||||
**网页可以随时关**,训练不受影响,重新打开即可接着看;**关闭控制台窗口会结束训练**,要关终端继续跑,Linux 用 `nohup bash ml/console.sh &`。
|
||||
终端里直接跑的 `bash ml/pipeline.sh …` 同样会显示进度(只是不能在网页上停止);控制台检测到终端里有阶段在跑时不会重复开始。
|
||||
**自检**:控制台启动时检查训练用 Python 能否 `import torch`、GPU、node、bash,结果显示在「全流程」卡片底部;不过则「开始训练」不可点。
|
||||
**开始**:点「▶ 开始训练」→ 运行方式(续跑 / 从头重跑)、动作头、共识过滤、任务数、进程数、目标样本数、挖错轮数、`MIN_GAP_MM` → 「开始」。控制台在后台执行 `bash ml/pipeline.sh all`。
|
||||
- 续跑:跳过已成功的阶段。改了参数时只作废受影响的阶段(改生成参数 → 从生成起重跑;改动作头 → R4 与训练起重跑;改挖错参数 → 从挖错起重跑),旧产物移到 `ml/archive/<时间>/`,不删除。
|
||||
- 从头重跑(`FRESH=1`):全部旧产物移到 `ml/archive/<时间>/`。
|
||||
**停止**:「■ 停止」时,训练类阶段(预训练 / M5 / APG / 挖错)先收到停止请求(`ml/runs/STOP`),存好 `last.pt` 自行退出(最多等 90 秒),然后结束整个进程树;这一步记为「已停止」并放锁,再点「开始训练」即从断点续跑。终端里按 Ctrl+C 或 `kill` 也会先存档再退出。终端或上一个控制台窗口起的流水线也能在这里停(按锁里记录的进程号)。
|
||||
**网页和控制台窗口都可以关**,训练不受影响(Windows 子进程有自己的隐藏控制台,Linux 在独立会话里),重新双击 `console.bat` 即可接着看。
|
||||
**互斥**:已有一条在跑时(网页、终端或 `all` 起的都算),第二个入口会被拒绝并给出持锁进程号;进程被强杀没来得及放锁时,心跳 5 分钟不刷新即视为失效。
|
||||
**超时**:每个阶段上限 = 估时 ×3(网页「本步粗估 / 上限」;`TIMEOUT_SCALE=2` 整体放宽),超了判卡死、阶段失败、放锁。生成阶段每分钟往终端输出打一行节拍。
|
||||
**安全**:开始/停止只接受本控制台页面发起的请求(自定义请求头 + 同源检查),本机其他网页发不进来。
|
||||
|
||||
页面内容,从上到下:
|
||||
|
||||
| 区域 | 内容 | 数据来源 |
|
||||
|---|---|---|
|
||||
| 流水线 | 11 个阶段的状态(完成 / 运行中 / 失败 / 已停止 / 未开始)与各步用时 | `ml/runs/pipeline.json`(每个阶段开始结束时自动写) |
|
||||
| 流水线 | 12 个阶段的状态(完成 / 运行中 / 失败 / 已停止 / 未开始)与各步用时 | `ml/runs/pipeline.json`(每个阶段开始结束时自动写) |
|
||||
| 全流程 | 全流程百分比(按各步粗估耗时加权)、当前步骤与它的进度、总用时、本步用时、剩余粗估、开始/停止按钮 | 同上 + 各阶段产物 |
|
||||
| GPU | 利用率、显存、温度、功耗 | `nvidia-smi` |
|
||||
| 当前运行 | 正在跑的训练(自动跟随;顶部下拉框可切换 `ml/runs` 下任一次运行):步数、已用/剩余时间、预计完成时刻、评估标记 | `ml/runs/*/log.jsonl` |
|
||||
@@ -244,7 +260,7 @@ Windows:双击 ml/console.bat Linux / WSL:bash ml/console.sh
|
||||
| 各阶段结果 | 数据统计(含 `gapDrop`)、R2、M5 两种口径、挖错各轮与门禁、部署评测、验收逐项 | `ml/data/*.json`、`ml/runs/*.json` |
|
||||
| 终端输出 | 控制台启动的流水线的命令行输出,报错标红 | `ml/runs/console.log` |
|
||||
|
||||
当前步骤的进度怎么算:生成数据 = 已写出任务条数 / 目标条数(含自动补量);打包 = 已打包分片 / 分片数;预训练 = (测速 2000 步 + 正式步数) / (2000 + 总步数);APG = 步数;挖错 = 已完成轮数 + 本轮 APG 步数(共最多 `ROUNDS` 轮,提升不足会提前停);M5 = 两遍评估;对拍、老师门禁、导出、部署评测、验收无法计量,显示用时与粗估。全流程百分比与剩余时间按手册表格的粗估耗时加权,只作参考。
|
||||
当前步骤的进度怎么算:生成数据 = 已写出任务条数 / 目标条数(含自动补量);打包 = 已打包分片 / 分片数;预训练 = (测速 2000 步 + 正式步数) / (2000 + 总步数);APG = 步数;挖错 = 已完成轮数 + 本轮 APG 步数(共最多 `ROUNDS` 轮,提升不足会提前停);M5 = 两遍评估;对拍、老师门禁、导出、部署评测、验收无法计量,显示用时与粗估。全流程百分比与剩余时间按 `ml/pstate.py` 里的阶段估时加权(与超时上限同一份表),只作参考。
|
||||
|
||||
同一局域网的手机或其他电脑要看:`python -m ml.monitor --host 0.0.0.0`,用本机 IP 访问;其他机器只能看,开始/停止只接受本机操作(`--remote-control` 放开)。
|
||||
|
||||
@@ -252,9 +268,10 @@ Windows:双击 ml/console.bat Linux / WSL:bash ml/console.sh
|
||||
|
||||
- **指纹不符**:`collide.json` 变了。依次重跑第 2、4、5 步并重训。
|
||||
- **训练发散**(loss 出现 nan/inf,自动停在 `diverged.pt`):先把 `lr` 降到 5e-4 重试一次,仍发散就走 WTA 回退(第 7 步)。
|
||||
- **显存/内存不足**:降 `batch`;APG 还可把 `seg_plans` 降到 5。出题与间隙计算已分块(每块 512 个位形)。
|
||||
- **显存/内存不足**:降 `batch`;APG 还可把 `seg_plans` 降到 5。出题与间隙计算按空闲显存自动分块(用 1/4)。
|
||||
- **小样本摸底**:几百条任务时 val 可能为空,训练会提示并跳过闭环验证;R2 检查近邻对不足时 `pass` 为 null,不拦后续步骤。
|
||||
- **`all` 在第 7 步中止**:M5 门禁不通过时有意退出(返回码 3),按第 7 步换 WTA 头。
|
||||
- **返回码**:3 = M5 两种头都没过;4 = R2 不过且 `R2_STRICT=1`;5 = R4 不过;6 = 生成进程崩溃(看 `ml/data/log_*.txt`,采样器连续失败会写明原因);8 = `MIN_GAP_MM` 前后不一致;9 = 已有一条在跑;124 = 阶段超时。
|
||||
- **生成阶段报「采样失败:…连续 N 次未命中」**:多半是 `collide.json` 或关节限位变了,某类位形几乎不存在;把该日志发我。
|
||||
- **浏览器只有 1 线程**:没走 `ml/serve.py`(缺 COOP/COEP 头)。
|
||||
- **查看器徽标「策略模式不可用」**:看控制台报错,多为模型文件缺失、指纹或 TCP 不符。
|
||||
|
||||
@@ -265,7 +282,7 @@ ml/
|
||||
pipeline.sh 一条龙阶段脚本(环境变量见文件头);每阶段开始/结束经 pstate.py 写 runs/pipeline.json
|
||||
console.bat / console.sh 训练控制台一键启动(Windows / Linux)
|
||||
monitor.py monitor.html 训练控制台:本地 HTTP 服务(开始/停止/进度/日志接口)+ 零依赖网页(地址带 ?demo=1 看示例数据)
|
||||
pstate.py 流水线状态文件读写
|
||||
pstate.py 流水线状态 runs/pipeline.json、互斥锁 runs/pipeline.lock、阶段估时/超时表(唯一来源)
|
||||
gen/ 老师数据:sampler / teacher(确定性 PRM A*)/ cert.cjs(距离下界认证,实现在 ik-policy.js)/
|
||||
pathdev.cjs(同伦类判定,共识过滤与一致性门禁用)/ timelaw / gen_dataset / consistency / stats / run_parallel.sh
|
||||
prep/ export_kin.cjs(→ assets/kin.json)、build_dataset.cjs(→ samples/*.npy)、npy.cjs(.npy 写出)、run_prep.sh
|
||||
@@ -275,11 +292,12 @@ ml/
|
||||
sim/closed_loop.py 部署节奏闭环仿真(验证 / 挖错 / 门禁)
|
||||
apg/apg_finetune.py APG 可微微调
|
||||
mining/ tasks.py(无老师出题)、mine_round.py、gate.py(含 R7)、loop.sh
|
||||
eval/ dump_parity.cjs + check_parity.py(JS↔PyTorch 对拍)、check_r2.py(R2)、acceptance.py(验收汇总)
|
||||
eval/ dump_parity.cjs + check_parity.py(JS↔PyTorch 对拍)、check_r2.py(R2)、r4_probe.py(R4 无头浏览器探针)、acceptance.py(验收汇总)
|
||||
toy/toy_bimodal.py 2D 双模态对照实验(动作头选型依据)
|
||||
export_onnx.py serve.py
|
||||
configs/ base.json(MeanFlow)、wta.json(回退)、apg.json
|
||||
configs/ base.json(MeanFlow)、wta.json(回退)、small.json(R4 不过时的 d=128)、apg.json
|
||||
webapp/js/ik-policy.js 运行时:特征、距离下界认证、刹停、投影、精修、Runner(浏览器与 Node 共用)
|
||||
webapp/js/ik-policy-glue.js 查看器接入(?policy=1)
|
||||
webapp/js/ik-policy-glue.js 查看器接入(?policy=1;&threads= &proxy=0 &ep=webgpu &probe=1)
|
||||
webapp/r4_probe.html R4 探针页(r4_probe.py 打开)
|
||||
tests/policy_bench.cjs 部署形态评测(nc / route / r8 / nc1000)
|
||||
```
|
||||
|
||||
+28
-9
@@ -7,7 +7,7 @@
|
||||
"到达"仍是每 seg_plans 段末的距离项(无到达即停),属有意设计:段末项给出持续的接近梯度。
|
||||
margin_train 3 mm:与训练数据过滤线一致(认证最小间隙 < 3 mm 的老师轨迹不进训练集),碰撞项不与 BC 标签对抗;
|
||||
运行时投影护栏独立保持 5 mm。端点与中点两个铰链取均值,与只查端点同量纲,w_col 无需重标。"""
|
||||
import argparse, json, math, os, random, time
|
||||
import argparse, json, math, os, random, sys, time
|
||||
import torch
|
||||
|
||||
from ml.common.kin_torch import Kin
|
||||
@@ -16,6 +16,7 @@ from ml.data import Samples, load_tasks
|
||||
from ml.model.policy import build, wta_loss
|
||||
from ml.model.meanflow import meanflow_loss
|
||||
from ml.sim.closed_loop import evaluate, tasks_to_tensors, goal_err
|
||||
from ml.common.ckpt import Saver, atomic_save
|
||||
|
||||
|
||||
def load_pool(paths):
|
||||
@@ -32,6 +33,7 @@ def main():
|
||||
ap.add_argument('--pool', nargs='*', default=[]); ap.add_argument('--out', default='ml/runs/apg')
|
||||
ap.add_argument('--config', default='ml/configs/apg.json'); ap.add_argument('--steps', type=int, default=None)
|
||||
ap.add_argument('--device', default='cuda' if torch.cuda.is_available() else 'cpu'); ap.add_argument('--seed', type=int, default=0)
|
||||
ap.add_argument('--resume', default=None, help='从 <out>/last.pt 续跑(2026-10-18 第五轮,审查 N-11:几小时的 APG 断了不再从头来)')
|
||||
a = ap.parse_args()
|
||||
cfg = json.load(open(a.config)); C = cfg['apg']
|
||||
if a.steps: C['steps'] = a.steps
|
||||
@@ -49,13 +51,26 @@ def main():
|
||||
opt = torch.optim.AdamW(model.parameters(), lr=C['lr'], betas=(0.9, 0.95), weight_decay=0.0)
|
||||
amp = dev.startswith('cuda') and C.get('amp') == 'bf16'
|
||||
step = torch.tensor(STEP, device=dev)
|
||||
log = open(os.path.join(a.out, 'log.jsonl'), 'w')
|
||||
base, _ = evaluate(model.eval(), kin, val_tasks, dev)
|
||||
print('EVAL0', json.dumps(base), flush=True); log.write(json.dumps({'eval': base, 'step': 0}) + '\n')
|
||||
best = base['success']
|
||||
torch.save({'model': model.state_dict(), 'cfg': mcfg, 'step': 0, 'fp': kin.fp, 'eval': base}, os.path.join(a.out, 'best.pt'))
|
||||
it0 = 0
|
||||
if a.resume and os.path.exists(a.resume):
|
||||
rk = torch.load(a.resume, map_location=dev); model.load_state_dict(rk['model'])
|
||||
if 'opt' in rk: opt.load_state_dict(rk['opt'])
|
||||
it0 = rk.get('step', 0)
|
||||
bp = os.path.join(a.out, 'best.pt')
|
||||
best = torch.load(bp, map_location='cpu').get('eval', {}).get('success', -1.0) if os.path.exists(bp) else -1.0
|
||||
log = open(os.path.join(a.out, 'log.jsonl'), 'a')
|
||||
print(f'续跑:从第 {it0} 步继续,当前最好 success {best:.4f}', flush=True)
|
||||
else:
|
||||
log = open(os.path.join(a.out, 'log.jsonl'), 'w')
|
||||
base, _ = evaluate(model.eval(), kin, val_tasks, dev)
|
||||
print('EVAL0', json.dumps(base), flush=True); log.write(json.dumps({'eval': base, 'step': 0}) + '\n')
|
||||
best = base['success']
|
||||
atomic_save({'model': model.state_dict(), 'cfg': mcfg, 'step': 0, 'fp': kin.fp, 'eval': base}, os.path.join(a.out, 'best.pt'))
|
||||
M = C['margin_train_mm']; t0 = time.time()
|
||||
for it in range(C['steps']):
|
||||
saver = Saver(C.get('ckpt_min', 10)) # 2026-10-18:每 10 分钟 + 收到停止请求时存 last.pt(不再只在每 500 次验证时存)
|
||||
def save_last(n, ev=None):
|
||||
atomic_save({'model': model.state_dict(), 'opt': opt.state_dict(), 'cfg': mcfg, 'step': n, 'fp': kin.fp, 'eval': ev}, os.path.join(a.out, 'last.pt')); saver.mark()
|
||||
for it in range(it0, C['steps']):
|
||||
model.train()
|
||||
batch = random.choices(train_tasks, weights=w, k=C['batch'])
|
||||
q0, T, R, ex, tr = tasks_to_tensors(batch, dev)
|
||||
@@ -134,14 +149,18 @@ def main():
|
||||
'gnorm': gn.item(), **stats, 'min': (time.time() - t0) / 60}
|
||||
if it % 10 == 0: print(json.dumps(rec), flush=True)
|
||||
log.write(json.dumps(rec) + '\n'); log.flush()
|
||||
if saver.stop():
|
||||
save_last(it + 1); log.close()
|
||||
print(f'收到停止请求:已存档 last.pt(第 {it + 1} 次),下次运行自动续跑', flush=True); sys.exit(143)
|
||||
if saver.due(): save_last(it + 1)
|
||||
if (it + 1) % C['eval_every'] == 0 or it == C['steps'] - 1:
|
||||
res, _ = evaluate(model.eval(), kin, val_tasks, dev)
|
||||
res['it'] = it + 1; res['t'] = time.time()
|
||||
print('EVAL', json.dumps(res), flush=True); log.write(json.dumps({'eval': res}) + '\n'); log.flush()
|
||||
sd = {'model': model.state_dict(), 'cfg': mcfg, 'step': it + 1, 'fp': kin.fp, 'eval': res}
|
||||
torch.save(sd, os.path.join(a.out, 'last.pt'))
|
||||
save_last(it + 1, res) # last 带优化器状态供续跑;best 不带(导出/门禁用)
|
||||
if res['success'] > best + 1e-4:
|
||||
best = res['success']; torch.save(sd, os.path.join(a.out, 'best.pt'))
|
||||
best = res['success']; atomic_save(sd, os.path.join(a.out, 'best.pt'))
|
||||
print(f'done, best success {best:.4f}', flush=True)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -5,6 +5,6 @@
|
||||
"margin_train_mm": 3.0,
|
||||
"w_tgt": 1.0, "w_rot": 0.3, "w_col": 2.0, "w_smooth": 0.02, "w_len": 0.001, "w_bc": 0.5,
|
||||
"cat_weights": {"A": 0.4, "B": 1.0, "C": 1.6, "D": 0.6, "Dr": 0.6},
|
||||
"eval_every": 500, "eval_tasks": 512
|
||||
"ckpt_min": 10, "eval_every": 500, "eval_tasks": 512
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,6 @@
|
||||
"amp": "bf16", "grad_clip": 1.0,
|
||||
"w_mf": 1.0, "w_fk": 0.5, "w_smooth": 0.05, "w_value": 0.1, "w_sub": 0.1,
|
||||
"p_neq": 0.25, "fk_caps": 96,
|
||||
"eval_every": 2000, "eval_tasks": 512, "log_every": 50, "patience_evals": 5
|
||||
"ckpt_min": 10, "eval_every": 2000, "eval_tasks": 512, "log_every": 50, "patience_evals": 5
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -5,6 +5,6 @@
|
||||
"amp": "bf16", "grad_clip": 1.0,
|
||||
"w_mf": 1.0, "w_fk": 0.5, "w_smooth": 0.05, "w_value": 0.1, "w_sub": 0.1,
|
||||
"p_neq": 0.25, "fk_caps": 96,
|
||||
"eval_every": 2000, "eval_tasks": 512, "log_every": 50, "patience_evals": 5
|
||||
"ckpt_min": 10, "eval_every": 2000, "eval_tasks": 512, "log_every": 50, "patience_evals": 5
|
||||
}
|
||||
}
|
||||
|
||||
+12
-3
@@ -1,8 +1,17 @@
|
||||
@echo off
|
||||
rem 训练控制台一键启动(Windows):双击本文件 → 自动打开 http://localhost:8100 ,在网页上点「开始训练」。
|
||||
rem 需要 Python(与训练同一个环境)和 Git for Windows(提供 Git Bash)。关闭本窗口会结束控制台和正在跑的训练。
|
||||
rem 需要:带 torch 的 Python(与训练同一个环境)和 Git for Windows(提供 Git Bash)。
|
||||
rem Python 选择顺序:环境变量 PYTHON → 当前 PATH 里的 python。判据是能 import torch(不是 where python:
|
||||
rem WindowsApps 里的假 python 也能被 where 找到,旧写法会"看起来正常、到训练那步才炸",审查 N-10)。
|
||||
rem 关闭本窗口只关控制台;已开始的训练继续跑,重新双击即可接着看、可停止。
|
||||
chcp 65001 >nul
|
||||
cd /d "%~dp0\.."
|
||||
where python >nul 2>nul || (echo 找不到 python,请先激活训练用的 Python 环境 & pause & exit /b 1)
|
||||
python -m ml.monitor --open %*
|
||||
if "%PYTHON%"=="" set "PYTHON=python"
|
||||
"%PYTHON%" -c "import torch" >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
echo 当前 Python(%PYTHON%)不能 import torch。
|
||||
echo 请先在 Anaconda Prompt / 终端里激活训练环境后运行 ml\console.bat,或设置环境变量 PYTHON 为训练环境的 python.exe 完整路径。
|
||||
pause & exit /b 1
|
||||
)
|
||||
"%PYTHON%" -m ml.monitor --open %*
|
||||
pause
|
||||
|
||||
+7
-2
@@ -1,6 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# 训练控制台一键启动(Linux / WSL / Git Bash):bash ml/console.sh → 打开 http://localhost:8100 ,在网页上点「开始训练」。
|
||||
# 关闭本终端会结束控制台和正在跑的训练;要关终端继续跑,用 nohup bash ml/console.sh & 。
|
||||
# Python 选择与 console.bat 一致:PYTHON → python → python3,判据是能 import torch(审查 N-10)。
|
||||
# 训练进程独立会话运行:关掉终端只关控制台,训练继续;重新运行本脚本即可接着看、可停止。
|
||||
cd "$(dirname "$0")/.."
|
||||
PY=${PYTHON:-python}; command -v "$PY" >/dev/null 2>&1 || PY=python3
|
||||
for c in "${PYTHON:-}" python python3; do
|
||||
[ -n "$c" ] && command -v "$c" >/dev/null 2>&1 && "$c" -c "import torch" 2>/dev/null && { PY=$c; break; }
|
||||
done
|
||||
[ -n "${PY:-}" ] || { echo "找不到能 import torch 的 Python:先激活训练环境,或设 PYTHON=..."; exit 1; }
|
||||
export PYTHON=$PY
|
||||
exec "$PY" -m ml.monitor --open "$@"
|
||||
|
||||
+11
-2
@@ -7,7 +7,10 @@
|
||||
ml/runs/bench_*/log.jsonl R10 吞吐 ≥ 5000 样本/s(≈ 60000 步 ≤ 3.5 h)
|
||||
ml/runs/m5_gate_*.json M5 头门禁
|
||||
ml/runs/mine/r*/gate.json R7 最后一轮左右比例 / 行程门禁
|
||||
2026-10-18 新增(审查:§9 验收表在流水线里没有执行点)。"""
|
||||
ml/runs/r4.json R4 浏览器推理 batch=8 P99 < 15 ms(r4 探针)
|
||||
webapp/models/*.meta.json 被评测的模型不能是 --random 探针
|
||||
2026-10-18 新增(审查:§9 验收表在流水线里没有执行点)。
|
||||
2026-10-18 第五轮:加 R4、随机探针模型拒绝(N-5)、R8 出题不足额判不通过(N-4);R2 改为参考项(N-2:阈值与池大小耦合、未校准)。"""
|
||||
import argparse, glob, json, os, re
|
||||
import torch
|
||||
|
||||
@@ -52,6 +55,10 @@ def main():
|
||||
add('D 类停靠距离 ≤ 老师 + 5 mm 的比例', rate, rate >= 0.98, '≥ 0.98')
|
||||
|
||||
# 2) 部署形态
|
||||
mm = jload('webapp/models/traj_policy.meta.json')
|
||||
add('部署模型是训练产物(非随机探针)', None if mm is None else not mm.get('random_init'), mm is not None and not mm.get('random_init'), 'meta.random_init 不为真')
|
||||
r4 = jload('ml/runs/r4.json')
|
||||
add('R4 浏览器推理 batch=8 P99(ms)', (r4 or {}).get('deploy', {}).get('p99') if r4 else 'missing', bool(r4 and r4.get('pass')), '< 15(随机初始化同结构模型实测)')
|
||||
pb = jload('ml/runs/policy_bench.json')
|
||||
if pb is None:
|
||||
add('部署形态评测', 'missing', False, '先跑 bash ml/pipeline.sh bench')
|
||||
@@ -65,6 +72,8 @@ def main():
|
||||
plans = sum(v.get('plans', 0) for v in sets.values()); br = sum(v.get('brakes', 0) for v in sets.values())
|
||||
add('刹停发生率(刹停 / 推理)', br / plans if plans else None, (br / plans < 0.02) if plans else False, '< 0.02')
|
||||
r8 = sets.get('r8_adversarial')
|
||||
if r8 and r8.get('requested') and r8['n'] < r8['requested']:
|
||||
add('R8 出题数', f"{r8['n']}/{r8['requested']}", False, '出满要求题数(不足则恢复率分母缩水)')
|
||||
if r8:
|
||||
add('R8 对抗集 3 s 内恢复率', r8['recoveryRate'], r8['recoveryRate'] >= 0.95, '≥ 0.95')
|
||||
add('R8 活锁', r8['livelock'], r8['livelock'] == 0, '= 0')
|
||||
@@ -73,7 +82,7 @@ def main():
|
||||
|
||||
# 3) 数据 / 训练 / 后训练门禁
|
||||
r2 = jload('ml/data/r2.json')
|
||||
add('R2 近邻标签 std(步长比例)', r2['label_std_mean'] if r2 else 'missing', bool(r2 and r2['pass']), '< 0.10')
|
||||
add('R2 近邻标签 std(步长比例)', [r2.get('label_std_mean'), r2.get('label_std_norm')] if r2 else 'missing', None, '参考:< 0.10;按近邻距离归一后看 label_std_norm(见 check_r2)')
|
||||
sps = None
|
||||
for f in sorted(glob.glob('ml/runs/bench_*/log.jsonl')):
|
||||
recs = [json.loads(l) for l in open(f) if '"samples_per_s"' in l]
|
||||
|
||||
+18
-4
@@ -3,6 +3,10 @@
|
||||
做法:特征逐维标准化;随机取 Q 个查询样本,在 P 个样本池里找最近邻(排除同一任务,避免同轨迹相邻帧冒充"近邻");
|
||||
取距离最近的 5% 近邻对,比较前 3 步(实际提交的动作)标签:std ≈ RMS(ΔY)/√2,单位为每步最大步长 STEP 的比例。
|
||||
判据:label_std < 0.10(即 < 10% 步长)。起步失败率在闭环仿真结果的 start_fail 字段(acceptance 汇总)。
|
||||
2026-10-18 第五轮(审查 N-2:判据与搜索池耦合):最近 5% 的近邻距离随池大小变,label_std 随之系统偏大/偏小。
|
||||
改为按"近邻距离 → 标签差"做线性回归,取距离 → 0 的截距 label_std_norm 作判据(与池大小无关:池大只是近邻更近、点更多,
|
||||
截距估计的是"同一输入下的固有标签分歧");旧口径 label_std_mean 保留作参考。pipeline 默认只警告,R2_STRICT=1 才拦。
|
||||
自检:同一份数据 --pool 50000 与 --pool 200000 两跑,label_std_norm 应一致(差 < 0.02)。
|
||||
2026-10-18 新增(审查:R2 无执行点)。默认 5000 查询 × 50000 池,分块 256:GPU 峰值 < 0.5 GB,CPU 约 1–2 分钟;近邻对为空时输出 pass=null 而不报错。"""
|
||||
import argparse, json, math
|
||||
import torch
|
||||
@@ -31,16 +35,26 @@ def main():
|
||||
v, j = D.min(1); best_d[s:s + 256] = v; best_j[s:s + 256] = j
|
||||
fin = torch.isfinite(best_d)
|
||||
if fin.sum() < 20:
|
||||
out = {'pairs': 0, 'label_std_mean': None, 'pass': None, 'rule': 'label_std_mean < 0.10', 'note': '近邻对不足(样本太少),未判定'}
|
||||
out = {'pairs': 0, 'label_std_mean': None, 'pass': None, 'pool': P, 'rule': 'label_std_norm < 0.10', 'note': '近邻对不足(样本太少),未判定'}
|
||||
print(json.dumps(out)); json.dump(out, open(a.out, 'w'), indent=1); return
|
||||
# 与池无关的口径:全部有效近邻对(取距离较近的一半,排除远处非线性段)上回归 std ~ a + b·dist,截距 a
|
||||
dd = best_d[fin]; jj = best_j[fin]; qf = torch.nonzero(fin).squeeze(1)
|
||||
half = dd <= torch.quantile(dd, 0.5)
|
||||
Ya, Yb = d.Y[qi][qf[half], :3].float(), d.Y[pi][jj[half], :3].float()
|
||||
sa = (Ya - Yb).pow(2).mean(-1).mean(-1).sqrt() / math.sqrt(2)
|
||||
A = torch.stack([torch.ones_like(dd[half]), dd[half]], 1)
|
||||
coef = torch.linalg.lstsq(A.cpu(), sa.cpu()[:, None]).solution.squeeze(1)
|
||||
std_norm = max(0.0, coef[0].item())
|
||||
thr = torch.quantile(best_d[fin], a.frac)
|
||||
m = fin & (best_d <= thr)
|
||||
Yq, Yp = d.Y[qi][m, :3].float(), d.Y[pi][best_j[m], :3].float()
|
||||
rms = (Yq - Yp).pow(2).mean(-1).mean(-1).sqrt() # 每对、6 轴 × 3 步平均
|
||||
std = (rms / math.sqrt(2))
|
||||
out = {'pairs': int(m.sum()), 'nn_dist_threshold': thr.item(), 'label_std_mean': std.mean().item(),
|
||||
'label_std_p90': torch.quantile(std, 0.9).item(), 'pass': std.mean().item() < 0.10,
|
||||
'rule': 'label_std_mean < 0.10(10% 步长)'}
|
||||
out = {'pairs': int(m.sum()), 'pool': P, 'queries': Q, 'nn_dist_threshold': thr.item(),
|
||||
'label_std_norm': std_norm, 'label_std_slope': coef[1].item(),
|
||||
'label_std_mean': std.mean().item(), 'label_std_p90': torch.quantile(std, 0.9).item(),
|
||||
'pass': std_norm < 0.10,
|
||||
'rule': 'label_std_norm(近邻距离→0 的回归截距)< 0.10(10% 步长);label_std_mean 为旧口径(随池大小变),仅参考'}
|
||||
print(json.dumps(out, indent=1))
|
||||
json.dump(out, open(a.out, 'w'), indent=1)
|
||||
|
||||
|
||||
@@ -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
@@ -1,7 +1,9 @@
|
||||
"""导出部署包(方案 §8.4):
|
||||
python -m ml.export_onnx --ckpt ml/runs/current.pt --out webapp/models/traj_policy.onnx [--fp16]
|
||||
输出 traj_policy.onnx(输入 feat[B,83]、z[B,16,6];输出 act[B,16,6] 归一化增量、value[B]、sub[B,13])
|
||||
与 traj_policy.meta.json(碰撞模型指纹、特征版本、维度)。导出后用 onnxruntime 与 PyTorch 对拍。"""
|
||||
输出 traj_policy.onnx(输入 feat[1,83]、z[K,16,6];输出 act[K,16,6] 归一化增量、value[K]、sub[K,13])
|
||||
与 traj_policy.meta.json(碰撞模型指纹、特征版本、维度、enc_once)。导出后用 onnxruntime 与 PyTorch 对拍。
|
||||
2026-10-18 第五轮:改为"编码一次、解码 K 次"(Policy.sample_shared),meta.enc_once=true;运行时据此只送 1 行特征。
|
||||
--random 导出的是延迟探针(meta.random_init=true):查看器 / bench / 验收都会拒绝把它当策略用。"""
|
||||
import argparse, json, os
|
||||
import numpy as np
|
||||
import torch
|
||||
@@ -16,7 +18,7 @@ class Wrap(torch.nn.Module):
|
||||
super().__init__(); self.m = m
|
||||
|
||||
def forward(self, feat, z):
|
||||
return self.m.sample(feat, z)
|
||||
return self.m.sample_shared(feat, z)
|
||||
|
||||
|
||||
def main():
|
||||
@@ -34,9 +36,9 @@ def main():
|
||||
kin = Kin()
|
||||
if ck.get('fp') and ck['fp'] != kin.fp: raise SystemExit(f"checkpoint 指纹 {ck['fp']} ≠ 当前碰撞模型 {kin.fp}:须重训")
|
||||
os.makedirs(os.path.dirname(a.out) or '.', exist_ok=True)
|
||||
feat, z = torch.randn(8, F_DIM), torch.randn(8, H, 6)
|
||||
feat, z = torch.randn(1, F_DIM), torch.randn(8, H, 6)
|
||||
torch.onnx.export(Wrap(m), (feat, z), a.out, input_names=['feat', 'z'], output_names=['act', 'value', 'sub'],
|
||||
dynamic_axes={'feat': {0: 'B'}, 'z': {0: 'B'}, 'act': {0: 'B'}, 'value': {0: 'B'}, 'sub': {0: 'B'}},
|
||||
dynamic_axes={'z': {0: 'K'}, 'act': {0: 'K'}, 'value': {0: 'K'}, 'sub': {0: 'K'}},
|
||||
opset_version=a.opset, dynamo=False)
|
||||
if a.fp16:
|
||||
from onnxconverter_common import float16
|
||||
@@ -46,10 +48,14 @@ def main():
|
||||
import onnxruntime as ort
|
||||
s = ort.InferenceSession(a.out, providers=['CPUExecutionProvider'])
|
||||
o = s.run(None, {'feat': feat.numpy(), 'z': z.numpy()})
|
||||
with torch.no_grad(): t = m.sample(feat, z)
|
||||
with torch.no_grad():
|
||||
t = m.sample_shared(feat, z)
|
||||
t8 = m.sample(feat.expand(8, -1), z) # 与旧的"复制 8 行"口径逐元素一致
|
||||
err8 = max(float((t[i] - t8[i]).abs().max()) for i in range(3))
|
||||
assert err8 < 1e-4, f'sample_shared 与 sample 不一致:{err8}'
|
||||
err = max(float(np.abs(o[i] - t[i].numpy()).max()) for i in range(3))
|
||||
meta = {'fp': kin.fp, 'tcp0': kin.tcp0.tolist(), 'head': m.head, 'feat_ver': FEAT_VER, 'f_dim': F_DIM, 'h': H, 'step': STEP, 'fp16': a.fp16,
|
||||
'ckpt': os.path.abspath(a.ckpt) if a.ckpt else None, 'random_init': bool(a.random), 'eval': ck.get('eval'), 'max_abs_err_vs_torch': err}
|
||||
'ckpt': os.path.abspath(a.ckpt) if a.ckpt else None, 'random_init': bool(a.random), 'enc_once': True, 'eval': ck.get('eval'), 'max_abs_err_vs_torch': err}
|
||||
json.dump(meta, open(a.out.replace('.onnx', '.meta.json'), 'w'), indent=1)
|
||||
print(json.dumps({'onnx': a.out, 'MB': os.path.getsize(a.out) / 1e6, 'max_abs_err_vs_torch': err}))
|
||||
|
||||
|
||||
+17
-5
@@ -3,7 +3,9 @@
|
||||
* --consensus k:邻域共识过滤。起点不变、目标位置各扰动 5 mm 造 k 个邻居跑老师,原任务路径若与多数邻居
|
||||
* 不在同一绕行类(pathdev.sameClass),整条样本 keep=false(why=consensus)。成本约 (1+k) 倍。
|
||||
* 每行一条任务:采样 → 老师 → 距离下界认证过滤 → 局部时间律(50 Hz)→ 写出。
|
||||
* 认证不过的样本也写出(keep=false + 原因),供 stats 统计丢弃率;训练只用 keep=true。 */
|
||||
* 认证不过的样本也写出(keep=false + 原因),供 stats 统计丢弃率;训练只用 keep=true。
|
||||
* 2026-10-18 断点续写:第 i 条任务的随机数只由 (seed, i) 决定(sampler.reseed);输出文件已存在时保留其中完整的行、
|
||||
* 截掉写了一半的最后一行,从下一条接着写 —— 生成几小时被中断,重跑只补剩下的,不重来、不重复。 */
|
||||
'use strict';
|
||||
const fs = require('fs'), path = require('path');
|
||||
const args = {}; for (let i = 2; i < process.argv.length; i += 2) args[process.argv[i].replace(/^--/, '')] = process.argv[i + 1];
|
||||
@@ -20,10 +22,20 @@ const cert = require('./cert.cjs')(S, {});
|
||||
const { timeParam } = require('./timelaw.cjs');
|
||||
const fp = S.modelFingerprint ? S.modelFingerprint() : null;
|
||||
fs.mkdirSync(path.dirname(OUT), { recursive: true });
|
||||
const fd = fs.openSync(OUT, 'w');
|
||||
let done = 0;
|
||||
if (fs.existsSync(OUT)) {
|
||||
const keep = [];
|
||||
for (const l of fs.readFileSync(OUT, 'utf8').split('\n')) { if (!l) continue; try { JSON.parse(l); keep.push(l); } catch (e) { break; } }
|
||||
done = Math.min(keep.length, NT);
|
||||
fs.writeFileSync(OUT, keep.slice(0, done).map(l => l + '\n').join(''));
|
||||
if (done) process.stderr.write(`[seed ${SEED}] 续写:已有 ${done}/${NT} 条,从第 ${done + 1} 条继续\n`);
|
||||
}
|
||||
const fd = fs.openSync(OUT, 'a');
|
||||
const taskSeed = i => (Math.imul(SEED, 2654435761) ^ Math.imul(i + 1, 40503) ^ 0x9e3779b9) >>> 0;
|
||||
const r3 = v => Math.round(v * 1000) / 1000;
|
||||
const t00 = performance.now();
|
||||
for (let i = 0; i < NT; i++) {
|
||||
for (let i = done; i < NT; i++) {
|
||||
sampler.reseed(taskSeed(i));
|
||||
const task = GIVEN ? Object.assign({ cat: 'E2' }, GIVEN[i], { mode: GIVEN[i].R ? 'pose' : 'pos' }) : sampler.sample();
|
||||
const t = teach(task);
|
||||
const rec = { id: 's' + String(SEED).padStart(4, '0') + '_' + String(i).padStart(6, '0'), cat: task.cat, mode: task.mode,
|
||||
@@ -53,7 +65,7 @@ for (let i = 0; i < NT; i++) {
|
||||
if (t.poseRelaxed && task.mode === 'pose') { rec.mode = 'pos'; rec.R = null; rec.demoted = true; rec.reached = t.err < 1; }
|
||||
if (rec.keep) { const tp = timeParam(t.pts); rec.traj = tp.traj.map(q => q.map(r3)); rec.exec_s = r3(tp.steps * 0.02); }
|
||||
fs.writeSync(fd, JSON.stringify(rec) + '\n');
|
||||
if ((i + 1) % 25 === 0) process.stderr.write(`[seed ${SEED}] ${i + 1}/${NT} ${((performance.now() - t00) / 1000 / (i + 1)).toFixed(2)} s/条\n`);
|
||||
if ((i + 1) % 25 === 0) process.stderr.write(`[seed ${SEED}] ${i + 1}/${NT} ${((performance.now() - t00) / 1000 / (i + 1 - done)).toFixed(2)} s/条\n`);
|
||||
}
|
||||
fs.closeSync(fd);
|
||||
process.stderr.write(`[seed ${SEED}] done ${NT} in ${((performance.now() - t00) / 1000).toFixed(0)} s\n`);
|
||||
process.stderr.write(`[seed ${SEED}] done ${NT}(本次 ${NT - done}) in ${((performance.now() - t00) / 1000).toFixed(0)} s\n`);
|
||||
|
||||
+29
-2
@@ -1,11 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# 并行生成:bash ml/gen/run_parallel.sh <每进程条数> <进程数> [起始种子=1] ["--consensus 4"]
|
||||
# 例:8 核机器 5 万条 → bash ml/gen/run_parallel.sh 6250 8
|
||||
# 2026-10-18 第五轮(审查 N-7 / N-16 / 缺口 1):
|
||||
# - 每个种子先写 shard_XXXX.jsonl.part,写完才改名 → 中断/并发都不会留下被截断却看似完整的 shard;
|
||||
# - 已存在的 shard_XXXX.jsonl(写完的)直接跳过;没写完的 .part 由 gen_dataset 从断点续写(只补剩下的条数);
|
||||
# - 逐个 wait 子进程并检查返回码(此前裸 `wait` 恒为 0,某个 node 崩了也会继续统计);
|
||||
# - 每 60 s 往控制台打一行节拍(已写条数 + 各进程最新进度),网页「终端输出」可见。
|
||||
set -e
|
||||
N=${1:-500}; P=${2:-2}; S0=${3:-1}; EXTRA=${4:-}
|
||||
cd "$(dirname "$0")/../.."
|
||||
mkdir -p ml/data
|
||||
for ((i=0;i<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
|
||||
wait
|
||||
pids=(); seeds=()
|
||||
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
|
||||
cat ml/data/stats.json
|
||||
|
||||
+10
-5
@@ -1,5 +1,8 @@
|
||||
/* 任务采样(方案 §4.3):A 均匀 45% / B 直线被挡 30% / C 贴立柱 15% / D 目标不可达 10%;pos/pose 各半(D 只做 pos) */
|
||||
'use strict';
|
||||
/* 2026-10-18 第五轮(审查 N-16):所有拒绝采样都有次数上限,超限抛错带原因(此前 for(;;) 在 collide.json 改动后可能静默死循环)。 */
|
||||
const CAP = 200000;
|
||||
function giveUp(what, n) { throw new Error(`采样失败:${what} 连续 ${n} 次未命中——多半是 collide.json / 关节限位变了,导致该类位形几乎不存在`); }
|
||||
module.exports = function makeSampler(S, seed) {
|
||||
const L = S.LIM, O = S.ORDER, w = S.wrap180;
|
||||
let st = (seed >>> 0) || 1; const rnd = () => { st = (st * 1664525 + 1013904223) >>> 0; return st / 4294967296; };
|
||||
@@ -8,19 +11,20 @@ module.exports = function makeSampler(S, seed) {
|
||||
const segOK = (a, b) => { const dd = d(a, b); const n = Math.max(1, Math.ceil(Math.max(...dd.map(Math.abs)))); for (let i = 1; i <= n; i++) if (!S.collideFree(a.map((v, k) => v + dd[k] * i / n))) return false; return true; };
|
||||
const hitsColumn = q => (S.collideDetail(q) || []).some(p => /立柱/.test(p.cnA + p.cnB));
|
||||
const nearColumn = q => { for (const k of [0, 1, 2]) for (const sg of [-4, 4]) { const p = q.slice(); p[k] += sg; if (hitsColumn(p)) return true; } return false; };
|
||||
const freeQ = () => { for (;;) { const q = rq(); if (S.collideFree(q)) return q; } };
|
||||
const freeQ = () => { for (let n = 0; n < CAP; n++) { const q = rq(); if (S.collideFree(q)) return q; } giveUp('无碰位形', CAP); };
|
||||
function sample() {
|
||||
const u = rnd();
|
||||
const cat = u < 0.45 ? 'A' : u < 0.75 ? 'B' : u < 0.90 ? 'C' : 'D';
|
||||
let a, b, T, R = null;
|
||||
if (cat === 'A') { a = freeQ(); b = freeQ(); }
|
||||
else if (cat === 'B') { for (;;) { a = freeQ(); b = freeQ(); if (!segOK(a, b)) break; } }
|
||||
else if (cat === 'B') { let n = 0; for (;; n++) { if (n >= 20000) giveUp('B 类直线被挡', n); a = freeQ(); b = freeQ(); if (!segOK(a, b)) break; } }
|
||||
else if (cat === 'C') {
|
||||
for (;;) { b = rq(); if (!S.collideFree(b) || !nearColumn(b)) continue; a = null;
|
||||
let n = 0;
|
||||
for (;; n++) { if (n >= CAP) giveUp('C 类贴立柱终点', n); b = rq(); if (!S.collideFree(b) || !nearColumn(b)) continue; a = null;
|
||||
for (let t = 0; t < 200; t++) { const c = rq(); if (S.collideFree(c) && !segOK(c, b)) { a = c; break; } } if (a) break; }
|
||||
} else {
|
||||
a = freeQ();
|
||||
if (rnd() < 0.6) { for (;;) { b = rq(); if (hitsColumn(b)) break; } T = S.fk(b, 0); } // 目标位置落在立柱附近/内部
|
||||
if (rnd() < 0.6) { let n = 0; for (;; n++) { if (n >= CAP) giveUp('D 类立柱内目标', n); b = rq(); if (hitsColumn(b)) break; } T = S.fk(b, 0); } // 目标位置落在立柱附近/内部
|
||||
else { b = rq(); const p = S.fk(b, 0), o = S.fk(O.map(() => 0), 0); T = p.map((v, i) => o[i] + (v - o[i]) * 1.6); } // 工作空间外
|
||||
}
|
||||
const mode = cat === 'D' ? 'pos' : (rnd() < 0.5 ? 'pos' : 'pose');
|
||||
@@ -28,5 +32,6 @@ module.exports = function makeSampler(S, seed) {
|
||||
if (mode === 'pose') R = S.orient(b);
|
||||
return { cat, mode, q0: a, T, R, qb: b };
|
||||
}
|
||||
return { sample, rnd, segOK };
|
||||
const reseed = v => { st = (v >>> 0) || 1; }; // 2026-10-18:每条任务独立种子,shard 中断后可从第 i 条接着生成
|
||||
return { sample, rnd, segOK, reseed };
|
||||
};
|
||||
|
||||
+2
-2
@@ -45,9 +45,9 @@ def main():
|
||||
so_side = torch.cat([p['side'] for _, p in po]); sn_side = torch.cat([p['side'] for _, p in pn])
|
||||
det = torch.tensor([c in ('B', 'C') for c in cat])
|
||||
# 2026-10-18:左右比例按全部 B/C 题的首次 J1 方向统计,不以到达为条件(APG 把一侧绕行全做失败时,到达子集比例可纹丝不动)
|
||||
left = lambda sd, ok: (((sd > 0) & det).sum() / det.sum().clamp(min=1)).item()
|
||||
left = lambda sd: (((sd > 0) & det).sum() / det.sum().clamp(min=1)).item() # side 为首次 J1 方向(closed_loop,第五轮改)
|
||||
ok_o, ok_n = torch.tensor(so, dtype=torch.bool), torch.tensor(sn, dtype=torch.bool)
|
||||
lr_shift = abs(left(sn_side, ok_n) - left(so_side, ok_o)) * 100
|
||||
lr_shift = abs(left(sn_side) - left(so_side)) * 100
|
||||
both = ok_o & ok_n
|
||||
tro = torch.cat([p['travel'] for _, p in po]); trn = torch.cat([p['travel'] for _, p in pn])
|
||||
travel_ratio = (trn[both].median() / tro[both].median().clamp(min=1e-6)).item() if both.any() else None
|
||||
|
||||
+21
-3
@@ -3,6 +3,8 @@
|
||||
# bash ml/mining/loop.sh [轮数=6] [每轮 APG 步数=1000] [每轮出题数=50000]
|
||||
# 2026-10-18:APG 采样池只用本轮错题(此前累积所有历史轮,后期分布偏向老错题);老错题的保持由门禁"上轮修好仍对"负责。
|
||||
# 统一 $PYTHON;门禁加 R7(左右比例 / 行程)。
|
||||
# 2026-10-18 第五轮(审查 N-11 / 缺口 1):可续跑——已有 gate.json 的轮次直接跳过(读回 gain 与 fixed 链),
|
||||
# 中途断在某轮的 APG 时用该轮 last.pt 续跑;current.pt 只在门禁通过时由 gate.py 更新,本身就是断点。
|
||||
set -e
|
||||
cd "$(dirname "$0")/../.."
|
||||
PY=${PYTHON:-python}
|
||||
@@ -13,14 +15,30 @@ mkdir -p ml/runs/mine
|
||||
SAMPLES="ml/data/samples"; FIXED=""; small=0
|
||||
for ((r=1; r<=ROUNDS; r++)); do
|
||||
D=ml/runs/mine/r$r; mkdir -p $D
|
||||
$PY -m ml.mining.mine_round --ckpt ml/runs/current.pt --seed $((1000+r)) --n $N --out $D
|
||||
if [ -f $D/gate.json ]; then
|
||||
gain=$($PY -c "import json;print(json.load(open('$D/gate.json'))['gain_pp'])")
|
||||
echo "round $r 已完成(gain ${gain} pp),跳过"; FIXED="--fixed $D/gate_fixed.jsonl"
|
||||
[ -s ml/data/e2/shard_e2_r$r.jsonl ] && SAMPLES="ml/data/samples ml/data/samples_e2"
|
||||
if $PY -c "import sys;sys.exit(0 if $gain < 0.5 else 1)"; then small=$((small+1)); else small=0; fi
|
||||
if [ $small -ge 2 ]; then echo "连续 2 轮提升 < 0.5 pp,停止"; break; fi
|
||||
continue
|
||||
fi
|
||||
# 2026-10-18 断点续跑:挖错做完打 mined.ok;E2 老师补路径写 .part 续写、写完改名。重挖时清掉旧的补路径,免得和新题对不上
|
||||
E2F=ml/data/e2/shard_e2_r$r.jsonl
|
||||
if [ -f $D/mined.ok ]; then echo "round $r 挖错已完成,跳过"
|
||||
else
|
||||
rm -f $E2F $E2F.part
|
||||
$PY -m ml.mining.mine_round --ckpt ml/runs/current.pt --seed $((1000+r)) --n $N --out $D
|
||||
touch $D/mined.ok
|
||||
fi
|
||||
# E2(局部极小)请老师补少量路径(唯一保留的后训练老师用途)
|
||||
if [ -s $D/e2_tasks.jsonl ]; then
|
||||
node ml/gen/gen_dataset.cjs --tasks $D/e2_tasks.jsonl --seed $((5000+r)) --out ml/data/e2/shard_e2_r$r.jsonl
|
||||
[ -f $E2F ] || { node ml/gen/gen_dataset.cjs --tasks $D/e2_tasks.jsonl --seed $((5000+r)) --out $E2F.part && mv $E2F.part $E2F; }
|
||||
node ml/prep/build_dataset.cjs ml/data/e2/shard_e2_r$r.jsonl ml/data/samples_e2
|
||||
SAMPLES="ml/data/samples ml/data/samples_e2"
|
||||
fi
|
||||
$PY -m ml.apg.apg_finetune --init ml/runs/current.pt --samples $SAMPLES --pool $D/pool.jsonl --out ml/runs/apg_r$r --steps $STEPS
|
||||
res=""; [ -f ml/runs/apg_r$r/last.pt ] && res="--resume ml/runs/apg_r$r/last.pt"
|
||||
$PY -m ml.apg.apg_finetune --init ml/runs/current.pt --samples $SAMPLES --pool $D/pool.jsonl --out ml/runs/apg_r$r --steps $STEPS $res
|
||||
$PY -m ml.mining.gate --old ml/runs/current.pt --new ml/runs/apg_r$r/best.pt --tasks ml/runs/gate_tasks.jsonl --out $D/gate.json $FIXED
|
||||
FIXED="--fixed $D/gate_fixed.jsonl"
|
||||
gain=$($PY -c "import json;print(json.load(open('$D/gate.json'))['gain_pp'])")
|
||||
|
||||
+15
-3
@@ -7,8 +7,19 @@ import torch
|
||||
from ml.common.features import wrap180
|
||||
|
||||
|
||||
def min_gap_chunked(kin, q, cap=0.5, chunk=512):
|
||||
"""分块算最小间隙:随机位形每个约 2800 个幸存胶囊对、峰值约 1.5 MB/位形,一次 4 万位形会爆内存/显存(2026-10-18 实测出题 OOM),每块 512"""
|
||||
def auto_chunk(kin, mb_per_q, lo=256, hi=8192):
|
||||
"""2026-10-18 第五轮(审查 N-6):分块大小按设备空闲显存定,用掉其 1/4。
|
||||
两处每位形峰值不同不是矛盾:kin.gaps 按 (胶囊对下限 + cap)² 筛候选对,cap=0.5 mm 时幸存约 2800 对(≈1.5 MB/位形),
|
||||
column_gap 用 cap=40 mm,40 mm 内幸存的对多一个量级(≈20 MB/位形)。CPU 上取下限 256。"""
|
||||
if kin.lo.device.type == 'cuda':
|
||||
free = torch.cuda.mem_get_info(kin.lo.device)[0]
|
||||
return int(max(lo, min(hi, free * 0.25 / (mb_per_q * 1e6))))
|
||||
return lo
|
||||
|
||||
|
||||
def min_gap_chunked(kin, q, cap=0.5, chunk=None):
|
||||
"""分块算最小间隙:一次 4 万位形会爆内存/显存(2026-10-18 实测出题 OOM);块大小见 auto_chunk(≈1.5 MB/位形)"""
|
||||
chunk = chunk or auto_chunk(kin, 1.5)
|
||||
return torch.cat([kin.gaps(q[i:i + chunk], cap)['gm'].min(-1).values for i in range(0, q.shape[0], chunk)])
|
||||
|
||||
|
||||
@@ -33,7 +44,8 @@ def straight_blocked(kin, a, b, n=24):
|
||||
return (min_gap_chunked(kin, pts) <= 0).view(a.shape[0], n).any(-1)
|
||||
|
||||
|
||||
def column_gap(kin, q, chunk=256):
|
||||
def column_gap(kin, q, chunk=None):
|
||||
chunk = chunk or auto_chunk(kin, 20.0) # cap=40 → ≈20 MB/位形
|
||||
out = []
|
||||
for s in range(0, q.shape[0], chunk): # 与 min_gap_chunked 一样分块防 OOM
|
||||
gm = kin.gaps(q[s:s + chunk], 40)['gm']
|
||||
|
||||
@@ -126,6 +126,22 @@ class Policy(nn.Module):
|
||||
x = z - self.decode(mem, z, zero, one)
|
||||
return x, v, s
|
||||
|
||||
def sample_shared(self, feat1, z):
|
||||
"""部署接口(ONNX,2026-10-18 第五轮,R4 实测后改):一次观测、K 条候选。
|
||||
feat1 [1,F],z [K,16,6] → act [K,16,6],value [K],sub [K,13](value/sub 对 K 行相同,展开只为保持运行时读法)。
|
||||
此前部署把同一观测复制 K=8 行喂 sample(),encoder 被重复算 8 次(约占总计算 29%);这里只编码一次再展开给解码器。"""
|
||||
mem = self.encode(feat1)
|
||||
v, s = self.heads(mem)
|
||||
K = z.shape[0]
|
||||
if self.head == 'wta':
|
||||
act, _ = self.wta_modes(mem)
|
||||
idx = torch.arange(K, device=z.device) % self.K
|
||||
return act[0, idx] + 0.0 * z, v.expand(K), s.expand(K, -1)
|
||||
m = mem.expand(K, -1, -1)
|
||||
one = torch.ones(K, device=z.device, dtype=z.dtype)
|
||||
x = z - self.decode(m, z, torch.zeros_like(one), one)
|
||||
return x, v.expand(K), s.expand(K, -1)
|
||||
|
||||
def sample_k(self, feat, K, z=None):
|
||||
"""仿真 / APG 用:每个观测 K 条候选 → act [B,K,16,6], value [B], sub [B,13]"""
|
||||
mem = self.encode(feat)
|
||||
|
||||
+53
-42
@@ -141,6 +141,8 @@
|
||||
.form label { display: flex; flex-direction: column; gap: 5px; font-size: 12px; color: var(--dim); }
|
||||
.form input, .form select { background: #0f1520; color: var(--text); border: 1px solid var(--line2); border-radius: 8px; padding: 7px 9px; font: 13px var(--mono); }
|
||||
.form .full { grid-column: 1 / -1; }
|
||||
.ckline { margin-top: 12px; font-size: 12px; color: var(--dim); } .ckline.bad { color: var(--bad); } .ckline .warn { color: var(--warn); }
|
||||
.demobar { background: #fbbf2418; border: 1px solid #fbbf2455; color: var(--warn); border-radius: 10px; padding: 8px 12px; margin: 10px 0; font-size: 13px; }
|
||||
.dlg .acts { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; }
|
||||
.dlg .warnbox { margin-top: 12px; font-size: 12px; color: var(--warn); background: #fbbf2410; border: 1px solid #fbbf2433; border-radius: 8px; padding: 8px 10px; display: none; }
|
||||
.toast { position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%); background: #1b2433; border: 1px solid var(--line2); padding: 9px 16px; border-radius: 10px; font-size: 13px; z-index: 50; display: none; box-shadow: 0 10px 30px #0008; }
|
||||
@@ -190,16 +192,16 @@
|
||||
<div class="toast" id="toast"></div>
|
||||
<div class="modal" id="modal"><div class="dlg">
|
||||
<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">
|
||||
<label>起始阶段<select id="fFrom"></select></label>
|
||||
<label>结束阶段<select id="fTo"></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>运行方式<select id="fMode"><option value="resume">续跑:跳过已完成的步骤</option><option value="fresh">从头重跑(旧产物移到 ml/archive/)</option></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>共识过滤 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>生成进程数 NPROC<input id="fP" placeholder="默认 = CPU 核数"></label>
|
||||
<label>目标样本 TARGET_SAMPLES<input id="fT" value="1700000"></label>
|
||||
<label>挖错轮数 ROUNDS<input id="fR" value="6"></label>
|
||||
<label>老师间隙下限 MIN_GAP_MM<input id="fG" value="3"></label>
|
||||
</div>
|
||||
<div class="warnbox" id="fWarn"></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 logyOn = false, job = null, userPicked = false, conOff = 0;
|
||||
/* 阶段表:名称、说明、粗估耗时(小时,RTX 3090 + 28 核;只用于全流程百分比的权重) */
|
||||
const STAGE_META = [
|
||||
['setup', '安装依赖', 0.15], ['kin', '对拍', 0.02], ['gate1', '老师门禁', 0.45], ['gen', '生成数据', 1.0], ['prep', '打包样本', 0.4],
|
||||
['train', '预训练', 3.0], ['m5', 'M5 门禁', 0.25], ['apg', 'APG 微调', 3.0], ['mine', '挖错循环', 9.0], ['export', '导出 ONNX', 0.02],
|
||||
['bench', '部署评测', 1.0], ['accept', '验收汇总', 0.25]];
|
||||
const SM = Object.fromEntries(STAGE_META.map(([k, n, w]) => [k, { n, w }]));
|
||||
/* 阶段表:名称、粗估耗时(小时,作全流程百分比权重)、超时上限。
|
||||
2026-10-18 第五轮(审查 N-14):真实模式下由 /api/status 的 stage_meta 下发(来源 ml/pstate.py STAGES,与超时同源);
|
||||
下面这份只给示例数据用,数值与 pstate.py 一致。 */
|
||||
let STAGE_META = [
|
||||
['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]];
|
||||
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 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;
|
||||
@@ -454,7 +464,7 @@ async function loadRuns() {
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
lastStatus = await api('/api/status');
|
||||
lastStatus = await api('/api/status'); applyStageMeta(lastStatus.stage_meta);
|
||||
job = await api('/api/job').catch(() => null);
|
||||
await loadRuns();
|
||||
if (cur) {
|
||||
@@ -467,7 +477,7 @@ async function poll() {
|
||||
render();
|
||||
}
|
||||
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 (cur && Date.now() / 1000 - lastMtime < 120) setPill('live', `运行中 · ${ago(lastMtime)}更新`);
|
||||
else setPill('', '空闲');
|
||||
@@ -577,9 +587,14 @@ function renderStatus(s) {
|
||||
/* ===================== 全流程卡片 / 开始停止 / 终端 ===================== */
|
||||
function renderPipe(s) {
|
||||
const P = s.pipeline, running = job && job.running;
|
||||
const btns = `<div class="ctlrow">${running || (P && P.current)
|
||||
? `<button class="danger" onclick="stopJob()">■ 停止</button><span class="hint">${running ? `PID ${job.pid} · 从「${SM[job.from]?.n}」到「${SM[job.to]?.n}」` : '由终端启动,需在终端里结束'}</span>`
|
||||
: `<button class="primary" onclick="openStart()">▶ ${P ? '继续 / 重新开始' : '开始训练'}</button><span class="hint">${job && job.rc != null ? (job.stopped ? '上次已手动停止' : job.rc === 0 ? '上次运行已全部完成' : `上次运行失败(返回码 ${job.rc}),看终端输出`) : '点击后选择起止阶段与参数'}</span>`}</div>`;
|
||||
const ck = s.check || {}, lk = s.lock;
|
||||
const ckline = ck.pending ? '<div class="ckline">环境自检中…</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; }
|
||||
const plan = P.plan && P.plan.length ? P.plan : Object.keys(P.stages);
|
||||
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); }
|
||||
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>`
|
||||
: 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>`;
|
||||
$('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>
|
||||
@@ -602,7 +617,7 @@ function renderPipe(s) {
|
||||
<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">${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>${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 openStart() {
|
||||
const P = lastStatus && lastStatus.pipeline, keys = STAGE_META.map(x => x[0]);
|
||||
const opt = keys.map(k => `<option value="${k}">${SM[k].n}(${k})</option>`).join('');
|
||||
$('fFrom').innerHTML = opt; $('fTo').innerHTML = opt;
|
||||
// 默认从第一个还没成功完成的阶段开始,到验收结束
|
||||
let from = 'kin';
|
||||
if (P) { const k = keys.slice(1).find(k => !(P.stages[k] && P.stages[k].rc === 0)); if (k) from = k; }
|
||||
$('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('起始阶段在结束阶段之后。');
|
||||
const P = lastStatus && lastStatus.pipeline, ck = lastStatus && lastStatus.check;
|
||||
if (ck && ck.problems && ck.problems.length) { toast('环境自检未通过:' + ck.problems.join(';')); return; }
|
||||
const w = $('fWarn'); const upd = () => { const msgs = [];
|
||||
const done = P ? (P.plan || []).filter(k => P.stages[k] && P.stages[k].rc === 0) : [];
|
||||
if ($('fMode').value === 'resume' && done.length) msgs.push(`将跳过已完成的 ${done.length} 步:${done.map(k => SM[k]?.n || k).join('、')}。改了参数会自动改为从头重跑。`);
|
||||
if ($('fMode').value === 'fresh' && P) msgs.push('从头重跑:上次的数据、模型、评测结果整体移到 ml/archive/<时间>/(不删除)。');
|
||||
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');
|
||||
}
|
||||
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 body = JSON.stringify({ from: $('fFrom').value, to: $('fTo').value, 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 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({ fresh: $('fMode').value === 'fresh', env });
|
||||
const r = DEMO ? await api('/api/start', body) : await post('/api/start', body);
|
||||
if (r.error) { toast('没能开始:' + r.error); return; }
|
||||
$('modal').classList.remove('show'); toast('已开始'); conOff = 0; userPicked = false; poll();
|
||||
}
|
||||
async function stopJob() {
|
||||
if (!confirm('确定停止?正在运行的这一步会中断,已完成的步骤保留,之后可以从这一步继续。')) return;
|
||||
const r = DEMO ? await api('/api/stop', '{}') : await fetch('/api/stop', { method: 'POST' }).then(x => x.json()).catch(e => ({ error: String(e) }));
|
||||
if (!confirm('确定停止?训练会先存档再退出(最多等 90 秒),生成/打包按已写完的部分保留。之后点「续跑」从断点接着来。')) return;
|
||||
toast('正在停止…训练阶段会先存档');
|
||||
const r = DEMO ? await api('/api/stop', '{}') : await post('/api/stop');
|
||||
toast(r.error ? '没能停止:' + r.error : '已停止'); poll();
|
||||
}
|
||||
$('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;
|
||||
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: {} };
|
||||
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 = {
|
||||
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',
|
||||
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',
|
||||
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 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: '' };
|
||||
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,
|
||||
@@ -716,12 +727,12 @@ function demoApi() {
|
||||
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;
|
||||
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 === 'train') { pipe = mkPipe([['kin', 0.02], ['gate1', 0.47], ['gen', 4.6], ['prep', 0.38]], 'train', 2.15);
|
||||
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], ['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 },
|
||||
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) };
|
||||
} 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;
|
||||
const t1 = now() - 9 * 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/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/start') { const b = JSON.parse(body || '{}'); 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`;
|
||||
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 = `$ ${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: {} }; }
|
||||
return { ok: true, pid: 23001 }; }
|
||||
if (u.pathname === '/api/stop') { jobS = { ...jobS, running: false, rc: -15, stopped: true };
|
||||
|
||||
+160
-41
@@ -1,16 +1,25 @@
|
||||
"""训练控制台(本地网页,零第三方依赖,离线可用):一键开始全流程、看进度、可停止。
|
||||
双击 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]
|
||||
网页上点「开始训练」→ 选起止阶段与参数 → 后台执行 bash ml/pipeline.sh all(FROM/TO 环境变量),
|
||||
输出写 ml/runs/console.log,网页实时显示;「停止」结束整个进程树。网页关掉不影响训练,重新打开即可继续看。
|
||||
网页上点「开始训练」→ 选参数 → 后台执行 bash ml/pipeline.sh all(已完成的阶段自动跳过 = 续跑;勾「从头重跑」= FRESH=1),
|
||||
输出写 ml/runs/console.log,网页实时显示;「停止」结束整个进程树。网页和控制台窗口关掉都不影响训练,重新打开即可继续看。
|
||||
只读数据来源:
|
||||
ml/runs/pipeline.json 各阶段开始/结束/返回码(pipeline.sh 自动写)→ 全流程进度
|
||||
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/data/stats.json / consistency.json / r2.json / samples/index.json、nvidia-smi
|
||||
安全:默认只监听 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
|
||||
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 urllib.parse import urlparse, parse_qs
|
||||
|
||||
@@ -88,6 +97,30 @@ def last_rec(name):
|
||||
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):
|
||||
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': 文字}。"""
|
||||
t0, info = s['start'], s.get('info') or {}
|
||||
if st == 'gen':
|
||||
n = 0
|
||||
for f in newer(f'{DATA}/shard_*.jsonl', t0):
|
||||
with open(f, 'rb') as fh: n += sum(1 for _ in fh)
|
||||
n = sum(LC.count(f) for f in newer(f'{DATA}/shard_*.jsonl', t0) + newer(f'{DATA}/shard_*.jsonl.part', t0))
|
||||
tgt = info.get('target_tasks') or 0
|
||||
return {'frac': min(1, n / tgt) if tgt else None, 'label': f'已生成 {n:,} / {tgt:,} 条任务'}
|
||||
if st == 'prep':
|
||||
@@ -154,15 +185,16 @@ def status():
|
||||
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')}
|
||||
shards = glob.glob(P(DATA, 'shard_*.jsonl'))
|
||||
gen_lines = 0
|
||||
for s in shards:
|
||||
try:
|
||||
with open(s, 'rb') as fh: gen_lines += sum(1 for _ in fh)
|
||||
except Exception: pass
|
||||
pl = pipeline()
|
||||
if pl and pl.get('current') == 'gen': # 只在生成阶段数行(增量);其他阶段用 stats.json 的 tasks
|
||||
gen_lines = sum(LC.count(f) for f in shards + glob.glob(P(DATA, 'shard_*.jsonl.part')))
|
||||
else: gen_lines = (stats or {}).get('tasks') or 0
|
||||
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')}
|
||||
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'),
|
||||
'gen_tasks_written': gen_lines, 'shards': len(shards), 'stats': stats,
|
||||
'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'))}
|
||||
|
||||
|
||||
STAGES = ['setup', 'kin', 'gate1', 'gen', 'prep', 'train', 'm5', 'apg', 'mine', 'export', 'bench', 'accept']
|
||||
ENV_KEYS = {'N_TASKS': r'\d+', 'NPROC': r'\d+', 'TARGET_SAMPLES': r'\d+', 'CFG': r'ml/configs/(base|wta)\.json',
|
||||
'CONSENSUS': r'[01]?', 'ROUNDS': r'\d+', 'APG_STEPS': r'\d+', 'MINE_N': r'\d+'}
|
||||
STAGES = pstate.ORDER
|
||||
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+', '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')
|
||||
|
||||
|
||||
@@ -191,6 +224,56 @@ def find_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:
|
||||
def __init__(self):
|
||||
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
|
||||
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()
|
||||
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):
|
||||
with self.lock:
|
||||
if self.p is not None and self.p.poll() is None: return {'error': '已经在运行'}
|
||||
pj = jload(os.path.join(ROOT, RUNS, 'pipeline.json')) or {}
|
||||
cur = pj.get('current')
|
||||
if cur and time.time() - (pj['stages'].get(cur, {}).get('start') or 0) < 7 * 86400 and not self.p:
|
||||
# 终端里另起的流水线还在跑(或上次被强杀没写结束):看有没有最近更新的日志
|
||||
fresh = [r for r in list_runs() if time.time() - r['mtime'] < 120]
|
||||
if fresh: return {'error': f'检测到终端里正在运行阶段 {cur}(日志 {fresh[0]["name"]} 2 分钟内有更新),请先在终端结束它'}
|
||||
lk = lock_info()
|
||||
if lk and lk['alive']:
|
||||
return {'error': f"已有一条流水线在运行({lk.get('owner')},pid {lk.get('pid')},{lk['age_s']:.0f} s 前心跳);先停止它"}
|
||||
ck = CHECK.get()
|
||||
if ck.get('pending'): return {'error': '环境自检还没完成,稍等几秒再点'}
|
||||
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': '起止阶段无效'}
|
||||
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')
|
||||
if ' ' not in sys.executable: e['PYTHON'] = sys.executable.replace('\\', '/') # 与控制台同一个 Python
|
||||
e = dict(os.environ, FROM=frm, TO=to, MONITOR='0', PYTHONUNBUFFERED='1', PYTHONIOENCODING='utf-8', PIPE_OWNER='网页')
|
||||
py = train_python()
|
||||
if ' ' not in py: e['PYTHON'] = py.replace('\\', '/')
|
||||
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)
|
||||
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') # 追加模式:网页写入的"已停止"提示不会被子进程覆盖
|
||||
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.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}}
|
||||
return {'ok': True, 'pid': self.p.pid}
|
||||
|
||||
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:
|
||||
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':
|
||||
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:
|
||||
try: os.killpg(self.p.pid, signal.SIGTERM)
|
||||
except ProcessLookupError: pass
|
||||
try: self.p.wait(10)
|
||||
except subprocess.TimeoutExpired: os.killpg(self.p.pid, signal.SIGKILL)
|
||||
if ours:
|
||||
kill_session(pid)
|
||||
try: self.p.wait(15)
|
||||
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
|
||||
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)
|
||||
if pj and cur and cur in pj['stages']: # 该阶段记为"已停止"(返回码 -15)
|
||||
pj['stages'][cur].update(end=time.time(), rc=-15); pj['current'] = None
|
||||
with open(PJ, 'w', encoding='utf-8') as f: json.dump(pj, f, ensure_ascii=False, indent=1)
|
||||
time.sleep(0.5)
|
||||
pstate.mark_stopped(-15) # 当前阶段记为"已停止"、释放锁(单一写者)
|
||||
try: os.remove(STOPF)
|
||||
except FileNotFoundError: pass
|
||||
return {'ok': True}
|
||||
|
||||
|
||||
STOPF = os.path.join(ROOT, 'ml', 'runs', 'STOP') # 与 ml/common/ckpt.py 的 STOP 同一文件
|
||||
JOB = Job()
|
||||
CHECK = None # main() 里创建(自检要等 find_bash 定义完)
|
||||
|
||||
|
||||
def console(offset):
|
||||
@@ -287,9 +395,18 @@ class H(BaseHTTPRequestHandler):
|
||||
u = urlparse(self.path)
|
||||
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')
|
||||
# 审查 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:
|
||||
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()
|
||||
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')
|
||||
@@ -303,9 +420,11 @@ def main():
|
||||
ap.add_argument('--open', action='store_true', help='启动后自动打开浏览器')
|
||||
ap.add_argument('--remote-control', action='store_true', help='允许其他机器点开始/停止(默认只能看)')
|
||||
a = ap.parse_args()
|
||||
global CHECK
|
||||
CHECK = SelfCheck()
|
||||
srv = ThreadingHTTPServer((a.host, a.port), H); srv.remote_control = a.remote_control
|
||||
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()
|
||||
try: srv.serve_forever()
|
||||
except KeyboardInterrupt: pass
|
||||
|
||||
+134
-63
@@ -2,81 +2,89 @@
|
||||
# 一条龙:bash ml/pipeline.sh <阶段> ;阶段按顺序:
|
||||
# setup 安装 Python/Node 依赖(torch 需先按 CUDA 版本装好)
|
||||
# kin 导出运动学/碰撞模型 + JS↔PyTorch 对拍(必须 PASS)
|
||||
# r4 浏览器推理延迟摸底(随机初始化模型导出 ONNX;训练前做,不过就改模型规模,不必等训完)
|
||||
# r4 浏览器推理延迟(随机初始化模型导出到 ml/runs/r4/,无头 Edge/Chrome 自动测 → ml/runs/r4.json;不过就停,改模型规模)
|
||||
# gate1 老师一致性门禁(决定生成时是否加 --consensus 4)
|
||||
# gen 生成老师轨迹(NPROC 进程;CONSENSUS=1 时加共识过滤;样本不足 TARGET_SAMPLES 自动补生成)
|
||||
# prep 打包训练样本 + R2 标签一致性检查
|
||||
# train 预训练(先 2000 步摸吞吐,再正式训练);输出目录随头分开:ml/runs/<CFG 名>
|
||||
# m5 头门禁:贴立柱集穿柱率 ≤ 2%(纯策略口径),同时报仿真安全层口径;通过则记为选定模型
|
||||
# m5 头门禁:贴立柱集穿柱率 ≤ 2%(纯策略口径),同时报仿真安全层口径;通过则记为选定模型,不过自动训 WTA 头再判
|
||||
# apg APG 首轮微调(从选定模型开始)
|
||||
# mine 挖错循环(3–6 轮,含门禁回滚)
|
||||
# export 导出 ONNX(fp32)到 webapp/models/
|
||||
# bench 部署形态评测(Node,策略 + 安全层,含 R8 对抗集)
|
||||
# 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)
|
||||
# 可调环境变量:PYTHON(默认 python;Linux 无 python 时设 PYTHON=python3)、NPROC、N_TASKS、TARGET_SAMPLES、
|
||||
# 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 可跑)、
|
||||
# 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")/.."
|
||||
PY=${PYTHON:-python}
|
||||
command -v "$PY" >/dev/null 2>&1 && "$PY" -c "import sys" 2>/dev/null || { echo "找不到可用的 Python($PY),请设 PYTHON=..."; exit 1; }
|
||||
export PYTHON=$PY
|
||||
NPROC=${NPROC:-$(nproc)}
|
||||
N_TASKS=${N_TASKS:-50000}
|
||||
TARGET_SAMPLES=${TARGET_SAMPLES:-1700000}
|
||||
CFG=${CFG:-ml/configs/base.json}
|
||||
export NPROC=${NPROC:-$(nproc)}
|
||||
export N_TASKS=${N_TASKS:-50000}
|
||||
export TARGET_SAMPLES=${TARGET_SAMPLES:-1700000}
|
||||
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)
|
||||
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; }
|
||||
stage=${1:-help}
|
||||
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
|
||||
gen) echo "target_tasks=$(( (N_TASKS + NPROC - 1) / NPROC * NPROC ))" ;;
|
||||
train) echo "tag=$TAG bench_steps=2000" ;;
|
||||
m5) echo "tag=$TAG" ;;
|
||||
mine) echo "rounds=${ROUNDS:-6} apg_steps=${APG_STEPS:-1000}" ;;
|
||||
mine) echo "rounds=$ROUNDS apg_steps=$APG_STEPS" ;;
|
||||
esac; }
|
||||
case " setup kin gate1 gen prep train m5 apg mine export bench accept r4 " in *" $stage "*)
|
||||
$PY -m ml.pstate begin $stage $(stage_info $stage) || true
|
||||
trap 'rc=$?; $PY -m ml.pstate end '"$stage"' $rc >/dev/null 2>&1 || true' EXIT ;;
|
||||
# ---- 互斥锁(审查 N-7):网页、终端、`all` 同一把锁;`all` 内部调用的子阶段继承(PIPE_LOCKED)
|
||||
OWN_LOCK=0; BEAT=; IN_STAGE=0
|
||||
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
|
||||
case $stage in
|
||||
setup) run $PY -m pip install -r ml/requirements.txt; (cd ml && npm install --no-audit --no-fund) ;;
|
||||
kin) run node ml/prep/export_kin.cjs; run node ml/eval/dump_parity.cjs 2000; run $PY -m ml.eval.check_parity ;;
|
||||
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 时按实测"样本/任务"补生成(最多 2 次)
|
||||
s0=$((1 + NPROC))
|
||||
for k in 1 2; 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']/s['tasks'])*1.05))")
|
||||
[ "$need" = "0" ] && break
|
||||
echo "样本不足目标 $TARGET_SAMPLES,补生成 $need 条任务"
|
||||
per2=$(( (need + NPROC - 1) / NPROC ))
|
||||
$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"
|
||||
s0=$((s0 + NPROC))
|
||||
done ;;
|
||||
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
|
||||
# R2 明确不通过(pass=false)就停在这里,不让一键流程白训 3 小时;近邻对不足(pass=null)不拦
|
||||
$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; } ;;
|
||||
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'
|
||||
case " $ALL_STAGES " in *" $stage "*)
|
||||
$PY -m ml.pstate begin $stage $(stage_info $stage); IN_STAGE=1 ;;
|
||||
esac
|
||||
on_exit() {
|
||||
rc=$?
|
||||
[ $IN_STAGE = 1 ] && { $PY -m ml.pstate end $stage $rc >/dev/null 2>&1 || true; }
|
||||
if [ $OWN_LOCK = 1 ]; then [ -n "$BEAT" ] && kill $BEAT 2>/dev/null; $PY -m ml.pstate lock release || true; fi
|
||||
exit $rc
|
||||
}
|
||||
trap on_exit EXIT
|
||||
trap 'exit 143' TERM INT HUP # 被停止 / 超时:先走 on_exit 记返回码、放锁
|
||||
# ---- 训练与门禁的单元(m5 自动分支要对两种头各调一次)
|
||||
train_one() { # $1 = 配置文件
|
||||
local cfg=$1 tag; tag=$(basename "$1" .json)
|
||||
if [ -f ml/runs/bench_$tag/last.pt ]; then echo "吞吐摸底 ml/runs/bench_$tag 已有,跳过"
|
||||
else run $PY -m ml.train --samples ml/data/samples --out ml/runs/bench_$tag --steps 2000 --no-eval --config $cfg; fi
|
||||
local res=""; [ -f ml/runs/$tag/last.pt ] && { res="--resume ml/runs/$tag/last.pt"; echo "发现 ml/runs/$tag/last.pt → 续训"; }
|
||||
run $PY -m ml.train --samples ml/data/samples --out ml/runs/$tag --config $cfg $res
|
||||
}
|
||||
m5_one() { # $1 = 头标签;返回 0 通过 / 3 不通过
|
||||
local tag=$1 ck=${CKPT:-ml/runs/$1/best.pt} o
|
||||
[ -f "$ck" ] || { echo "M5:$ck 不存在(训练发散或没训完)→ 按不通过处理"; return 3; }
|
||||
# 断点续跑:结果文件比检查点新 → 这一半已经评过,跳过
|
||||
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
|
||||
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 - "$tag" "$ck" <<'PY'
|
||||
import json, sys
|
||||
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'))
|
||||
@@ -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)
|
||||
print('[%s] 纯策略穿柱率 %.2f%%、到达 %.2f%%;加仿真安全层 穿柱 %.2f%%、到达 %.2f%%' % (
|
||||
tag, 100 * p['collision_rate'], 100 * p['success'], 100 * s['collision_rate'], 100 * s['success']))
|
||||
if ok:
|
||||
open('ml/runs/selected_ckpt', 'w').write(ck); print('M5 门禁:通过 → 后续阶段使用', ck)
|
||||
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)
|
||||
if ok: open('ml/runs/selected_ckpt', 'w').write(ck); print('M5 门禁:通过 → 后续阶段使用', ck)
|
||||
sys.exit(0 if ok else 3)
|
||||
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 ;;
|
||||
mine) run bash ml/mining/loop.sh ${ROUNDS:-6} ${APG_STEPS:-1000} ${MINE_N:-50000} ;;
|
||||
r4) run $PY -m ml.export_onnx --random $CFG --out webapp/models/traj_policy.onnx
|
||||
echo "下一步:python ml/serve.py → 浏览器打开 http://localhost:8000/?policy=1 → 拖动黄球 1 分钟 → 控制台 __POLICY.latency()" ;;
|
||||
}
|
||||
case $stage in
|
||||
setup) run $PY -m pip install -r ml/requirements.txt; (cd ml && npm install --no-audit --no-fund) ;;
|
||||
kin) run node ml/prep/export_kin.cjs; run node ml/eval/dump_parity.cjs 2000; run $PY -m ml.eval.check_parity ;;
|
||||
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:-} ;;
|
||||
export) ck=ml/runs/current.pt; [ -f $ck ] || ck=$(ckpt)
|
||||
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 ;;
|
||||
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 ;;
|
||||
all) # FROM / TO 指定起止阶段(默认 kin → accept),网页控制台的「开始训练」也走这里
|
||||
all) # 默认 kin → accept;已完成(rc=0)的阶段自动跳过,从第一个没完成的继续。FRESH=1 从头来;FROM/TO 可只跑一段
|
||||
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
|
||||
done
|
||||
PLAN=${PLAN# }; [ -n "$PLAN" ] || { echo "FROM=$FROM / TO=$TO 不是有效阶段"; exit 1; }
|
||||
echo "计划:$PLAN"
|
||||
$PY -m ml.pstate reset ${PLAN// /,}
|
||||
# 启动前自检(审查 N-10:不能等到 train 才发现这个 Python 没有 torch)
|
||||
$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 关闭);端口已被占用说明已经开着,不重复启动
|
||||
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 &
|
||||
echo "训练监控已在后台启动:http://localhost:8100/ (全部跑完后仍可查看,关闭终端即结束)"
|
||||
echo "训练监控已在后台启动:http://localhost:8100/"
|
||||
fi
|
||||
for s in $PLAN; do bash ml/pipeline.sh $s; done ;;
|
||||
*) sed -n '2,22p' "$0" ;;
|
||||
for s in $TODO; do
|
||||
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
|
||||
|
||||
@@ -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 + '.SG.npy'), cat(SG, 13), [n, 13]);
|
||||
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 }));
|
||||
|
||||
+17
-3
@@ -1,8 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
# 并行打包样本:bash ml/prep/run_prep.sh [进程数=CPU 核数]
|
||||
# 2026-10-18 断点续跑:samples/<shard>.tasks.jsonl 存在且比 shard 新 → 该 shard 已打包完,跳过;
|
||||
# shard 已不存在的旧打包结果删掉(不让上一批数据混进来)。中断后重跑只补没打完的 shard。
|
||||
set -e
|
||||
cd "$(dirname "$0")/../.."
|
||||
P=${1:-$(nproc)}
|
||||
mkdir -p ml/data/samples
|
||||
ls ml/data/shard_*.jsonl | xargs -P "$P" -I{} node ml/prep/build_dataset.cjs {} ml/data/samples
|
||||
${PYTHON:-python} -m ml.data --index ml/data/samples
|
||||
D=ml/data/samples
|
||||
mkdir -p $D
|
||||
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
@@ -1,25 +1,77 @@
|
||||
"""流水线状态文件 ml/runs/pipeline.json(供训练监控网页显示"现在在哪一步、每步用了多久")。
|
||||
pipeline.sh 每个阶段开始/结束时自动调用,不需要手动运行:
|
||||
python -m ml.pstate reset <阶段1,阶段2,...> # `all` 开始时:清空并写入计划
|
||||
python -m ml.pstate begin <阶段> [键=值 ...] # 阶段开始(值可为数字),如 target_tasks=50000
|
||||
python -m ml.pstate set <阶段> 键=值 ... # 阶段中途更新(如补量后的目标条数)
|
||||
"""流水线状态与互斥锁(单一写者:所有对 ml/runs/pipeline.json / pipeline.lock 的写都经过本模块,原子替换)。
|
||||
pipeline.sh 自动调用,一般不需要手动运行:
|
||||
python -m ml.pstate reset <阶段1,...> [--fresh] # `all` 开始:保留 rc=0 的已完成阶段(续跑);参数变了只作废受影响的阶段;--fresh 全部作废
|
||||
python -m ml.pstate todo # 打印计划中尚未完成的阶段(空格分隔)
|
||||
python -m ml.pstate begin <阶段> [键=值 ...] # 阶段开始
|
||||
python -m ml.pstate set <阶段> 键=值 ... # 阶段中途更新
|
||||
python -m ml.pstate get <阶段> <键> # 读值;不存在则非零退出(不静默回空)
|
||||
python -m ml.pstate end <阶段> <返回码>
|
||||
2026-10-18 新增(用户要求:从训练开始到结束都能在监控面板看到进度)。"""
|
||||
import json, os, sys, time
|
||||
python -m ml.pstate timeout <阶段> # 该阶段超时秒数(估时 ×3,TIMEOUT_SCALE 可再乘)
|
||||
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():
|
||||
try:
|
||||
with open(P, encoding='utf-8') as f: return json.load(f)
|
||||
except Exception:
|
||||
return {'started': time.time(), 'plan': None, 'stages': {}, 'current': None}
|
||||
return {'started': time.time(), 'plan': None, 'stages': {}, 'current': None, 'params': {}}
|
||||
|
||||
|
||||
def save(d):
|
||||
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)
|
||||
os.replace(tmp, P)
|
||||
|
||||
@@ -34,16 +86,116 @@ def kv(args):
|
||||
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():
|
||||
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()
|
||||
if cmd == 'reset':
|
||||
plan = rest[0].split(',') if rest else []
|
||||
# 从中间某步续跑时保留计划之外(之前已跑完)各步的记录,计划内的清空重来
|
||||
d = {'started': now, 'plan': plan, 'stages': {k: v for k, v in d.get('stages', {}).items() if k not in plan}, 'current': None}
|
||||
plan = rest[0].split(',') if rest and rest[0] else []
|
||||
pn = params_now(); prev = d.get('params') or {}
|
||||
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':
|
||||
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
|
||||
elif cmd == 'set':
|
||||
s = d['stages'].setdefault(rest[0], {'start': now, 'end': None, 'rc': None, 'info': {}})
|
||||
|
||||
@@ -13,6 +13,8 @@ from ml.common.features import features, STEP, H, GAP_CAP, D2R, wrap180
|
||||
DT = 0.02
|
||||
|
||||
|
||||
FIRST_DEG = 5.0 # R7 首次方向判定阈值(度):小于它的抖动不算选边
|
||||
|
||||
def tasks_to_tensors(tasks, 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)
|
||||
@@ -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
|
||||
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_first = torch.zeros(B, device=device) # 2026-10-18 第五轮(审查 N-3):J1 累计偏离首次超过 FIRST_DEG 时的方向(真正的"首次方向")
|
||||
cold = True
|
||||
for it in range(nmax):
|
||||
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)
|
||||
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_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 return_traj: traj.append(q.clone())
|
||||
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(),
|
||||
'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 左右比例门禁
|
||||
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(),
|
||||
'side': side.cpu(), 'start_fail': start_fail.cpu(), 'brakes': brakes.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)
|
||||
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)
|
||||
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__':
|
||||
|
||||
+18
-7
@@ -3,11 +3,12 @@
|
||||
吞吐摸底: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 为剩余小时数。
|
||||
每 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
|
||||
|
||||
from ml.common.kin_torch import Kin
|
||||
from ml.common.features import STEP, D2R
|
||||
from ml.common.ckpt import Saver, atomic_save
|
||||
from ml.data import Samples, load_tasks
|
||||
from ml.model.policy import build, wta_loss
|
||||
from ml.model.meanflow import meanflow_loss, one_step
|
||||
@@ -45,11 +46,12 @@ def main():
|
||||
model = build(cfg).to(dev)
|
||||
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'])
|
||||
step0, best = 0, -1.0
|
||||
step0, best, bad_evals = 0, -1.0, 0
|
||||
if a.resume:
|
||||
ck = torch.load(a.resume, map_location=dev); model.load_state_dict(ck['model'])
|
||||
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']
|
||||
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'
|
||||
@@ -58,11 +60,14 @@ def main():
|
||||
log = open(os.path.join(a.out, 'log.jsonl'), 'a' if a.resume else 'w') # 新训练覆盖旧日志,续训才追加
|
||||
if not val_tasks and not a.no_eval:
|
||||
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):
|
||||
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)
|
||||
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 gpar in opt.param_groups: gpar['lr'] = lr_at(step)
|
||||
X, Y, V, SG = data.batch(tc['batch'])
|
||||
@@ -93,8 +98,12 @@ def main():
|
||||
loss.backward()
|
||||
gn = torch.nn.utils.clip_grad_norm_(model.parameters(), tc['grad_clip'])
|
||||
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]
|
||||
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:
|
||||
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(),
|
||||
@@ -112,6 +121,8 @@ def main():
|
||||
else:
|
||||
bad_evals += 1
|
||||
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)
|
||||
if a.no_eval: save('best.pt', step + 1)
|
||||
print(f'done, best success {best:.4f}, {(time.time() - t0) / 3600:.2f} h', flush=True)
|
||||
|
||||
@@ -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'));
|
||||
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.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 ncCases(N, seed) {
|
||||
@@ -76,7 +77,7 @@ const pct = (a, p) => { if (!a.length) return null; const s = a.slice().sort((x,
|
||||
|
||||
(async () => {
|
||||
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 out = { model: MODEL, meta: { fp: meta.fp, eval: meta.eval } };
|
||||
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('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');
|
||||
const s = JSON.stringify(out, null, 1);
|
||||
console.log(s);
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
* ?policy=1 用 models/traj_policy.onnx
|
||||
* ?policy=models/xxx.onnx 指定模型
|
||||
* &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 与后台节流影响),
|
||||
* 每 3 步推理一次,安全层认证后才执行;ik-glue 的逐帧碰撞守卫不参与(策略执行不走 animTo),由 Runner 安全层保证。
|
||||
* 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.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);
|
||||
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');
|
||||
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 sess = await ort.InferenceSession.create(MODEL, { executionProviders: ep, graphOptimizationLevel: 'all' });
|
||||
runner = new ArmPolicy.Runner(S, ArmPolicy.ortInfer(ort, sess), {});
|
||||
let sess = null, proxy = ep[0] === 'wasm' && qs.get('proxy') !== '0'; // proxy 只支持 wasm EP
|
||||
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' }));
|
||||
worker = new Worker(tick);
|
||||
worker.onmessage = onTick;
|
||||
@@ -50,10 +65,12 @@ async function boot() {
|
||||
},
|
||||
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; };
|
||||
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;
|
||||
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); }
|
||||
}
|
||||
function start() { if (running) return; running = true; worker.postMessage(ArmPolicy.DT * 1000); }
|
||||
|
||||
@@ -336,9 +336,13 @@ class Runner {
|
||||
const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
||||
|
||||
/* ---------------- 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) => {
|
||||
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 };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user