diff --git a/docs/ML运行手册审查.md b/docs/ML运行手册审查.md
index 00974aa..d156736 100644
--- a/docs/ML运行手册审查.md
+++ b/docs/ML运行手册审查.md
@@ -219,3 +219,116 @@
1. **结掉 R4**:用一个随机初始化的 4.92 M 模型导出 fp32 ONNX,走 `ml/serve.py` + `?policy=1`,在目标浏览器实测 `__POLICY.latency()` 的 batch=8 P99 与安全层 P99(方案要求 <15 ms)。不过就要改 `d`/`commit`,此时改配置的成本远低于训完 3 小时再改。
2. **把 margin 决定单独拍板**:在"过滤老师数据保 5 mm"与"放宽护栏到 3 mm"之间选一个,再把 `w_col` 与 §8 估时按选择重算。这两条是本轮唯一拦住往下跑的理由。
+
+---
+---
+
+# 第三轮:对第二轮 B-1~B-8 的处置 + 新增训练控制台
+
+修订方:沙箱(Hark)。基于 `655e1fb`。沙箱只做了 CPU 小数据冒烟(见第四节),所有耗时、keepRate、gapDrop 比例仍以你机器上的实测为准。
+本轮改动以补丁形式交付(`git apply review2_and_monitor.patch`),确认后再推送。
+
+## 一、B-1~B-8 处置
+
+| 条目 | 处置 | 改动位置 |
+|---|---|---|
+| B-1 3 mm 拿护栏迁就数据 | **采纳你的方向**:运行时投影护栏恢复 **5 mm**;改为在数据侧剔除认证最小间隙 `cert.minGap < 3 mm` 的老师轨迹;APG 碰撞项余量 3 mm 与过滤线一致(碰撞项不与 BC 标签对抗)。护栏比数据更保守,二者不再绑定 | `webapp/js/ik-policy.js:174` margin 3→5;`ml/gen/stats.cjs` 新增 `gapDrop`、`minGapFilterMm`,样本数与补量只按过滤后计;`ml/prep/build_dataset.cjs` 同一过滤(`MIN_GAP_MM=3`,可用环境变量改),打包输出也报 `gapDrop` |
+| B-2 端点+中点 hinge ×2、w_col 未重标 | 两处 hinge 由求和改为**取均值**,与只查端点同量纲,`w_col=2.0` 不用动。展开 60–150 次的单步代价不再拍估时:手册 §8/§9 改为"未实测,以监控页前 50 步换算的 ETA 为准" | `ml/apg/apg_finetune.py:104`;README 阶段表 |
+| B-3 check_r2 空匹配崩、显存峰值 | 近邻对 < 20 时输出 `pass: null` 不报错;默认 5000 查询 × 50000 池、分块 256、`masked_fill_` 原地,GPU 峰值 < 0.5 GB。`pass: false` 时 prep 以返回码 4 结束、一键流程停下;`null` 不拦 | `ml/eval/check_r2.py`;`ml/pipeline.sh` prep |
+| B-4 R7 以到达为条件会漏报 | 左右比例改为按**全部** B/C 题的首次 J1 方向统计,不筛到达 | `ml/mining/gate.py:47` |
+| B-5 r8 起点没贴柱 | 起点也要求 `nearColumn`,两侧都要绕远 | `tests/policy_bench.cjs` r8Cases |
+| B-6 估时口径 | 对拍改为"约 1 分钟(估算:300 条实测 7.6 s,流程跑 2000 条)";M5、部署评测标"估算";APG、挖错标"未实测" | README 阶段表 |
+| B-7 R4 排在最后 | **提到训练前**:新增 `bash ml/pipeline.sh r4`,用随机初始化模型导出 fp32 ONNX(延迟只与结构有关),`python ml/serve.py` + `?policy=1` 测 `__POLICY.latency()`;不过就把 `model.d` 192→128、`ff` 768→512 后重测 | `ml/export_onnx.py` 新增 `--random <配置>`;`pipeline.sh` r4;README §2;方案 R4 行"M1 前" |
+| B-8 杂项 | 噪声增广接受地板 1 mm → 3 mm(与过滤线一致);验收起步失败 `= 0` → `≤ 0.2%`;`column_gap` 分块 256(沙箱实测约 20 MB/位形,整批 2048 会爆);m5 返回码 3 中止 `all` 写进常见问题;`set -o pipefail` + 门禁结果先写 `.tmp` 再改名(中断不留残缺 `consistency.json`) | `build_dataset.cjs`、`acceptance.py`、`tasks.py`、`pipeline.sh`、README 常见问题 |
+| `onnxconverter-common` | 保留(`--fp16` 仍可用于 WebGPU 对比) | — |
+
+方案文档同步:决策表"训练余量"改为"数据过滤与 APG 碰撞项 3 mm、运行时护栏 5 mm";APG 碰撞项写明端点与中点取均值;R4 检测时点改为训练前。HTML 已重新生成。
+
+**需要你确认的一处**:3 mm 过滤会剔除多少老师轨迹没实测过。沙箱 30 条小样本里剔除了 9/29(p10 = 1 mm),量太小不具代表性;你那边第一轮实测 p50 4.4 mm。生成完看 `stats.json` 的 `gapDrop`:> 25% 说明老师整体太贴柱,把 `stats.json` 发我再定。自动补量会按过滤后的样本数补,所以样本总量不受影响,只是生成时间相应变长。
+
+## 二、新增:训练控制台(一键运行 + 全程可视化)
+
+### 怎么用
+
+1. Windows 双击 `ml/console.bat`(Linux/WSL:`bash ml/console.sh`),浏览器自动打开 `http://localhost:8100/`。
+2. 点右上角「▶ 开始训练」,在对话框里选起始阶段、结束阶段和参数,点「开始」。
+3. 之后整个流程(对拍 → 老师门禁 → 生成 → 打包 → 预训练 → M5 → APG → 挖错 → 导出 → 部署评测 → 验收)都在这一页看;「■ 停止」随时停,再开始默认从停下的那一步继续。
+
+网页可以关,训练不受影响;关闭控制台窗口会结束训练。R2、M5 不通过时流程会停在那一步。
+
+### 组成与接口
+
+| 文件 | 作用 |
+|---|---|
+| `ml/console.bat` / `ml/console.sh` | 一键启动:`python -m ml.monitor --open` |
+| `ml/monitor.py` | Python 标准库 HTTP 服务,默认只监听 127.0.0.1:8100 |
+| `ml/monitor.html` | 网页,零依赖(原生 canvas 画图),不连外网;`?demo=1` 或直接双击打开时用内置示例数据 |
+| `ml/pstate.py` | 写 `ml/runs/pipeline.json`:`pipeline.sh` 每个阶段开始/结束时自动调用(终端里跑也会写) |
+
+| 接口 | 方法 | 说明 |
+|---|---|---|
+| `/` | GET | 网页 |
+| `/api/runs` | GET | `ml/runs/*/log.jsonl` 列表(名称、类型 train/apg/bench、更新时间、是否发散) |
+| `/api/log?run=&offset=` | GET | 增量读日志:只返回 offset 之后的完整行,日志被新训练覆盖时自动从头读 |
+| `/api/status` | GET | GPU、`pipeline.json` + 当前阶段进度、数据统计、R2、M5、挖错各轮、部署评测、验收 |
+| `/api/job` | GET | 控制台启动的流水线进程:是否在跑、PID、起止阶段、返回码、是否手动停止 |
+| `/api/console?offset=` | GET | `ml/runs/console.log` 增量(首次只给最后 64 KB) |
+| `/api/start` | POST | `{"from":"kin","to":"accept","env":{...}}` → 后台 `bash ml/pipeline.sh all`;env 只接受白名单:`N_TASKS NPROC TARGET_SAMPLES CFG CONSENSUS ROUNDS APG_STEPS MINE_N`,逐个正则校验 |
+| `/api/stop` | POST | 结束整个进程树(Windows `taskkill /T /F`,其他系统进程组 SIGTERM,10 秒后 SIGKILL),当前阶段记为返回码 -15 |
+
+安全:开始/停止只接受本机请求(`--host 0.0.0.0` 开放给局域网时其他机器只能看,`--remote-control` 才放开);已经有流水线在跑(控制台自己起的,或终端里起的、日志 2 分钟内有更新)时拒绝再开始。
+Windows 找 bash 的顺序:环境变量 `BASH` → `C:\Program Files\Git\bin\bash.exe` → `C:\Program Files (x86)\Git\...` → `%LOCALAPPDATA%\Programs\Git\...` → `git` 所在目录推断;不用 `System32\bash.exe`(那是 WSL)。控制台把自己的 `sys.executable` 作为 `PYTHON` 传给流水线(路径含空格时不传,回落到 `python`)。
+
+### 流水线脚本配套改动
+
+- `bash ml/pipeline.sh all` 支持 `FROM` / `TO` 只跑一段(如 `FROM=gen TO=train`);在终端里用 `all` 时也会在后台自动起控制台(`MONITOR=0` 关闭)。
+- 每个阶段开始写 `begin`(生成阶段带 `target_tasks`,补量时更新;预训练带 `tag`;挖错带 `rounds`),退出时由 `trap EXIT` 写返回码。
+- `pipeline.json` 在从中间续跑时保留计划之外各步的记录,所以流水线卡片能显示"之前已完成"。
+- 训练与 APG 日志每条记录新增 `total`(总步数)与 `t`(时间戳),评估记录也带 `t`,用于进度条、已用时间与 ETA。
+
+### 网页内容
+
+流水线阶段条 → 全流程卡(总百分比按各步粗估耗时加权、当前步骤及其进度、总用时、本步用时、剩余粗估、开始/停止)+ GPU 仪表 → 当前运行(自动跟随正在跑的训练,可手动切换)→ 指标卡(带趋势小图)→ 4 张曲线(预训练:总损失含原始值淡线、各分量、闭环评估带"最好"标记、吞吐带 R10 5000 线;APG:到达误差、损失分量、闭环评估、展开长度)→ 数据 / M5 / 挖错 / 部署评测 / 验收面板 → 终端输出(报错标红)。顶栏有平滑系数、对数纵轴、自动刷新开关。
+
+当前步骤进度的口径:生成 = 已写任务条数 / 目标条数;打包 = 已打包分片 / 分片数;预训练 = (测速 2000 步 + 正式步数) / (2000 + 总步数);APG = 步数;挖错 = 已完成轮数 + 本轮 APG 步数;M5 = 两遍评估;对拍、老师门禁、导出、部署评测、验收无法计量,显示用时与粗估。
+
+## 三、本轮改动文件清单
+
+```
+新增 ml/monitor.py ml/monitor.html ml/pstate.py ml/console.bat ml/console.sh
+修改 ml/pipeline.sh r4 / monitor 阶段、all 支持 FROM/TO 与自动起控制台、阶段状态记录、pipefail、R2 不通过停下、补量更新目标
+ ml/train.py 日志加 total、t
+ ml/apg/apg_finetune.py 日志加 total、t;端点+中点 hinge 取均值;docstring 余量说明
+ ml/gen/stats.cjs gapDrop / minGapFilterMm,样本数按过滤后计
+ ml/prep/build_dataset.cjs 剔除 minGap < 3 mm 的轨迹;增广地板 3 mm;输出 gapDrop
+ ml/eval/check_r2.py 空匹配保护、默认规模、分块 256
+ ml/eval/acceptance.py 起步失败 ≤ 0.2%
+ ml/mining/gate.py R7 左右比例不以到达为条件
+ ml/mining/tasks.py column_gap 分块
+ ml/export_onnx.py --random
+ tests/policy_bench.cjs r8 起点贴柱
+ webapp/js/ik-policy.js 投影护栏 5 mm
+ ml/README.md 一键运行、R4、训练控制台、过滤口径、估时标注、回传文件、常见问题、文件地图
+ docs/Transformer轨迹拟合训练方案.md / .html 余量决策、APG 碰撞项、R4 时点
+```
+
+## 四、沙箱验证(CPU,2 核,小数据)
+
+| 项 | 结果 |
+|---|---|
+| `stats.cjs` 过滤 | 30 条任务:keep 20、gapDrop 9;`MIN_GAP_MM=4.4` 时 keep 16、gapDrop 13,口径一致 |
+| `build_dataset.cjs` | 20 条任务 761 样本,gapDrop 9,增广拒绝 5/226 |
+| `check_r2.py` | 小样本可跑,不再报错 |
+| APG 2 步 + 门禁(含新 R7) | 通过 |
+| `column_gap` 分块 | 300 位形 / 块 100 正常;块 512 在 7 GB 内存下被杀 → 默认 256 |
+| `export_onnx --random` | 走到导出一步,沙箱缺 `onnx` 包未完成,需在你机器上跑 `r4` |
+| 控制台接口 | 网页开始 `gen→prep`(6 条任务):进度 2/6 → 4/6 → 打包 0/2 → 完成,返回码 0;停止 `gate1`:进程树结束、该步记为已停止、不留残缺 `consistency.json`、"已停止"提示不被覆盖 |
+| 网页 | jsdom 跑 4 个示例场景 + 开始/停止流程,无脚本错误;沙箱没有浏览器,没有截图 |
+| 未验证 | Windows 下 `console.bat` 与 Git Bash 查找、`taskkill` 停止;`policy_bench.cjs` 完整运行(沙箱无 `onnxruntime-node`) |
+
+## 五、你机器上的建议顺序
+
+1. `git apply review2_and_monitor.patch`(或等我推送后 `git pull`)。
+2. `bash ml/pipeline.sh setup`(如依赖已装可跳过)→ `bash ml/pipeline.sh r4` + 浏览器测延迟,把 `__POLICY.latency()` 发我。
+3. 双击 `ml/console.bat` → 「开始训练」,起始「对拍」、结束「打包样本」,跑完看 `gapDrop` 与 R2。
+4. 没问题再「开始训练」从「预训练」跑到「验收汇总」。
diff --git a/docs/Transformer轨迹拟合训练方案.html b/docs/Transformer轨迹拟合训练方案.html
index c0a7a0a..8188245 100644
--- a/docs/Transformer轨迹拟合训练方案.html
+++ b/docs/Transformer轨迹拟合训练方案.html
@@ -615,7 +615,9 @@ K=8,二者共用骨干,切换约 1 天
越大越不易被认证拒绝 |
越大行程越长、贴柱越难;高于老师轨迹自身间隙(实测最小间隙 p50 4.4 /
p90 5.9 mm)会与 BC 标签对抗 |
-3 mm(≈ 老师 p10),APG 碰撞项与运行时投影共用 |
+数据过滤与 APG 碰撞项 3 mm(认证最小间隙 < 3 mm
+的老师轨迹不进训练集);运行时投影护栏独立保持 5
+mm,护栏不迁就数据分布 |
| 关节噪声增广 σ |
@@ -1240,8 +1242,8 @@ s),统计到达率、用时 ≤ 老师 × 1.3
反传,梯度裁剪 ≤ 1.0,lr 1e-4
- 碰撞项 L_col = Σ ReLU(margin − 胶囊距离),margin = 训练余量 3
-mm(间隙已扣除
margin_mm);每步端点 +
-中点;含臂×立柱、臂×台架、臂×臂(非相邻段)。
+mm(间隙已扣除
+margin_mm);每步端点与中点,两处取均值(与只查端点同量纲);含臂×立柱、臂×台架、臂×臂(非相邻段)。
- 到达项:每个截断段末的位置误差² + λ_rot·‖R −
R*‖_F²;不做到达即停,段末项在整条展开上持续给出接近梯度。
- 平滑项:速度² + 加速度²。
@@ -1513,9 +1515,10 @@ held-out 闭环仿真(开仿真安全层)测量,由
| R4 |
浏览器推理延迟 |
-M4 后立即导出 ONNX,wasm-SIMD 多线程测 batch=8 P99 < 15 ms |
+训练前用随机初始化模型导出 ONNX(延迟与权重无关),wasm-SIMD
+多线程测 batch=8 P99 < 15 ms |
减小模型(d=128,约 1.8M 参数);同时测 WebGPU,更快则换 |
-M4(最大不确定项) |
+M1 前(最大不确定项,训练前结掉) |
| R5 |
diff --git a/docs/Transformer轨迹拟合训练方案.md b/docs/Transformer轨迹拟合训练方案.md
index 85bbe7f..8c91cb9 100644
--- a/docs/Transformer轨迹拟合训练方案.md
+++ b/docs/Transformer轨迹拟合训练方案.md
@@ -55,7 +55,7 @@
| 挖错循环中 E2 错题请老师补路径 | 解决可微代价绕不出的局部极小 | 量小、离线 | **上** |
| 老师:PRM 路网 A* vs `solveDyn` | A*:确定性,同类任务左右选择一致,且是路网上的最短;`solveDyn` 覆盖更全 | A*:受路网分辨率限制,个别任务失败 | **A* 为主老师**,失败的任务用 `solveDyn`(固定种子)补 |
| 推理后端:WebGPU vs wasm | WebGPU 大模型快 | 本模型约 4M 参数,WebGPU 每次调度 33–67 μs × 150–300 次,调度开销反而大 | **wasm-SIMD 多线程**;M4 实测若 WebGPU P99 更低再换 |
-| 训练余量 | 越大越不易被认证拒绝 | 越大行程越长、贴柱越难;高于老师轨迹自身间隙(实测最小间隙 p50 4.4 / p90 5.9 mm)会与 BC 标签对抗 | **3 mm**(≈ 老师 p10),APG 碰撞项与运行时投影共用 |
+| 训练余量 | 越大越不易被认证拒绝 | 越大行程越长、贴柱越难;高于老师轨迹自身间隙(实测最小间隙 p50 4.4 / p90 5.9 mm)会与 BC 标签对抗 | 数据过滤与 APG 碰撞项 **3 mm**(认证最小间隙 < 3 mm 的老师轨迹不进训练集);运行时投影护栏独立保持 **5 mm**,护栏不迁就数据分布 |
| 关节噪声增广 σ | 覆盖偏离状态 | 过大产生无意义状态 | **1°** |
| 局部牛顿精修 | 末端精度到 < 1 mm | 无 | **上**,同样过安全层 |
@@ -320,7 +320,7 @@ for 每个 batch(随机采样 q0 和目标,不需要老师):
反传,梯度裁剪 ≤ 1.0,lr 1e-4
```
-- 碰撞项 L_col = Σ ReLU(margin − 胶囊距离),margin = 训练余量 3 mm(间隙已扣除 `margin_mm`);每步端点 + 中点;含臂×立柱、臂×台架、臂×臂(非相邻段)。
+- 碰撞项 L_col = Σ ReLU(margin − 胶囊距离),margin = 训练余量 3 mm(间隙已扣除 `margin_mm`);每步端点与中点,两处取均值(与只查端点同量纲);含臂×立柱、臂×台架、臂×臂(非相邻段)。
- 到达项:每个截断段末的位置误差² + λ_rot·‖R − R*‖_F²;不做到达即停,段末项在整条展开上持续给出接近梯度。
- 平滑项:速度² + 加速度²。
- BC 锚定项:保留预训练损失,防止多模态被压缩;每轮监控左右比例。
@@ -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 | 浏览器推理延迟 | M4 后立即导出 ONNX,wasm-SIMD 多线程测 batch=8 P99 < 15 ms | 减小模型(d=128,约 1.8M 参数);同时测 WebGPU,更快则换 | **M4(最大不确定项)** |
+| R4 | 浏览器推理延迟 | 训练前用随机初始化模型导出 ONNX(延迟与权重无关),wasm-SIMD 多线程测 batch=8 P99 < 15 ms | 减小模型(d=128,约 1.8M 参数);同时测 WebGPU,更快则换 | **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 |
diff --git a/ml/README.md b/ml/README.md
index 3f18ad1..bb5c7f3 100644
--- a/ml/README.md
+++ b/ml/README.md
@@ -3,25 +3,28 @@
目标:在你的机器上从零跑出 Transformer 避障策略,并在查看器里直接用上。
方案依据见 `docs/Transformer轨迹拟合训练方案.md`。所有命令在**仓库根目录**执行。
-按阶段执行 `bash ml/pipeline.sh <阶段>`,每一阶段完成后看本手册对应小节的「检查」再进下一步。
+**一键运行**: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 步)不通过时一键流程会停下,按对应小节处理。
| 阶段 | 命令 | 预计耗时(28 核 CPU + RTX 3090) | 产出 |
|---|---|---|---|
| 1 环境 | `bash ml/pipeline.sh setup` | 10 分钟 | 依赖 |
-| 2 对拍 | `bash ml/pipeline.sh kin` | 1 分钟(实测 7.6 s 对拍) | `ml/assets/kin.json`、对拍 PASS |
+| 2 对拍 | `bash ml/pipeline.sh kin` | 约 1 分钟(估算:300 条对拍实测 7.6 s,流程跑 2000 条) | `ml/assets/kin.json`、对拍 PASS |
+| 2′ R4 延迟 | `bash ml/pipeline.sh r4` → 浏览器测 | 10 分钟 | 浏览器 `__POLICY.latency()` 输出 |
| 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` |
| 6 预训练 | `bash ml/pipeline.sh train` | 测速 2000 步约 6 分钟,正式约 3 小时 | `ml/runs/base/best.pt` |
-| 7 M5 门禁 | `bash ml/pipeline.sh m5` | 10–20 分钟 | `ml/runs/m5_gate_base.json` |
-| 8 APG 微调 | `bash ml/pipeline.sh apg` | 以前 100 步速度为准(预计 2–4 小时) | `ml/runs/apg/best.pt` |
-| 9 挖错循环 | `bash ml/pipeline.sh mine` | 每轮约 1–2 小时,3–6 轮 | `ml/runs/current.pt` |
+| 7 M5 门禁 | `bash ml/pipeline.sh m5` | 估算 10–20 分钟 | `ml/runs/m5_gate_base.json` |
+| 8 APG 微调 | `bash ml/pipeline.sh apg` | 未实测:展开长度 60–150 次推理随批变化,以监控页前 50 步换算的 ETA 为准 | `ml/runs/apg/best.pt` |
+| 9 挖错循环 | `bash ml/pipeline.sh mine` | 未实测:每轮 = 5 万题作答 + APG,以第 1 轮实际耗时 × 轮数为准 | `ml/runs/current.pt` |
| 10 导出 | `bash ml/pipeline.sh export` | 1 分钟 | `webapp/models/traj_policy.onnx`(fp32) |
-| 11 部署评测 | `bash ml/pipeline.sh bench` | 约 1 小时(Node CPU,1120 个任务) | `ml/runs/policy_bench.json` |
+| 11 部署评测 | `bash ml/pipeline.sh bench` | 估算约 1 小时(Node CPU,1120 个任务;R8 出题约 2 秒/题) | `ml/runs/policy_bench.json` |
| 12 验收汇总 | `bash ml/pipeline.sh accept` | 10–20 分钟 | `ml/runs/acceptance.json` |
| 13 使用 | `python ml/serve.py` → 浏览器 `?policy=1` | — | — |
-第 2/4/5/6 步耗时为 RTX 3090 + 28 核实测换算,其余为估算,以每步首次输出的速度为准。
+第 4/5/6 步耗时为 RTX 3090 + 28 核实测换算;标「估算」「未实测」的不要当预算,以每步首次输出的速度为准。
+第 1 步(安装依赖)和第 2′ 步(R4,需要在浏览器里看延迟)建议先手动做;一键流程默认从第 2 步开始。
---
@@ -46,6 +49,16 @@ bash ml/pipeline.sh kin
检查:最后打印的 JSON 里 `"PASS": true`(fk、姿态、组对间隙、83 维特征、间隙梯度与 JS 一致)。
`docs/collide.json` 以后只要重新拟合或重生成 `pair2d`/`roadmap`,这一步、数据、模型都要重来(指纹不符时各脚本会拒绝运行)。
+**R4 延迟摸底(训练前做)**:推理延迟只取决于模型结构,与权重无关,所以用随机初始化模型先测,不过就在训练前改结构,不必等训完。
+
+```bash
+bash ml/pipeline.sh r4 # 随机初始化 4.92 M 模型导出 fp32 ONNX 到 webapp/models/
+python ml/serve.py # 浏览器打开 http://localhost:8000/?policy=1 ,来回拖动黄球约 1 分钟
+```
+
+控制台运行 `__POLICY.latency()`:推理 batch=8 P99 < 15 ms、安全层 P99 < 15 ms 为通过(随机权重的动作是乱的,手臂会乱动或频繁刹停,这一步只看延迟)。
+不通过:把 `ml/configs/base.json` 的 `model.d` 从 192 改为 128(`ff` 改 512)后重跑 `r4`;仍不通过再加 `&ep=webgpu` 对比。把输出发我。
+
## 3. 老师一致性门禁
```bash
@@ -71,7 +84,8 @@ bash ml/pipeline.sh gen # 默认 5 万条、进程数 = CPU
| `samplesPerTask` | ≈ 36.5 | ≈ 36.5 | — |
| `samplesEvery3` | ≥ 170 万(自动补量后) | ≥ 170 万 | 补量后仍不足:发我 |
| `consensusDrop` | 0 | 约任务数的 10–20% | — |
-| `vertexMinGapMm` | p50 ≈ 4.4、p90 ≈ 5.9 mm(p10 预计约 3) | 同 | p10 < 2.5 mm:发我(APG 与投影的 3 mm 余量要随之下调) |
+| `vertexMinGapMm` | p50 ≈ 4.4、p90 ≈ 5.9 mm | 同 | — |
+| `gapDrop` | 认证最小间隙 < 3 mm 被剔除的轨迹数(未实测,预计约 10%) | 同 | > 25%:发我(自动补量会按剔除后的样本数补,但老师整体太贴柱) |
| `byCatMode` | C 类(贴立柱)各模式都有数千条 | 同 | — |
## 5. 打包训练样本 + R2 检查
@@ -80,9 +94,10 @@ bash ml/pipeline.sh gen # 默认 5 万条、进程数 = CPU
bash ml/pipeline.sh prep
```
-每条轨迹按 50 Hz 时间律重放,每 3 步取一个样本(特征与部署同一份 JS 代码计算),30% 样本加 σ = 1° 噪声增广。
+每条轨迹按 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% 步长)为通过;不通过先发我,不要直接训练。
+随后自动跑 R2 检查(`ml/data/r2.json`):近邻样本前 3 步标签差 `label_std_mean < 0.10`(10% 步长)为通过;不通过时这一步以返回码 4 结束、一键流程停下,先把 `r2.json` 发我。小样本时近邻对不足,`pass` 为 null,不拦。
## 6. 预训练
@@ -132,7 +147,7 @@ bash ml/pipeline.sh apg
- **展开长度**:每批覆盖该批老师时长 p90 × 1.2(无老师题按 6 s),在 60–150 次推理(3.6–9 s)之间;日志 `plans` 字段是本批实际次数
- **到达项**:每 10 次推理段末的 TCP 距离与姿态误差,没有「到达即停」。这是有意的:段末项在整条展开上持续给出接近目标的梯度
-- **碰撞项**:每步端点 + 中点的逐胶囊对铰链 `ReLU(3 mm − 间隙)`。3 mm ≈ 老师轨迹最小间隙的 p10(p50 4.4 mm),避免碰撞项与 BC 标签对抗;运行时投影余量同为 3 mm
+- **碰撞项**:每步端点与中点的逐胶囊对铰链 `ReLU(3 mm − 间隙)`,两处取均值(与只查端点同量纲,`w_col` 不变)。3 mm 与训练数据的过滤线一致,碰撞项不与 BC 标签对抗;运行时投影护栏是独立的 5 mm
- 显存不够把 `configs/apg.json` 的 `batch` 降到 64,或把 `seg_plans` 从 10 降到 5
## 9. 挖错循环
@@ -175,7 +190,7 @@ Node 里的推理/安全层耗时只作参考,浏览器延迟用第 13 步实
bash ml/pipeline.sh accept
```
-自己补跑 val 全部任务的闭环(开仿真安全层):可达任务到达率 ≥ 98%、D 类停靠距离 ≤ 老师 + 5 mm、起步失败 = 0;再读第 5–11 步产物核对 R2、R7、R8、R10、M5 与部署形态各项。逐项打印「通过 / 未过」,结果在 `ml/runs/acceptance.json`。
+自己补跑 val 全部任务的闭环(开仿真安全层):可达任务到达率 ≥ 98%、D 类停靠距离 ≤ 老师 + 5 mm、起步失败率 ≤ 0.2%;再读第 5–11 步产物核对 R2、R7、R8、R10、M5 与部署形态各项。逐项打印「通过 / 未过」,结果在 `ml/runs/acceptance.json`。
## 13. 在查看器里使用
@@ -187,6 +202,7 @@ python ml/serve.py # 带跨源隔离头的本地服务,wasm
浏览器打开 `http://localhost:8000/?policy=1`:右上角徽标显示「策略模式就绪」后,拖动黄球或松手,手臂由策略闭环执行(不调用任何规划器)。
- `&ep=webgpu` 改用 WebGPU 后端对比延迟
- 控制台 `__POLICY.latency()` 查看推理与安全层的 P50/P99;`__POLICY.stats()` 查看刹停、换向、精修次数
+- 运行时投影护栏 5 mm:候选动作离障碍 < 5 mm 时沿间隙梯度外推(训练数据的过滤线是 3 mm,护栏比数据更保守)
- 工具偏移 ≠ 0 时自动交回原规划器
- 离线环境:把 onnxruntime-web 的 `ort.min.js` 与 wasm 文件放到 `webapp/vendor/ort/`,地址加 `&ortLocal=1`
@@ -194,19 +210,51 @@ python ml/serve.py # 带跨源隔离头的本地服务,wasm
| 步骤 | 文件 |
|---|---|
-| 2 | 对拍输出(终端 JSON) |
+| 2 | 对拍输出(终端 JSON)、R4 的 `__POLICY.latency()` 输出 |
| 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()` 输出 |
+| 任一步出错 | `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)
+```
+
+浏览器自动打开 `http://localhost:8100/`,零第三方依赖、不连外网,每 5 秒刷新。
+
+**开始**:点右上角「▶ 开始训练」→ 选起始/结束阶段(默认从第一个还没成功完成的阶段到「验收汇总」)、动作头、共识过滤、任务数、进程数、目标样本数、挖错轮数 → 「开始」。控制台在后台执行 `bash ml/pipeline.sh all`(`FROM`/`TO` 即起止阶段),用的是启动控制台的同一个 Python。
+**停止**:「■ 停止」结束整个进程树,这一步记为「已停止」,已完成的步骤保留;再点「开始」默认从这一步继续。
+**网页可以随时关**,训练不受影响,重新打开即可接着看;**关闭控制台窗口会结束训练**,要关终端继续跑,Linux 用 `nohup bash ml/console.sh &`。
+终端里直接跑的 `bash ml/pipeline.sh …` 同样会显示进度(只是不能在网页上停止);控制台检测到终端里有阶段在跑时不会重复开始。
+
+页面内容,从上到下:
+
+| 区域 | 内容 | 数据来源 |
+|---|---|---|
+| 流水线 | 11 个阶段的状态(完成 / 运行中 / 失败 / 已停止 / 未开始)与各步用时 | `ml/runs/pipeline.json`(每个阶段开始结束时自动写) |
+| 全流程 | 全流程百分比(按各步粗估耗时加权)、当前步骤与它的进度、总用时、本步用时、剩余粗估、开始/停止按钮 | 同上 + 各阶段产物 |
+| GPU | 利用率、显存、温度、功耗 | `nvidia-smi` |
+| 当前运行 | 正在跑的训练(自动跟随;顶部下拉框可切换 `ml/runs` 下任一次运行):步数、已用/剩余时间、预计完成时刻、评估标记 | `ml/runs/*/log.jsonl` |
+| 指标卡与曲线 | 预训练:总损失、各损失分量、吞吐(R10 线 5000 样本/s)、闭环评估(到达率 / 碰撞率 / ≤老师时长×1.3 / 起步失败);APG:到达误差、损失分量、展开长度 | 同上 |
+| 各阶段结果 | 数据统计(含 `gapDrop`)、R2、M5 两种口径、挖错各轮与门禁、部署评测、验收逐项 | `ml/data/*.json`、`ml/runs/*.json` |
+| 终端输出 | 控制台启动的流水线的命令行输出,报错标红 | `ml/runs/console.log` |
+
+当前步骤的进度怎么算:生成数据 = 已写出任务条数 / 目标条数(含自动补量);打包 = 已打包分片 / 分片数;预训练 = (测速 2000 步 + 正式步数) / (2000 + 总步数);APG = 步数;挖错 = 已完成轮数 + 本轮 APG 步数(共最多 `ROUNDS` 轮,提升不足会提前停);M5 = 两遍评估;对拍、老师门禁、导出、部署评测、验收无法计量,显示用时与粗估。全流程百分比与剩余时间按手册表格的粗估耗时加权,只作参考。
+
+同一局域网的手机或其他电脑要看:`python -m ml.monitor --host 0.0.0.0`,用本机 IP 访问;其他机器只能看,开始/停止只接受本机操作(`--remote-control` 放开)。
## 常见问题
- **指纹不符**:`collide.json` 变了。依次重跑第 2、4、5 步并重训。
- **训练发散**(loss 出现 nan/inf,自动停在 `diverged.pt`):先把 `lr` 降到 5e-4 重试一次,仍发散就走 WTA 回退(第 7 步)。
- **显存/内存不足**:降 `batch`;APG 还可把 `seg_plans` 降到 5。出题与间隙计算已分块(每块 512 个位形)。
-- **小样本摸底**:几百条任务时 val 可能为空,训练会提示并跳过闭环验证。
+- **小样本摸底**:几百条任务时 val 可能为空,训练会提示并跳过闭环验证;R2 检查近邻对不足时 `pass` 为 null,不拦后续步骤。
+- **`all` 在第 7 步中止**:M5 门禁不通过时有意退出(返回码 3),按第 7 步换 WTA 头。
- **浏览器只有 1 线程**:没走 `ml/serve.py`(缺 COOP/COEP 头)。
- **查看器徽标「策略模式不可用」**:看控制台报错,多为模型文件缺失、指纹或 TCP 不符。
@@ -214,7 +262,10 @@ python ml/serve.py # 带跨源隔离头的本地服务,wasm
```
ml/
- pipeline.sh 一条龙阶段脚本(环境变量见文件头)
+ pipeline.sh 一条龙阶段脚本(环境变量见文件头);每阶段开始/结束经 pstate.py 写 runs/pipeline.json
+ console.bat / console.sh 训练控制台一键启动(Windows / Linux)
+ monitor.py monitor.html 训练控制台:本地 HTTP 服务(开始/停止/进度/日志接口)+ 零依赖网页(地址带 ?demo=1 看示例数据)
+ pstate.py 流水线状态文件读写
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
diff --git a/ml/apg/apg_finetune.py b/ml/apg/apg_finetune.py
index 54c5b60..07ff994 100644
--- a/ml/apg/apg_finetune.py
+++ b/ml/apg/apg_finetune.py
@@ -5,7 +5,8 @@
损失:段末到达项 + Σ碰撞铰链 ReLU(margin_train − 间隙)(逐胶囊对,端点+中点)+ 平滑(速度²+加速度²)+ 行程 + BC 锚定(MeanFlow 损失)。
2026-10-18:展开长度不再定长 60 次推理(3.6 s,会截掉老师时长 > 3.6 s 的绕远题),改为覆盖本批 exec_s p90×1.2,上限 plans_max;
"到达"仍是每 seg_plans 段末的距离项(无到达即停),属有意设计:段末项给出持续的接近梯度。
- margin_train 5 → 3 mm:老师轨迹自身最小间隙 p50 4.4 / p90 5.9 mm,5 mm 会让约一半 BC 标签与碰撞项对抗;3 mm ≈ 老师 p10。"""
+ margin_train 3 mm:与训练数据过滤线一致(认证最小间隙 < 3 mm 的老师轨迹不进训练集),碰撞项不与 BC 标签对抗;
+ 运行时投影护栏独立保持 5 mm。端点与中点两个铰链取均值,与只查端点同量纲,w_col 无需重标。"""
import argparse, json, math, os, random, time
import torch
@@ -101,7 +102,7 @@ def main():
# 碰撞铰链:端点 + 中点(与仿真/运行时的离散检查一致)
G = kin.gaps(torch.cat([q, 0.5 * (q + qp)], 0), M)
hinge = torch.relu(M - G['gap'])
- col = torch.zeros(2 * B, device=dev).index_add(0, G['b'], hinge).view(2, B).sum(0)
+ col = torch.zeros(2 * B, device=dev).index_add(0, G['b'], hinge).view(2, B).mean(0) # 端点与中点取均值:与只查端点时同量纲,w_col 无需重标
sm = ((d / step) ** 2).sum(-1) + (((d - prev_d) / step) ** 2).sum(-1)
prev_d = d
seg_loss = seg_loss + C['w_col'] * col.mean() + C['w_smooth'] * sm.mean() + C['w_len'] * d.abs().sum(-1).mean()
@@ -129,13 +130,13 @@ def main():
opt.step()
with torch.no_grad():
e, oe = goal_err(kin, q, T, R)
- rec = {'it': it, 'plans': n_plans, 'err_mm_mean': e.mean().item(), 'reach_frac': ((e < 1) & (oe < 0.5)).float().mean().item(), 'bc': l_bc.item(),
+ rec = {'it': it, 'total': C['steps'], 't': time.time(), 'plans': n_plans, 'err_mm_mean': e.mean().item(), 'reach_frac': ((e < 1) & (oe < 0.5)).float().mean().item(), 'bc': l_bc.item(),
'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 (it + 1) % C['eval_every'] == 0 or it == C['steps'] - 1:
res, _ = evaluate(model.eval(), kin, val_tasks, dev)
- res['it'] = it + 1
+ 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'))
diff --git a/ml/console.bat b/ml/console.bat
new file mode 100644
index 0000000..910729f
--- /dev/null
+++ b/ml/console.bat
@@ -0,0 +1,8 @@
+@echo off
+rem 训练控制台一键启动(Windows):双击本文件 → 自动打开 http://localhost:8100 ,在网页上点「开始训练」。
+rem 需要 Python(与训练同一个环境)和 Git for Windows(提供 Git Bash)。关闭本窗口会结束控制台和正在跑的训练。
+chcp 65001 >nul
+cd /d "%~dp0\.."
+where python >nul 2>nul || (echo 找不到 python,请先激活训练用的 Python 环境 & pause & exit /b 1)
+python -m ml.monitor --open %*
+pause
diff --git a/ml/console.sh b/ml/console.sh
new file mode 100755
index 0000000..43975d2
--- /dev/null
+++ b/ml/console.sh
@@ -0,0 +1,6 @@
+#!/usr/bin/env bash
+# 训练控制台一键启动(Linux / WSL / Git Bash):bash ml/console.sh → 打开 http://localhost:8100 ,在网页上点「开始训练」。
+# 关闭本终端会结束控制台和正在跑的训练;要关终端继续跑,用 nohup bash ml/console.sh & 。
+cd "$(dirname "$0")/.."
+PY=${PYTHON:-python}; command -v "$PY" >/dev/null 2>&1 || PY=python3
+exec "$PY" -m ml.monitor --open "$@"
diff --git a/ml/eval/acceptance.py b/ml/eval/acceptance.py
index 4f62a31..41720a2 100644
--- a/ml/eval/acceptance.py
+++ b/ml/eval/acceptance.py
@@ -41,7 +41,7 @@ def main():
if reach:
r, _ = evaluate(model, kin, reach, a.device, safety=True)
add('held-out 可达任务到达率', r['success'], r['success'] >= 0.98, '≥ 0.98(仿真安全层)')
- add('起步失败率(R2)', r['start_fail'], r['start_fail'] == 0, '= 0')
+ add('起步失败率(R2)', r['start_fail'], r['start_fail'] <= 0.002, '≤ 0.2%(约 2500 题容许 ≤5 题,0 题判据过脆)')
else:
add('held-out 可达任务到达率', None, False, 'val 中无可达任务')
if dock:
diff --git a/ml/eval/check_r2.py b/ml/eval/check_r2.py
index fe027bf..8c88669 100644
--- a/ml/eval/check_r2.py
+++ b/ml/eval/check_r2.py
@@ -3,7 +3,7 @@
做法:特征逐维标准化;随机取 Q 个查询样本,在 P 个样本池里找最近邻(排除同一任务,避免同轨迹相邻帧冒充"近邻");
取距离最近的 5% 近邻对,比较前 3 步(实际提交的动作)标签:std ≈ RMS(ΔY)/√2,单位为每步最大步长 STEP 的比例。
判据:label_std < 0.10(即 < 10% 步长)。起步失败率在闭环仿真结果的 start_fail 字段(acceptance 汇总)。
-2026-10-18 新增(审查:R2 无执行点)。GPU 上约 1 分钟;CPU 上把 --pool 降到 50000。"""
+2026-10-18 新增(审查:R2 无执行点)。默认 5000 查询 × 50000 池,分块 256:GPU 峰值 < 0.5 GB,CPU 约 1–2 分钟;近邻对为空时输出 pass=null 而不报错。"""
import argparse, json, math
import torch
@@ -13,7 +13,7 @@ from ml.data import Samples
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--samples', nargs='+', default=['ml/data/samples']); ap.add_argument('--out', default='ml/data/r2.json')
- ap.add_argument('--queries', type=int, default=20000); ap.add_argument('--pool', type=int, default=200000)
+ ap.add_argument('--queries', type=int, default=5000); ap.add_argument('--pool', type=int, default=50000)
ap.add_argument('--frac', type=float, default=0.05); ap.add_argument('--seed', type=int, default=0)
ap.add_argument('--device', default='cuda' if torch.cuda.is_available() else 'cpu')
a = ap.parse_args()
@@ -25,11 +25,14 @@ def main():
Xp, Xq = (X[pi] - mu) / sd, (X[qi] - mu) / sd
tp, tq = d.TID[pi], d.TID[qi]
best_d = torch.empty(Q, device=a.device); best_j = torch.empty(Q, dtype=torch.long, device=a.device)
- for s in range(0, Q, 1024):
- D = torch.cdist(Xq[s:s + 1024], Xp)
- D = torch.where(tq[s:s + 1024, None] == tp[None], torch.full_like(D, float('inf')), D)
- v, j = D.min(1); best_d[s:s + 1024] = v; best_j[s:s + 1024] = j
+ for s in range(0, Q, 256):
+ D = torch.cdist(Xq[s:s + 256], Xp)
+ D.masked_fill_(tq[s:s + 256, None] == tp[None], float('inf'))
+ 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': '近邻对不足(样本太少),未判定'}
+ print(json.dumps(out)); json.dump(out, open(a.out, 'w'), indent=1); return
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()
diff --git a/ml/export_onnx.py b/ml/export_onnx.py
index aa630e9..d50e495 100644
--- a/ml/export_onnx.py
+++ b/ml/export_onnx.py
@@ -21,11 +21,16 @@ class Wrap(torch.nn.Module):
def main():
ap = argparse.ArgumentParser()
- ap.add_argument('--ckpt', required=True); ap.add_argument('--out', default='webapp/models/traj_policy.onnx')
+ ap.add_argument('--ckpt'); ap.add_argument('--random', metavar='CFG', help='R4 测延迟:按配置随机初始化导出,不需要训练'); ap.add_argument('--out', default='webapp/models/traj_policy.onnx')
ap.add_argument('--fp16', action='store_true'); ap.add_argument('--opset', type=int, default=17)
a = ap.parse_args()
- ck = torch.load(a.ckpt, map_location='cpu')
- m = build(ck['cfg']); m.load_state_dict(ck['model']); m.eval()
+ if a.random: # 2026-10-18:R4(浏览器 batch=8 P99)提前到训练前测,延迟只取决于结构不取决于权重
+ torch.manual_seed(0); ck = {'cfg': json.load(open(a.random)), 'random_init': True}
+ m = build(ck['cfg']); m.eval()
+ elif a.ckpt:
+ ck = torch.load(a.ckpt, map_location='cpu')
+ m = build(ck['cfg']); m.load_state_dict(ck['model']); m.eval()
+ else: raise SystemExit('需要 --ckpt 或 --random <配置>')
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)
@@ -44,7 +49,7 @@ def main():
with torch.no_grad(): t = m.sample(feat, z)
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), '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), 'eval': ck.get('eval'), 'max_abs_err_vs_torch': err}
json.dump(meta, open(a.out.replace('.onnx', '.meta.json'), 'w'), indent=1)
print(json.dumps({'onnx': a.out, 'MB': os.path.getsize(a.out) / 1e6, 'max_abs_err_vs_torch': err}))
diff --git a/ml/gen/stats.cjs b/ml/gen/stats.cjs
index 06f69c2..721d56a 100644
--- a/ml/gen/stats.cjs
+++ b/ml/gen/stats.cjs
@@ -2,6 +2,10 @@
'use strict';
const fs = require('fs');
const files = process.argv.slice(2);
+/* 2026-10-18:老师样本按认证最小间隙过滤(< MIN_GAP_MM 的轨迹不进训练集,build_dataset.cjs 同值)。
+ 安全护栏(运行时投影 5 mm)不迁就数据分布;样本数与补量只按过滤后计。 */
+const MIN_GAP_MM = +(process.env.MIN_GAP_MM || 3);
+let gapDrop = 0;
const by = {}; let total = 0, keep = 0, steps = 0, samples = 0, padTail = 0;
const q = a => { a.sort((x, y) => x - y); return a.length ? { p10: +a[Math.floor(a.length * 0.1)].toFixed(1), p50: +a[a.length >> 1].toFixed(1), p90: +a[Math.floor(a.length * 0.9)].toFixed(1), max: +a[a.length - 1].toFixed(1) } : null; };
const plan = [], certMs = [], travel = [], exec = [], gaps = [];
@@ -10,7 +14,8 @@ for (const f of files) for (const line of fs.readFileSync(f, 'utf8').split('\n')
const k = r.cat + '/' + r.mode; const b = by[k] = by[k] || { n: 0, reached: 0, certFail: 0, keep: 0, demoted: 0, consensusDrop: 0, j6Frozen: 0 };
b.n++; if (r.reached) b.reached++; if (r.cert && !r.cert.ok) b.certFail++; if (r.demoted) b.demoted++; if (r.why === 'consensus') b.consensusDrop++; if (r.j6Frozen) b.j6Frozen++;
if (r.plan_ms != null) plan.push(r.plan_ms); if (r.cert) certMs.push(r.cert.ms); if (r.cert) gaps.push(r.cert.minGap);
- if (r.keep) { b.keep++; keep++; travel.push(r.travel_deg); exec.push(r.exec_s);
+ if (r.keep && r.cert && r.cert.minGap != null && r.cert.minGap < MIN_GAP_MM) { gapDrop++; b.gapDrop = (b.gapDrop || 0) + 1; }
+ else if (r.keep) { b.keep++; keep++; travel.push(r.travel_deg); exec.push(r.exec_s);
const n = r.traj.length - 1; steps += n; const s = Math.floor(n / 3) + 1; samples += s;
for (let t = 0; t <= n; t += 3) if (t + 16 > n) padTail++; }
}
@@ -18,5 +23,6 @@ const out = { tasks: total, keep, keepRate: +(keep / total).toFixed(3), byCatMod
planMs: q(plan), certMs: q(certMs), vertexMinGapMm: q(gaps), travelDeg: q(travel), execS: q(exec),
samplesEvery3: samples, samplesPerTask: +(samples / keep).toFixed(1), paddedSampleShare: +(padTail / samples).toFixed(3),
projectedSamplesFor50k: Math.round(samples / total * 50000),
+ gapDrop, minGapFilterMm: MIN_GAP_MM,
consensusDrop: Object.values(by).reduce((a, b) => a + b.consensusDrop, 0) };
console.log(JSON.stringify(out, null, 1));
diff --git a/ml/mining/gate.py b/ml/mining/gate.py
index c5bfe16..96ac404 100644
--- a/ml/mining/gate.py
+++ b/ml/mining/gate.py
@@ -44,7 +44,8 @@ def main():
cat = [t['cat'] for t in tasks]
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])
- left = lambda sd, ok: (((sd > 0) & det & ok).sum() / ((det & ok).sum().clamp(min=1))).item()
+ # 2026-10-18:左右比例按全部 B/C 题的首次 J1 方向统计,不以到达为条件(APG 把一侧绕行全做失败时,到达子集比例可纹丝不动)
+ left = lambda sd, ok: (((sd > 0) & det).sum() / det.sum().clamp(min=1)).item()
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
both = ok_o & ok_n
diff --git a/ml/mining/tasks.py b/ml/mining/tasks.py
index 3476083..a3c0df4 100644
--- a/ml/mining/tasks.py
+++ b/ml/mining/tasks.py
@@ -33,9 +33,12 @@ 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):
- gm = kin.gaps(q, 40)['gm']
- return torch.stack([gm[:, g] for g in (2, 3, 4, 5, 6)], -1).min(-1).values # 组 0(立柱/底座)× 组 2..6
+def column_gap(kin, q, chunk=256):
+ out = []
+ for s in range(0, q.shape[0], chunk): # 与 min_gap_chunked 一样分块防 OOM
+ gm = kin.gaps(q[s:s + chunk], 40)['gm']
+ out.append(torch.stack([gm[:, g] for g in (2, 3, 4, 5, 6)], -1).min(-1).values) # 组 0(立柱/底座)× 组 2..6
+ return torch.cat(out)
@torch.no_grad()
diff --git a/ml/monitor.html b/ml/monitor.html
new file mode 100644
index 0000000..c185960
--- /dev/null
+++ b/ml/monitor.html
@@ -0,0 +1,795 @@
+
+
+
+
+
+训练监控 · 机械臂避障策略
+
+
+
+
+
+
+
训练监控
机械臂避障策略 · Transformer
+
+
+
+ 连接中
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ml/monitor.py b/ml/monitor.py
new file mode 100644
index 0000000..1d47118
--- /dev/null
+++ b/ml/monitor.py
@@ -0,0 +1,315 @@
+"""训练控制台(本地网页,零第三方依赖,离线可用):一键开始全流程、看进度、可停止。
+ 双击 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,网页实时显示;「停止」结束整个进程树。网页关掉不影响训练,重新打开即可继续看。
+只读数据来源:
+ 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 新增(用户要求:训练全程可视化,并在网页上点按钮一键开始)。"""
+import argparse, glob, json, os, re, shutil, signal, subprocess, sys, threading, time, webbrowser
+from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
+from urllib.parse import urlparse, parse_qs
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+HTML = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'monitor.html')
+RUNS, DATA = 'ml/runs', 'ml/data'
+
+
+def jload(p):
+ try:
+ with open(p, encoding='utf-8') as f: return json.load(f)
+ except Exception: return None
+
+
+def kind_of(name):
+ if name.startswith('apg'): return 'apg'
+ if name.startswith('bench'): return 'bench'
+ return 'train'
+
+
+def list_runs():
+ out = []
+ for f in glob.glob(os.path.join(ROOT, RUNS, '*', 'log.jsonl')):
+ d = os.path.basename(os.path.dirname(f)); st = os.stat(f)
+ out.append({'name': d, 'kind': kind_of(d), 'size': st.st_size, 'mtime': st.st_mtime,
+ 'has_best': os.path.exists(os.path.join(os.path.dirname(f), 'best.pt')),
+ 'diverged': os.path.exists(os.path.join(os.path.dirname(f), 'diverged.pt'))})
+ out.sort(key=lambda r: -r['mtime'])
+ return out
+
+
+def read_log(name, offset):
+ if not re.fullmatch(r'[\w.\-]+', name or ''): return {'error': 'bad run'}
+ f = os.path.join(ROOT, RUNS, name, 'log.jsonl')
+ if not os.path.exists(f): return {'error': 'no log'}
+ size = os.path.getsize(f)
+ if offset > size: offset = 0 # 日志被新训练覆盖 → 从头读
+ recs = []
+ with open(f, 'rb') as fh:
+ fh.seek(offset); buf = fh.read(min(size - offset, 64 << 20))
+ end = buf.rfind(b'\n') + 1 # 只取完整行,半行留到下次
+ for line in buf[:end].decode('utf-8', 'replace').splitlines():
+ if line.strip():
+ try: recs.append(json.loads(line))
+ except Exception: pass
+ return {'records': recs, 'offset': offset + end, 'size': size, 'reset': offset == 0, 'mtime': os.path.getmtime(f)}
+
+
+def gpu():
+ try:
+ q = 'name,utilization.gpu,memory.used,memory.total,temperature.gpu,power.draw'
+ r = subprocess.run(['nvidia-smi', f'--query-gpu={q}', '--format=csv,noheader,nounits'], capture_output=True, text=True, timeout=3)
+ out = []
+ for line in r.stdout.strip().splitlines():
+ n, u, mu, mt, t, p = [x.strip() for x in line.split(',')]
+ num = lambda x: float(x) if re.fullmatch(r'[\d.]+', x) else None
+ out.append({'name': n, 'util': num(u), 'mem_used': num(mu), 'mem_total': num(mt), 'temp': num(t), 'power': num(p)})
+ return out
+ except Exception:
+ return []
+
+
+def last_rec(name):
+ """读日志最后一条非评估记录(只读文件尾 16 KB)。"""
+ f = os.path.join(ROOT, RUNS, name, 'log.jsonl')
+ try:
+ with open(f, 'rb') as fh:
+ fh.seek(0, 2); n = fh.tell(); fh.seek(max(0, n - 16384)); tail = fh.read().decode('utf-8', 'replace').splitlines()
+ for line in reversed(tail):
+ try:
+ r = json.loads(line)
+ if 'eval' not in r: return r
+ except Exception: pass
+ except Exception: pass
+ return None
+
+
+def newer(pattern, t0):
+ return [f for f in glob.glob(os.path.join(ROOT, pattern)) if os.path.getmtime(f) >= t0 - 1]
+
+
+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)
+ tgt = info.get('target_tasks') or 0
+ return {'frac': min(1, n / tgt) if tgt else None, 'label': f'已生成 {n:,} / {tgt:,} 条任务'}
+ if st == 'prep':
+ nsh = len(glob.glob(os.path.join(ROOT, DATA, 'shard_*.jsonl')))
+ done = len(newer(f'{DATA}/samples/*.X.npy', t0))
+ if os.path.exists(os.path.join(ROOT, DATA, 'r2.json')) and newer(f'{DATA}/r2.json', t0): return {'frac': 1, 'label': '打包完成,R2 检查完成'}
+ return {'frac': min(0.95, done / nsh) if nsh else None, 'label': f'已打包 {done} / {nsh} 个分片' + (',R2 检查中' if nsh and done >= nsh else '')}
+ if st == 'train':
+ tag, bs = info.get('tag', 'base'), info.get('bench_steps', 2000)
+ b, m = last_rec(f'bench_{tag}') if newer(f'{RUNS}/bench_{tag}/log.jsonl', t0) else None, last_rec(tag) if newer(f'{RUNS}/{tag}/log.jsonl', t0) else None
+ if m and m.get('total'):
+ return {'frac': (bs + m['step']) / (bs + m['total']), 'label': f'正式训练 {m["step"]:,} / {m["total"]:,} 步', 'run': tag}
+ if b: return {'frac': b['step'] / (bs + 60000), 'label': f'测速 {b["step"]:,} / {bs:,} 步', 'run': f'bench_{tag}'}
+ return {'frac': 0, 'label': '加载数据中'}
+ if st == 'apg':
+ r = last_rec('apg') if newer(f'{RUNS}/apg/log.jsonl', t0) else None
+ return {'frac': r['it'] / r['total'] if r and r.get('total') else 0, 'label': f'APG {r["it"]:,} / {r["total"]:,} 步' if r else '准备中', 'run': 'apg'}
+ if st == 'mine':
+ R = info.get('rounds', 6)
+ k = len(newer(f'{RUNS}/mine/r*/gate.json', t0))
+ cur = sorted(newer(f'{RUNS}/apg_r*/log.jsonl', t0), key=os.path.getmtime)
+ frac_in, lab = 0, f'第 {k + 1} 轮:挖错出题'
+ if cur and len(cur) > k:
+ name = os.path.basename(os.path.dirname(cur[-1])); r = last_rec(name)
+ if r and r.get('total'): frac_in = 0.3 + 0.6 * r['it'] / r['total']; lab = f'第 {k + 1} 轮:APG {r["it"]:,} / {r["total"]:,} 步'
+ return {'frac': min(1, (k + frac_in) / R), 'label': f'{lab}(共最多 {R} 轮,提升不足会提前停)', 'run': name}
+ return {'frac': min(1, k / R), 'label': f'{lab}(共最多 {R} 轮,提升不足会提前停)'}
+ if st == 'm5':
+ tag = info.get('tag', 'base')
+ n = len(newer(f'{RUNS}/m5_{tag}_policy.json', t0)) + len(newer(f'{RUNS}/m5_{tag}_safety.json', t0))
+ return {'frac': n / 2, 'label': ['纯策略评估中', '加安全层评估中', '汇总'][min(n, 2)]}
+ return {'frac': None, 'label': '进行中'}
+
+
+def pipeline():
+ d = jload(os.path.join(ROOT, RUNS, 'pipeline.json'))
+ if not d: return None
+ cur = d.get('current')
+ if cur and cur in d['stages']:
+ try: d['progress'] = stage_progress(cur, d['stages'][cur])
+ except Exception as e: d['progress'] = {'frac': None, 'label': f'进度读取失败:{e}'}
+ return d
+
+
+def status():
+ P = lambda *a: os.path.join(ROOT, *a)
+ mine = []
+ for d in sorted(glob.glob(P(RUNS, 'mine', 'r*')), key=lambda x: int(re.sub(r'\D', '', os.path.basename(x)) or 0)):
+ rep, gate = jload(os.path.join(d, 'report.json')), jload(os.path.join(d, 'gate.json'))
+ if rep or gate:
+ mine.append({'round': os.path.basename(d), 'report': rep and {k: rep.get(k) for k in ('n', 'E1', 'E2', 'E3', 'E4', 'E5', 'ok', 'pool')},
+ 'gate': gate and {k: gate.get(k) for k in ('gain_pp', 'A_drop_pp', 'fixed_kept', 'R7_left_share_shift_pp', 'R7_travel_median_ratio', 'accept')}})
+ 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
+ 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(),
+ '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'))},
+ 'm5': [x for x in (jload(f) for f in sorted(glob.glob(P(RUNS, 'm5_gate_*.json')))) if x],
+ 'selected': open(P(RUNS, 'selected_ckpt')).read().strip() if os.path.exists(P(RUNS, 'selected_ckpt')) else None,
+ '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+'}
+CONSOLE = os.path.join(ROOT, RUNS, 'console.log')
+
+
+def find_bash():
+ """Windows 优先 Git Bash(System32\\bash.exe 是 WSL,不能用);其他系统用 PATH 里的 bash。"""
+ if os.name == 'nt':
+ for p in [os.environ.get('BASH', ''), r'C:\Program Files\Git\bin\bash.exe', r'C:\Program Files (x86)\Git\bin\bash.exe',
+ os.path.expandvars(r'%LOCALAPPDATA%\Programs\Git\bin\bash.exe')]:
+ if p and os.path.exists(p): return p
+ g = shutil.which('git')
+ if g:
+ p = os.path.join(os.path.dirname(os.path.dirname(g)), 'bin', 'bash.exe')
+ if os.path.exists(p): return p
+ return None
+ return shutil.which('bash')
+
+
+class Job:
+ def __init__(self):
+ self.p = None; self.info = {}; self.lock = threading.Lock()
+
+ def state(self):
+ with self.lock:
+ 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}
+
+ 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 分钟内有更新),请先在终端结束它'}
+ 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
+ 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')
+ log = open(CONSOLE, 'a', encoding='utf-8') # 追加模式:网页写入的"已停止"提示不会被子进程覆盖
+ kw = {'creationflags': subprocess.CREATE_NEW_PROCESS_GROUP} 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):
+ 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)
+ 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)
+ 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)
+ return {'ok': True}
+
+
+JOB = Job()
+
+
+def console(offset):
+ if not os.path.exists(CONSOLE): return {'text': '', 'offset': 0, 'reset': True}
+ size = os.path.getsize(CONSOLE); reset = offset > size or offset == 0
+ if reset: offset = max(0, size - 65536) # 首次只给最后 64 KB
+ with open(CONSOLE, 'rb') as f: f.seek(offset); b = f.read(min(size - offset, 1 << 20))
+ return {'text': b.decode('utf-8', 'replace').replace('\r', ''), 'offset': offset + len(b), 'reset': reset}
+
+
+class H(BaseHTTPRequestHandler):
+ def log_message(self, *a): pass
+
+ def send(self, code, body, ctype):
+ b = body if isinstance(body, bytes) else body.encode('utf-8')
+ self.send_response(code); self.send_header('Content-Type', ctype); self.send_header('Content-Length', str(len(b)))
+ self.send_header('Cache-Control', 'no-store'); self.end_headers(); self.wfile.write(b)
+
+ def do_GET(self):
+ u = urlparse(self.path); q = parse_qs(u.query)
+ try:
+ if u.path in ('/', '/index.html'):
+ with open(HTML, 'rb') as f: return self.send(200, f.read(), 'text/html; charset=utf-8')
+ if u.path == '/api/runs': return self.send(200, json.dumps(list_runs()), 'application/json')
+ if u.path == '/api/log':
+ return self.send(200, json.dumps(read_log(q.get('run', [''])[0], int(q.get('offset', ['0'])[0]))), 'application/json')
+ if u.path == '/api/status': return self.send(200, json.dumps(status()), 'application/json')
+ if u.path == '/api/job': return self.send(200, json.dumps(JOB.state()), 'application/json')
+ if u.path == '/api/console': return self.send(200, json.dumps(console(int(q.get('offset', ['0'])[0]))), 'application/json')
+ self.send(404, 'not found', 'text/plain')
+ except Exception as e:
+ self.send(500, json.dumps({'error': str(e)}), 'application/json')
+
+
+ def do_POST(self):
+ 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')
+ 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 {})
+ 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')
+ except Exception as e:
+ self.send(500, json.dumps({'error': str(e)}), 'application/json')
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument('--port', type=int, default=8100); ap.add_argument('--host', default='127.0.0.1')
+ ap.add_argument('--open', action='store_true', help='启动后自动打开浏览器')
+ ap.add_argument('--remote-control', action='store_true', help='允许其他机器点开始/停止(默认只能看)')
+ a = ap.parse_args()
+ srv = ThreadingHTTPServer((a.host, a.port), H); srv.remote_control = a.remote_control
+ url = f'http://localhost:{a.port}/'
+ print(f'训练控制台:{url} (关闭本窗口会停止控制台;已开始的训练会一并结束,先在网页上确认跑完)', flush=True)
+ if a.open: threading.Timer(0.8, lambda: webbrowser.open(url)).start()
+ try: srv.serve_forever()
+ except KeyboardInterrupt: pass
+
+
+if __name__ == '__main__':
+ main()
diff --git a/ml/pipeline.sh b/ml/pipeline.sh
index a706c11..f510709 100755
--- a/ml/pipeline.sh
+++ b/ml/pipeline.sh
@@ -2,6 +2,7 @@
# 一条龙:bash ml/pipeline.sh <阶段> ;阶段按顺序:
# setup 安装 Python/Node 依赖(torch 需先按 CUDA 版本装好)
# kin 导出运动学/碰撞模型 + JS↔PyTorch 对拍(必须 PASS)
+# r4 浏览器推理延迟摸底(随机初始化模型导出 ONNX;训练前做,不过就改模型规模,不必等训完)
# gate1 老师一致性门禁(决定生成时是否加 --consensus 4)
# gen 生成老师轨迹(NPROC 进程;CONSENSUS=1 时加共识过滤;样本不足 TARGET_SAMPLES 自动补生成)
# prep 打包训练样本 + R2 标签一致性检查
@@ -12,12 +13,13 @@
# export 导出 ONNX(fp32)到 webapp/models/
# bench 部署形态评测(Node,策略 + 安全层,含 R8 对抗集)
# accept 验收汇总(方案 §9.2 各项 + R2/R7/R8/R10)→ ml/runs/acceptance.json
-# all 依次执行 kin → accept
+# all 一键依次执行 kin → accept(FROM=gen TO=train 可只跑一段),并在后台自动打开训练监控(r4 需在浏览器里看,不含在 all 里;R2、M5 不通过会停下)
+# 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
# 2026-10-18:按另一台机器的审查改——头分目录不互相覆盖、统一 $PYTHON(Windows Git Bash 可跑)、
# gen 自动补量、m5 双口径、export 默认 fp32、新增 accept。
-set -e
+set -e -o pipefail # pipefail:`node ... | tee` 里 node 失败也算失败(否则门禁中断会留下残缺的 consistency.json 继续往下跑)
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; }
@@ -31,10 +33,21 @@ 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(监控网页据此显示全流程进度与各步用时)
+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}" ;;
+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 ;;
+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 ;;
+ 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)")
@@ -48,11 +61,15 @@ case $stage in
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 条任务"
- run bash ml/gen/run_parallel.sh $(( (need + NPROC - 1) / NPROC )) $NPROC $s0 "$extra"
+ 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 ;;
+ 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
@@ -80,11 +97,27 @@ 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()" ;;
+ 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) for s in kin gate1 gen prep train m5 apg mine export bench accept; do bash ml/pipeline.sh $s; done ;;
- *) sed -n '2,20p' "$0" ;;
+ all) # FROM / TO 指定起止阶段(默认 kin → accept),网页控制台的「开始训练」也走这里
+ 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
+ [ "$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// /,}
+ # 监控网页随 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/ (全部跑完后仍可查看,关闭终端即结束)"
+ fi
+ for s in $PLAN; do bash ml/pipeline.sh $s; done ;;
+ *) sed -n '2,22p' "$0" ;;
esac
diff --git a/ml/prep/build_dataset.cjs b/ml/prep/build_dataset.cjs
index 9df4df6..edd80f3 100644
--- a/ml/prep/build_dataset.cjs
+++ b/ml/prep/build_dataset.cjs
@@ -25,10 +25,13 @@ const gauss = () => Math.sqrt(-2 * Math.log(rnd() + 1e-12)) * Math.cos(2 * Math.
const hash = s => { let h = 2166136261; for (const c of s) h = Math.imul(h ^ c.charCodeAt(0), 16777619); return h >>> 0; };
const X = [], Y = [], V = [], SG = [], TID = [], tasks = [];
const lines = fs.readFileSync(IN, 'utf8').split('\n').filter(Boolean);
-let nAug = 0, nAugRej = 0;
+let nAug = 0, nAugRej = 0, nGapDrop = 0;
+/* 2026-10-18:认证最小间隙 < 3 mm 的老师轨迹不进训练集;噪声增广接受地板同步为 3 mm(原 1 mm)。 */
+const MIN_GAP_MM = +(process.env.MIN_GAP_MM || 3);
for (const line of lines) {
const r = JSON.parse(line);
if (!r.keep) continue;
+ if (r.cert && r.cert.minGap != null && r.cert.minGap < MIN_GAP_MM) { nGapDrop++; continue; } // 与 stats.cjs 同一过滤
const tid = hash(r.id) & 0x7fffffff;
const split = (hash(r.id + '#split') % 100) < 5 ? 'val' : 'train';
const P = r.path;
@@ -43,7 +46,7 @@ for (const line of lines) {
if (rnd() < 0.3) {
const qn = q.map((v, j) => { let x = v + gauss(); const t2 = L[O[j]]; if (!t2.cont) x = Math.max(t2.lo, Math.min(t2.hi, x)); return x; });
nAug++;
- if (S.collideFree(qn) && S.clearance(qn, 5) > 1) { off = qn.map((v, j) => v - q[j]); q = qn; qPrev = qPrev.map((v, j) => v + off[j]); aug = true; }
+ if (S.collideFree(qn) && S.clearance(qn, 5) > MIN_GAP_MM) { off = qn.map((v, j) => v - q[j]); q = qn; qPrev = qPrev.map((v, j) => v + off[j]); aug = true; }
else nAugRej++;
}
const pos = k => { const i = Math.min(N, t + k); const w = aug ? Math.max(0, 1 - k / 8) : 0; return traj[i].map((v, j) => v + off[j] * w); };
@@ -72,4 +75,4 @@ 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');
-console.log(JSON.stringify({ shard: base, tasks: tasks.length, samples: n, aug: nAug, augRejected: nAugRej, featVer: POL.FEAT_VER }));
+console.log(JSON.stringify({ shard: base, tasks: tasks.length, samples: n, aug: nAug, augRejected: nAugRej, gapDrop: nGapDrop, featVer: POL.FEAT_VER }));
diff --git a/ml/pstate.py b/ml/pstate.py
new file mode 100644
index 0000000..def0f5a
--- /dev/null
+++ b/ml/pstate.py
@@ -0,0 +1,62 @@
+"""流水线状态文件 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 <阶段> 键=值 ... # 阶段中途更新(如补量后的目标条数)
+ python -m ml.pstate end <阶段> <返回码>
+2026-10-18 新增(用户要求:从训练开始到结束都能在监控面板看到进度)。"""
+import json, os, sys, time
+
+P = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'ml', 'runs', 'pipeline.json')
+
+
+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}
+
+
+def save(d):
+ os.makedirs(os.path.dirname(P), exist_ok=True)
+ tmp = P + '.tmp'
+ with open(tmp, 'w', encoding='utf-8') as f: json.dump(d, f, ensure_ascii=False, indent=1)
+ os.replace(tmp, P)
+
+
+def kv(args):
+ out = {}
+ for a in args:
+ k, _, v = a.partition('=')
+ try: v = float(v) if '.' in v else int(v)
+ except ValueError: pass
+ out[k] = v
+ return out
+
+
+def main():
+ cmd, *rest = sys.argv[1:] or ['?']
+ 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}
+ elif cmd == 'begin':
+ st = rest[0]
+ d['stages'][st] = {'start': now, 'end': None, 'rc': None, 'info': kv(rest[1:])}
+ d['current'] = st
+ elif cmd == 'set':
+ s = d['stages'].setdefault(rest[0], {'start': now, 'end': None, 'rc': None, 'info': {}})
+ s['info'].update(kv(rest[1:]))
+ elif cmd == 'end':
+ st, rc = rest[0], int(rest[1]) if len(rest) > 1 else 0
+ s = d['stages'].setdefault(st, {'start': now, 'info': {}})
+ s['end'], s['rc'] = now, rc
+ if d.get('current') == st: d['current'] = None
+ else:
+ print(__doc__); return
+ save(d)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/ml/train.py b/ml/train.py
index 45e3338..62029fa 100644
--- a/ml/train.py
+++ b/ml/train.py
@@ -97,14 +97,14 @@ def main():
opt.step(); seen += X.shape[0]
if step % tc['log_every'] == 0:
el = time.time() - t0
- rec = {'step': step, 'loss': loss.item(), 'mf': mf_raw.item(), 'fk_cm': l_fk.item(), 'smooth': l_sm.item(),
+ rec = {'step': step, 'total': total, 't': time.time(), 'loss': loss.item(), 'mf': mf_raw.item(), 'fk_cm': l_fk.item(), 'smooth': l_sm.item(),
'value': l_val.item(), 'sub': l_sub.item(), 'gnorm': gn.item(), 'lr': lr_at(step), 'samples_per_s': seen / max(el, 1e-6),
'eta_h': (total - step - 1) * tc['batch'] / max(seen / max(el, 1e-6), 1e-6) / 3600}
print(json.dumps(rec), flush=True); log.write(json.dumps(rec) + '\n'); log.flush()
if not a.no_eval and (step + 1) % tc['eval_every'] == 0:
model.eval()
res, _ = evaluate(model, kin, val_tasks, dev)
- res['step'] = step + 1
+ res['step'] = step + 1; res['t'] = time.time()
print('EVAL', json.dumps(res), flush=True); log.write(json.dumps({'eval': res}) + '\n'); log.flush()
save('last.pt', step + 1)
if res['success'] > best + 1e-4:
diff --git a/tests/policy_bench.cjs b/tests/policy_bench.cjs
index 5dd82e0..0dd4494 100644
--- a/tests/policy_bench.cjs
+++ b/tests/policy_bench.cjs
@@ -56,7 +56,7 @@ function r8Cases(N, seed) {
tries++;
const b = rq(); if (!S.collideFree(b) || !nearColumn(b)) continue;
for (let t = 0; t < 200; t++) {
- const a = rq(); if (!S.collideFree(a)) continue;
+ const a = rq(); if (!S.collideFree(a) || !nearColumn(a)) continue; // 2026-10-18:起点也贴柱 → 两侧都要绕远(方案 R8 口径)
if (Math.abs(d(a, b)[0]) < 140 || segOK(a, b)) continue;
cases.push({ q0: a, T: S.fk(b, 0), R: null }); break;
}
diff --git a/webapp/js/ik-policy.js b/webapp/js/ik-policy.js
index 5bd109e..324f923 100644
--- a/webapp/js/ik-policy.js
+++ b/webapp/js/ik-policy.js
@@ -171,7 +171,7 @@ function makeRng(seed) {
class Runner {
constructor(S, infer, opts) {
this.S = S; this.infer = infer;
- this.o = Object.assign({ K: 8, commit: 3, margin: 3, /* 2026-10-18:投影余量 5→3 mm,与 APG margin_train 一致(老师最小间隙 p50 4.4 mm) */
+ this.o = Object.assign({ K: 8, commit: 3, margin: 5, /* 2026-10-18:运行时投影余量保持 5 mm(真机护栏,不迁就数据分布);数据侧另行过滤认证间隙 < 3 mm 的老师轨迹,APG 训练余量 3 mm */
projMax: 0.5, refineMm: 5, arriveMm: 1, arriveDeg: 0.5,
stuckPlans: 5, lockSec: 1.0, subReachDeg: 5, subTimeout: 1.5, seed: 12345, lookahead: true,
certBudget: 40, safetyMs: 30 }, opts || {}); // 每条候选最多 40 次距离查询;每次推理安全层总预算 30 ms(超出按刹停处理)