diff --git a/.gitignore b/.gitignore
index ad02d59..931d2cd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -46,3 +46,6 @@ webapp/models/*.meta.json
# 从头重跑时旧产物挪到这里(不入库)
ml/archive/
+
+# onnxruntime-web 本地副本(bash ml/prep/vendor_ort.sh 生成,约 50 MB,不入库)
+webapp/vendor/ort/
diff --git a/docs/ML运行手册审查.md b/docs/ML运行手册审查.md
index 8dbb3a6..7ab7f84 100644
--- a/docs/ML运行手册审查.md
+++ b/docs/ML运行手册审查.md
@@ -82,3 +82,100 @@ tickGapMax 全部 ≈21.5 ms(20 ms 节拍没被卡住)。背景:第五轮
1. ~~A 组修复并重跑 `r4`~~:已完成(本机 2026-10-18,rc=0、P99 11.25)。
2. 接着 `FROM=gen TO=prep` 跑一次(数小时),拿 `gapDrop` 与 R2 两个口径的数——C-2 / C-3 随之消掉,B-2 的 Node 侧安全层数字可顺手从 m5 前的 bench 拿。
3. B 组等上面的数字齐了再定;C-1 / C-5 / C-6 随第一次真跑逐项核对;A-3(离线 ort 加载)交给实现方。
+
+---
+
+## 五、实现方处置(第六轮,2026-10-18)
+
+逐条对应第二部分。分歧按第五轮第〇节规则以实现方为准,下一轮用实测数据定。
+
+**A-3 离线 ort 加载**:已修。`ik-policy-glue.js` 与 `r4_probe.html` 的本地前缀改为绝对 URL(`new URL('vendor/ort/', location.href)`),ORT 的 `import()` 不再把它当裸模块名。新增 `ml/prep/vendor_ort.sh`(`setup` 与 `r4` 自动调用)用 `npm pack onnxruntime-web@1.20.1` 把 `ort.min.js`、`ort.webgpu.min.js` 与两套 wasm/mjs 放进 `webapp/vendor/ort/`(33 MB,`.gitignore`);探针检测到本地副本就默认用它,不再依赖间歇不可达的 jsdelivr(`ORT_LOCAL=0` 强制 CDN)。
+
+**GBK 兜底**:除了本机已补的 7 处,`pipeline.sh`、`console.bat`、`console.sh`、监控起的子进程统一 `PYTHONUTF8=1`,所有 Python 读写默认 UTF-8,不再逐处补 `encoding`。
+
+**B-1 R4 口径**:R4 保持纯推理判据(随机权重只代表推理开销,这点不变)。整周期另立 **R9**,用**训练好的模型**在**真查看器**里测:`python -m ml.eval.r4_probe --viewer`(`bench` 阶段自动跑,写 `ml/runs/r9_browser.json`),查看器 `&autotest=30` 跑与 Node bench **同种子同题**的 30 道贴柱题,判据 **整次规划 `planP99` < 45 ms 且饥饿节拍 < 1%**(饥饿 = 执行时钟到点时队列为空 = 实际丢步)。`r4.json` 另带 `viewer_ref`(随机模型的同一测试),只作参考不判定——随机策略几乎每次都要认证多条候选,安全层路径不代表真模型。验收汇总加 R9 一项。方案 R9 行同步改写(原"安全层 P99 < 5 ms"不可测也不对口径)。
+
+**B-2 安全层两把尺**:找到了原因,而且 Node 侧也一样慢,不是浏览器独有。沙箱 Node 单次调用:`gapMatrix` cap20 **3.6 ms** / cap40 **5.5 ms**、`collideFree` 1.5 ms、`criticalPairs` 3.1 ms,而推理只有 **1.5 ms**——**耗时在几何查询,不在模型**。具体三处:
+1. 截止只在候选之间检查,单条候选内部最多 40 次查询(≈150 ms)不受限 → 墙钟截止下沉到认证递归内部:第 1 条候选截止 45 ms,其余共享到 30 ms,到点判不过(`certFailWhy.time`),沿已认证刹停段停,安全性不变。
+2. `safetyMs` 从推理结束开始计,**把前瞻(第二次推理 + 8 份特征 = 8 次 gapMatrix cap40)算进去了** → 拆成 `lookaheadMs` / `safetyMs` / `planMs` 三项分别记。
+3. 前瞻在"卡死"时也触发,随机或差的策略几乎一直卡死 → 改为只在冷启动(新目标 / 目标跳变)前瞻,与仿真 `closed_loop.py`(只在首块前瞻)对齐;卡死由换向锁处理。
+沙箱 Node、同 2 道贴柱题、小模型实测(同一机器前后对比):安全层 P99 **200.9 → 51.9 ms**,前瞻 P99 126 → 83 ms(只剩冷启动那几次),整次规划 P99 **236.7 → 63.3 ms**(P50 57.5)。代价是沙箱上 `time` 截止判不过占多数、刹停多(2 vCPU 上每次 gapMatrix 约 4 ms,45 ms 只够约 10 次查询;未训练小模型本身也差)——你机器单核快 2× 左右,真模型候选第 1 条通过率高,R9 以本机实测为准;若本机 R9 仍不过,下一轮把几何查询(特征、投影、认证)整体搬进 Worker,主线程只执行。
+另外 bench 与查看器都记 `certQueriesP99`、`certMemoHitRate`(沙箱 0.69–0.72)、`certFailWhy`;验收加"同题安全层 浏览器 / Node ≤ 3×"一项。
+
+**顺带发现并修掉一个真 bug**:第五轮 `enc_once` 后,前瞻的 8 行特征**各不相同**,但 `ortInfer` 只送了第 1 行 → 8 个候选的前瞻价值全是同一个观测算的,冷启动时的候选排序等于随机。现在 ONNX 的 `feat` 轴改为动态(1 行 = 主推理编码一次;K 行 = 前瞻逐行编码,`meta.feat_dyn=true`),导出时对拍 8 行不同特征与 `sample()` 一致(2e-7 量级);第五轮导出的旧模型运行时自动退化为逐行推(正确但慢)。**你本机的 r4 模型需重新导出**(`bash ml/pipeline.sh r4` 会重导)。
+
+**B-3 WebGPU**:`backend not found` 的直接原因是 `ort.min.js` 里**没有 WebGPU 后端**(只在 `ort.webgpu.min.js`)。查看器与探针在 `ep=webgpu` 时改载 `ort.webgpu.min.js`;探针先记 `navigator.gpu` 与适配器信息(区分"没有 GPU"与"包不对");WebGPU 配置改用有界面窗口打开(无头模式拿不到真适配器),跑完自动关。
+
+**D**:`webapp/models/README.md` 已改为 fp32、第 10 步、新输入签名。
+
+**C 组**:第七轮已处置,下一步见第六节。
+
+## 六、实现方处置与待本机验证(第七轮,2026-10-18)
+
+本节是遗留问题的**唯一清单**:不需要计算的已在代码里修完(「本轮已修完」);需要计算的只有一条技术验证命令(「本机技术验证」),由你的 PC 跑,沙箱不跑;做法已全部定下(「已定的做法」);需要你确认的写在「还需要你确认的」。
+
+---
+
+### 6.1 本轮已修完(不需要计算)
+
+| 遗留 | 处置 | 位置 |
+|---|---|---|
+| A-3 离线 ort 加载失败 | 本地前缀改绝对 URL;`ml/prep/vendor_ort.sh` 放好 ort 本地副本,探针默认用本地、不依赖 CDN | `ik-policy-glue.js`、`r4_probe.html`、`vendor_ort.sh` |
+| GBK 读中文 json | 全流程 `PYTHONUTF8=1`(pipeline / console.bat / console.sh / 监控子进程) | `pipeline.sh` 等 |
+| B-1 R4 口径 | R4 仍判纯推理;整周期另立 R9(真查看器、训练好的模型、与 bench 同题:`planP99` < 45 ms、饥饿 < 1%),验收加 R9 | `r4_probe.py --viewer`、`acceptance.py` |
+| B-2 安全层两把尺 | 原因定位:几何查询慢(gapMatrix 3.6–5.5 ms/次),不是模型。截止下沉到认证内部;耗时拆 4 项记录;前瞻只在冷启动;验收加"同题浏览器 / Node ≤ 3×" | `ik-policy.js`、`policy_bench.cjs` |
+| 前瞻 bug(新发现) | enc_once 后前瞻只送 1 行特征 → feat 轴改动态(`meta.feat_dyn`),导出对拍 K 行 | `policy.py`、`export_onnx.py` |
+| B-3 WebGPU 零数据 | 根因:`ort.min.js` 不含 WebGPU → 改载 `ort.webgpu.min.js`;有界面窗口测;记录适配器信息 | 同上 |
+| C-3 R2 池无关性 | 每次 R2 自动以 4 倍池再测一次,记 `pool_check.independent`(差 < 0.02) | `check_r2.py` |
+| C-4 R8 出题耗时 | 出过的题按碰撞指纹缓存,`policy_bench.json` 记 `genS`、`casesCached` | `policy_bench.cjs` |
+| C-5 / C-6 本机生命周期与续跑 | 新增一条命令自检(见「本机技术验证」 V1) | `ml/selftest.sh`、`ml/selftest_proc.py` |
+| C-1 第 7–13 步零实测 | 靠断点续跑兜底(V1 验证),正式训练分三段跑、出错从该阶段续;另备 `ml/smoke.sh` 全流程冒烟,不必跑 | `ml/smoke.sh`、`configs/smoke.json` |
+| 续跑后数值不一致 | `last.pt` 存 torch / CUDA / random 随机数状态,续跑 batch 顺序与一口气跑完一致 | `ml/common/ckpt.py` |
+| D 模型目录说明过时 | 已改(fp32、第 10 步、新签名) | `webapp/models/README.md` |
+
+沙箱只做了语法检查与几秒级的对拍,**以上全部以你本机结果为准**。
+
+---
+
+### 6.2 本机技术验证(只验可行性与意外,不做正式训练)
+
+一条命令,5–10 分钟,在仓库旁的副本目录里跑,不碰本仓库的数据与产物,跑完副本可删。
+
+| # | 命令(Git Bash,仓库根目录) | 耗时 | 回传 | 验证什么 |
+|---|---|---|---|---|
+| V1 | `bash ml/pipeline.sh selftest` | 5–10 分钟 | `ml/runs/selftest.json` | 意外中断能否恢复:gen 截断续写、prep 二次运行、训练 STOP / 续训、APG 续跑;Windows 进程:控制台停止能否杀净整棵树、关掉黑窗口后流程是否继续、TERM 能否送到 python |
+
+判读:`all_pass = true`。没过就把文件发我,不用排查。
+
+不单独跑全流程冒烟:V1 通过即证明断点续跑可靠,正式训练中途某一步出错时,已完成的阶段与训练进度都保留,我修完后从出错那一步接着跑(`FROM=<阶段>`),损失只是出错到发现之间的空闲时间。为缩短这段时间,正式训练按"数据 → 训练 → 后训练与评测"三段跑,每段结束看一眼(见「正式训练」)。`ml/smoke.sh` 保留在仓库里,不作为必跑项。
+
+---
+
+### 6.3 已定的做法
+
+| 事项 | 定为 |
+|---|---|
+| R9 通过线 | 整次规划 P99 < 45 ms、饥饿节拍 < 1%(60 ms 周期留 15 ms 给渲染) |
+| 浏览器测试 | 有界面窗口(无头拿不到真 GPU) |
+| R9 本机不过 | 几何查询(特征、投影、认证)整体搬进 Worker,主线程只执行 |
+| 训练余量 3 mm、`R2_STRICT`、共识过滤 | 按正式数据段的 `stats.json` / `r2.json` 定:间隙 p10 ≥ 3 mm 保持 3 mm,否则降到 p10;R2 `pool_check.independent = true` 且截距 < 0.10 时打开 `R2_STRICT`;门 1 < 0.90 自动开共识 |
+
+---
+
+### 6.4 还需要你确认的
+
+无。V1 结果回来后,我只按结果改代码;若结果暴露出需要你决定的事,再写进本节。
+
+---
+
+### 6.5 正式训练(以后再跑,不在本轮)
+
+V1 通过后再跑,分三段,每段跑完把回传文件发我:
+
+| 段 | 命令 | 耗时 | 回传 |
+|---|---|---|---|
+| 数据 | `FROM=kin TO=prep bash ml/pipeline.sh all` | 数小时 | `ml/data/stats.json`、`ml/data/r2.json`、`ml/runs/r4.json` |
+| 训练 | `FROM=train TO=m5 bash ml/pipeline.sh all` | 约 3–6 小时 | `ml/runs/m5_gate_*.json` |
+| 后训练与评测 | `FROM=apg TO=accept bash ml/pipeline.sh all` | 约 10–20 小时 | `ml/runs/acceptance.json`、`policy_bench.json`、`r9_browser.json` |
+
+任何一段中途出错:把出错信息发我,我修完后用 `FROM=<出错阶段>` 接着跑,前面的不重做。
diff --git a/docs/Transformer轨迹拟合训练方案.html b/docs/Transformer轨迹拟合训练方案.html
index 5c81679..26616be 100644
--- a/docs/Transformer轨迹拟合训练方案.html
+++ b/docs/Transformer轨迹拟合训练方案.html
@@ -1298,7 +1298,10 @@ k 的最大距离,随机 4000 位形采样再放大 5%;末端组对 J1…J6
7.37 / 4.47 / 4.48 / 2.47 / 0.69 mm/°)。间隙取全部 |Δg| ≥ 2 胶囊对的
距离 − (r_i + r_j + margin)。外加关节限位。先用离散
collideFree 剔除明显干涉的候选;运行时每条候选最多 40
-次距离查询、每次推理安全层总预算 30 ms,超出按刹停处理。
+次距离查询;墙钟截止下沉到认证内部:第 1 条候选截止 45
+ms,其余候选共享到 30
+ms,到点即判不过、沿已认证刹停段停。安全层耗时在几何查询而不在模型(沙箱
+Node 每次 gapMatrix 3.6–5.5 ms、collideFree 1.5 ms,推理 1.5 ms)。
离散采样检查(现有 pathFine,1.5° +
半格)只作快速预筛,不作保证依据。
可刹停不变式:只有当"候选前 3 步 + 从第 3
@@ -1518,16 +1521,14 @@ held-out 闭环仿真(开仿真安全层)测量,由
| 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
-前(最大不确定项,训练前结掉;不过流程自动停) |
+ONNX(延迟与权重无关),pipeline.sh r4 自动测部署口径 K=8
+P99 < 15 ms。2026-10-18 本机:编码一次 + 16 线程 + 代理 Worker 后 P99
+10.2–11.3 ms,已通过(改前 37.0 ms)
+WebGPU 达标即用 WebGPU,否则
+d=128(configs/small.json)。K=8 不降 |
+M1 前(已结) |
| R5 |
@@ -1562,9 +1563,14 @@ d=128(configs/small.json,约 1.8M 参数)。K=8 不降
| R9 |
-安全层贴柱时耗时超标 |
-near_column 全程 P99 < 5 ms |
-Web Worker 流水线;提交步数降到 2 |
+整周期超标(推理 + 前瞻 + 安全层 > 60 ms
+周期,执行时钟饥饿丢步) |
+训练好的模型在真查看器里跑与 bench 同一批 30
+道贴柱题:整次规划 P99 < 45 ms、饥饿节拍 <
+1%(r4_probe --viewer →
+r9_browser.json);同题安全层浏览器 / Node 差 ≤ 3× |
+几何查询(features、投影、认证)移到
+Worker,主线程只执行;前瞻只在冷启动时做 |
M7 |
diff --git a/docs/Transformer轨迹拟合训练方案.md b/docs/Transformer轨迹拟合训练方案.md
index 7129e8b..ac3d2be 100644
--- a/docs/Transformer轨迹拟合训练方案.md
+++ b/docs/Transformer轨迹拟合训练方案.md
@@ -364,7 +364,7 @@ for 每个 batch(随机采样 q0 和目标,不需要老师):
### 8.3 安全层(唯一的硬保证)
-- **距离下界认证**:对一段运动 q_a → q_b,若两端到障碍的最小距离之和 d(q_a) + d(q_b) > Σ_j r_j·|Δθ_j|,则整段安全;否则二分,直到全部子段通过或判不安全。按组对分别判定:组对 (g_A, g_B) 的间隙只受关节 g_A…g_B−1 带动组 g_B 影响,右端取 Σ_{k=g_A}^{g_B−1} T[k][g_B]·|Δθ_k|,T 为分组杠杆表(组 g_B 胶囊端点到轴 k 的最大距离,随机 4000 位形采样再放大 5%;末端组对 J1…J6 分别为 9.56 / 7.37 / 4.47 / 4.48 / 2.47 / 0.69 mm/°)。间隙取全部 |Δg| ≥ 2 胶囊对的 距离 − (r_i + r_j + margin)。外加关节限位。先用离散 `collideFree` 剔除明显干涉的候选;运行时每条候选最多 40 次距离查询、每次推理安全层总预算 30 ms,超出按刹停处理。
+- **距离下界认证**:对一段运动 q_a → q_b,若两端到障碍的最小距离之和 d(q_a) + d(q_b) > Σ_j r_j·|Δθ_j|,则整段安全;否则二分,直到全部子段通过或判不安全。按组对分别判定:组对 (g_A, g_B) 的间隙只受关节 g_A…g_B−1 带动组 g_B 影响,右端取 Σ_{k=g_A}^{g_B−1} T[k][g_B]·|Δθ_k|,T 为分组杠杆表(组 g_B 胶囊端点到轴 k 的最大距离,随机 4000 位形采样再放大 5%;末端组对 J1…J6 分别为 9.56 / 7.37 / 4.47 / 4.48 / 2.47 / 0.69 mm/°)。间隙取全部 |Δg| ≥ 2 胶囊对的 距离 − (r_i + r_j + margin)。外加关节限位。先用离散 `collideFree` 剔除明显干涉的候选;运行时每条候选最多 40 次距离查询;墙钟截止下沉到认证内部:第 1 条候选截止 45 ms,其余候选共享到 30 ms,到点即判不过、沿已认证刹停段停。安全层耗时在几何查询而不在模型(沙箱 Node 每次 gapMatrix 3.6–5.5 ms、collideFree 1.5 ms,推理 1.5 ms)。
- 离散采样检查(现有 `pathFine`,1.5° + 半格)只作快速预筛,不作保证依据。
- **可刹停不变式**:只有当"候选前 3 步 + 从第 3 步末按每轴 a_max 减速到停"整段都通过认证时才提交。任何时候系统都持有一条已认证的停车路线,下一次推理即使完全失效也只会停下。
- 耗时:每段一次距离查询覆盖整段,不通过才二分(通常 2–6 次);按排序逐个认证,第一个通过即停;包围球预筛先行。目标每次 < 5 ms,否则放 Web Worker 与推理流水线并行。
@@ -444,12 +444,12 @@ 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(延迟与权重无关),`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 前(最大不确定项,训练前结掉;不过流程自动停)** |
+| R4 | 浏览器纯推理延迟 | 训练前用随机初始化模型导出 ONNX(延迟与权重无关),`pipeline.sh r4` 自动测部署口径 K=8 P99 < 15 ms。2026-10-18 本机:编码一次 + 16 线程 + 代理 Worker 后 P99 10.2–11.3 ms,**已通过**(改前 37.0 ms) | WebGPU 达标即用 WebGPU,否则 d=128(`configs/small.json`)。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 |
| R8 | 卡死换向活锁 | 50 个"两侧都要绕远"对抗用例(J1 起终差 ≥ 140°、终点贴立柱,部署运行时)首次换向锁后 3 s 内到达 ≥ 95%,活锁 = 0(`tests/policy_bench.cjs` r8) | 子目标改为离散绕行锚点分类 | M6 / M7 |
-| R9 | 安全层贴柱时耗时超标 | near_column 全程 P99 < 5 ms | Web Worker 流水线;提交步数降到 2 | M7 |
+| R9 | 整周期超标(推理 + 前瞻 + 安全层 > 60 ms 周期,执行时钟饥饿丢步) | 训练好的模型在**真查看器**里跑与 bench 同一批 30 道贴柱题:整次规划 P99 < 45 ms、饥饿节拍 < 1%(`r4_probe --viewer` → `r9_browser.json`);同题安全层浏览器 / Node 差 ≤ 3× | 几何查询(features、投影、认证)移到 Worker,主线程只执行;前瞻只在冷启动时做 | M7 |
| R10 | 训练耗时估计偏差 | 60000 步 ≤ 3.5 h,即 2000 步测速 ≥ 5000 样本/s(3090 实测 6.2 k/s ≈ 2.8 h) | FK 损失胶囊数 96 → 48;仍不够则步数降到 40000(MeanFlow JVP 受算力限制,增大 batch 无效) | M5 |
### 10.2 已知缺陷
diff --git a/ml/README.md b/ml/README.md
index 15bcfec..09f4ea8 100644
--- a/ml/README.md
+++ b/ml/README.md
@@ -29,7 +29,7 @@
| 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 个任务;R8 出题约 2 秒/题) | `ml/runs/policy_bench.json` |
+| 11 部署评测 | `bash ml/pipeline.sh bench` | 估算约 1 小时(Node CPU,1120 个任务;R8 出题耗时未实测)+ 查看器 R9 约 5 分钟 | `ml/runs/policy_bench.json`、`ml/runs/r9_browser.json` |
| 12 验收汇总 | `bash ml/pipeline.sh accept` | 10–20 分钟 | `ml/runs/acceptance.json` |
| 13 使用 | `python ml/serve.py` → 浏览器 `?policy=1` | — | — |
@@ -61,6 +61,8 @@ bash ml/pipeline.sh kin
检查:最后打印的 JSON 里 `"PASS": true`(fk、姿态、组对间隙、83 维特征、间隙梯度与 JS 一致)。
`docs/collide.json` 以后只要重新拟合或重生成 `pair2d`/`roadmap`,这一步、数据、模型都要重来(指纹不符时各脚本会拒绝运行)。
+**真跑之前(第七轮新增,5–10 分钟)**:`bash ml/pipeline.sh selftest`(进程生命周期与断点续跑自检 → `ml/runs/selftest.json`,不碰本仓库数据)。通过后正式流程按「数据 → 训练 → 后训练与评测」三段跑,中途出错从该阶段 `FROM=` 续跑。`bash ml/pipeline.sh smoke`(副本里极小模型全流程冒烟,40–70 分钟)备用,不必跑。待办与已定做法见 `docs/ML运行手册审查.md` 第六节。
+
**R4 延迟(训练前做,自动)**:推理延迟只取决于模型结构,与权重无关,所以用随机初始化模型先测,不过就在训练前改结构,不必等训完。
```bash
@@ -71,7 +73,10 @@ bash ml/pipeline.sh r4 # 随机初始化模型导出到 ml/runs/r4/traj_pol
探针测 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/`。
+找不到浏览器时 `pass` 为 null、流程继续、验收标红;可用 `R4_BROWSER=浏览器路径` 指定。
+onnxruntime-web 默认用本地副本 `webapp/vendor/ort/`(`setup` 与 `r4` 会自动执行 `bash ml/prep/vendor_ort.sh` 放好,约 33 MB、不入库),不依赖 CDN;`ORT_LOCAL=0` 强制走 CDN。
+WebGPU 配置和查看器参考测试会弹出一个有界面的浏览器窗口(无头模式拿不到真 GPU),跑完自动关闭,期间别动它。
+`r4.json` 还带一项 `viewer_ref`:随机模型在真查看器里跑 30 道贴柱题的整次规划耗时,**只作参考**(随机策略几乎每次都要认证多条候选,安全层路径不代表真模型),R9 以第 11 步为准。
手动看:`python ml/serve.py` 后打开 `http://localhost:8000/?policy=../ml/runs/r4/traj_policy_r4.onnx&probe=1`(随机模型默认会被查看器拒绝,`&probe=1` 才放行,徽标标红「随机探针模型」)。
## 3. 老师一致性门禁
@@ -191,7 +196,9 @@ bash ml/pipeline.sh bench
跑与浏览器同一份运行时(`webapp/js/ik-policy.js`):贴立柱 30 例(pos、pose 各一遍)、直线被挡 40 例、R8 对抗 50 例(J1 起终差 ≥ 140°、终点贴立柱、两侧都要绕远)、一次性贴立柱扩展集 1000 例。各集合数量可用 `--nc-n` / `--route-n` / `--r8-n` 单独调。
看 `ml/runs/policy_bench.json`:各集合 `collisionFrames` 为 0;`near_column_*` 与 `route40` 全部 `ok`;`near_column_1000` 0 失败;`brakeRate` < 0.02;`r8_adversarial` 的 `recoveryRate` ≥ 0.95、`livelock` = 0。
-Node 里的推理/安全层耗时只作参考,浏览器延迟用第 13 步实测。
+同一步还会在查看器里跑**同一批** 30 道贴柱题(与 Node 同种子),写 `ml/runs/r9_browser.json`(会弹出浏览器窗口几分钟):
+- **R9 通过线:整次规划 `planP99` < 45 ms、饥饿节拍 `starve_rate` < 1%**(整次规划 = 推理 + 前瞻 + 安全层,60 ms 周期留 15 ms 给渲染与执行时钟;饥饿 = 执行时钟到点时队列空了,即实际丢步)。
+- 两边对照:`policy_bench.json` 的 `near_column_pos.safetyMsP99` 与浏览器 `safetyP99` 差 > 3× 时验收标红,用 `certQueriesP99`(每次规划新增距离查询)、`certMemoHitRate`、`certFailWhy`(`cert` / `budget` / `time` / `box`)定位。
## 12. 验收汇总
@@ -210,10 +217,11 @@ python ml/serve.py # 带跨源隔离头的本地服务,wasm
浏览器打开 `http://localhost:8000/?policy=1`:右上角徽标显示「策略模式就绪」后,拖动黄球或松手,手臂由策略闭环执行(不调用任何规划器)。
- `&ep=webgpu` 改用 WebGPU 后端对比延迟
-- 控制台 `__POLICY.latency()` 查看推理与安全层的 P50/P99;`__POLICY.stats()` 查看刹停、换向、精修次数
+- 控制台 `__POLICY.latency()` 查看推理、前瞻、安全层、整次规划的 P50/P99 与节拍;`__POLICY.stats()` 查看刹停、换向、精修次数
+- `&autotest=30` 自动跑与 bench 同一批贴柱题,结果在 `__POLICY.autotest`
- 运行时投影护栏 5 mm:候选动作离障碍 < 5 mm 时沿间隙梯度外推(训练数据的过滤线是 3 mm,护栏比数据更保守)
- 工具偏移 ≠ 0 时自动交回原规划器
-- 离线环境:把 onnxruntime-web 的 `ort.min.js` 与 wasm 文件放到 `webapp/vendor/ort/`,地址加 `&ortLocal=1`
+- 离线环境:`bash ml/prep/vendor_ort.sh` 把 onnxruntime-web 放到 `webapp/vendor/ort/`,地址加 `&ortLocal=1`(WebGPU 同样可用)
## 14. 回传给我
@@ -224,7 +232,7 @@ python ml/serve.py # 带跨源隔离头的本地服务,wasm
| 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` |
+| 11–13 | `ml/runs/policy_bench.json`、`ml/runs/r9_browser.json`、`ml/runs/acceptance.json` |
| 任一步出错 | `ml/runs/pipeline.json`、`ml/runs/console.log`(控制台启动时)或终端输出 |
## 训练控制台
diff --git a/ml/apg/apg_finetune.py b/ml/apg/apg_finetune.py
index b3bb126..421a3b8 100644
--- a/ml/apg/apg_finetune.py
+++ b/ml/apg/apg_finetune.py
@@ -16,7 +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
+from ml.common.ckpt import Saver, atomic_save, rng_state, set_rng_state
def load_pool(paths):
@@ -33,7 +33,7 @@ def main():
ap.add_argument('--pool', nargs='*', default=[]); ap.add_argument('--out', default='ml/runs/apg')
ap.add_argument('--config', default='ml/configs/apg.json'); ap.add_argument('--steps', type=int, default=None)
ap.add_argument('--device', default='cuda' if torch.cuda.is_available() else 'cpu'); ap.add_argument('--seed', type=int, default=0)
- ap.add_argument('--resume', default=None, help='从 /last.pt 续跑(2026-10-18 第五轮,审查 N-11:几小时的 APG 断了不再从头来)')
+ ap.add_argument('--stop-at', type=int, default=0, help='自检用:跑到第 N 次按停止请求处理'); ap.add_argument('--resume', default=None, help='从 /last.pt 续跑(2026-10-18 第五轮,审查 N-11:几小时的 APG 断了不再从头来)')
a = ap.parse_args()
cfg = json.load(open(a.config)); C = cfg['apg']
if a.steps: C['steps'] = a.steps
@@ -56,6 +56,7 @@ def main():
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)
+ if set_rng_state(rk.get('rng')): print('续跑:随机数状态已恢复', flush=True)
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')
@@ -69,7 +70,7 @@ def main():
M = C['margin_train_mm']; t0 = time.time()
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()
+ atomic_save({'model': model.state_dict(), 'opt': opt.state_dict(), 'cfg': mcfg, 'step': n, 'fp': kin.fp, 'eval': ev, 'rng': rng_state()}, 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'])
@@ -149,7 +150,7 @@ 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():
+ if saver.stop() or (a.stop_at and it + 1 >= a.stop_at):
save_last(it + 1); log.close()
print(f'收到停止请求:已存档 last.pt(第 {it + 1} 次),下次运行自动续跑', flush=True); sys.exit(143)
if saver.due(): save_last(it + 1)
diff --git a/ml/common/ckpt.py b/ml/common/ckpt.py
index a5963a8..818d0cd 100644
--- a/ml/common/ckpt.py
+++ b/ml/common/ckpt.py
@@ -6,7 +6,7 @@
训练循环据此先存 last.pt 再以 143 退出。Windows 上 taskkill /F 是硬杀、收不到信号,
所以控制台「停止」先写 STOP 文件、等进程自己存档退出,超时才硬杀(见 monitor.Job.stop)。
"""
-import os, signal, time
+import os, random, signal, time
import torch
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -42,3 +42,21 @@ class Saver:
return False
def mark(self): self.last = time.time()
+
+
+def rng_state():
+ """2026-10-18 第七轮(遗留:续跑后数值轨迹与不断点不一致):随机数状态一起存档。
+ 训练取 batch 用 torch.randint(全局生成器,CUDA 上取 CUDA 生成器)、噪声用 torch.randn、APG 选题用 random.choices,
+ 三者状态都恢复后,续跑的 batch 顺序与噪声与一口气跑完一致(同一设备、同一版本;cuDNN 非确定算子另计)。"""
+ d = {'torch': torch.get_rng_state(), 'py': random.getstate()}
+ if torch.cuda.is_available(): d['cuda'] = torch.cuda.get_rng_state_all()
+ return d # 不存 numpy 状态:训练不用 np.random,且 ndarray 过不了 torch.load 的 weights_only 默认
+
+
+def set_rng_state(d):
+ if not d: return False
+ torch.set_rng_state(d['torch'].cpu() if hasattr(d['torch'], 'cpu') else d['torch']); random.setstate(d['py'])
+ if 'cuda' in d and torch.cuda.is_available():
+ try: torch.cuda.set_rng_state_all([t.cpu() for t in d['cuda']])
+ except Exception: pass
+ return True
diff --git a/ml/configs/smoke.json b/ml/configs/smoke.json
new file mode 100644
index 0000000..5cc09f2
--- /dev/null
+++ b/ml/configs/smoke.json
@@ -0,0 +1,35 @@
+{
+ "model": {
+ "d": 32,
+ "heads": 4,
+ "ff": 64,
+ "enc": 1,
+ "dec": 1
+ },
+ "train": {
+ "batch": 64,
+ "lr": 0.001,
+ "warmup": 10,
+ "steps": 300,
+ "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": 1,
+ "eval_every": 100,
+ "eval_tasks": 16,
+ "log_every": 10,
+ "patience_evals": 5
+ },
+ "_note": "冒烟专用(bash ml/smoke.sh):只验证流程能跑通,数值无意义"
+}
\ No newline at end of file
diff --git a/ml/console.bat b/ml/console.bat
index a4f49f5..ea065fa 100644
--- a/ml/console.bat
+++ b/ml/console.bat
@@ -7,6 +7,8 @@ rem 关闭本窗口只关控制台;已开始的训练继续跑,重新双击
chcp 65001 >nul
cd /d "%~dp0\.."
if "%PYTHON%"=="" set "PYTHON=python"
+rem 2026-10-18 第六轮:Python 按 UTF-8 读写文件(Windows 默认 GBK 读中文 json 会报错)
+set "PYTHONUTF8=1"
"%PYTHON%" -c "import torch" >nul 2>nul
if errorlevel 1 (
echo 当前 Python(%PYTHON%)不能 import torch。
diff --git a/ml/console.sh b/ml/console.sh
index 142cb54..b67b10c 100755
--- a/ml/console.sh
+++ b/ml/console.sh
@@ -8,4 +8,5 @@ for c in "${PYTHON:-}" python python3; do
done
[ -n "${PY:-}" ] || { echo "找不到能 import torch 的 Python:先激活训练环境,或设 PYTHON=..."; exit 1; }
export PYTHON=$PY
+export PYTHONUTF8=1 # 2026-10-18 第六轮:文件读写统一 UTF-8
exec "$PY" -m ml.monitor --open "$@"
diff --git a/ml/eval/acceptance.py b/ml/eval/acceptance.py
index 080d9de..45a7806 100644
--- a/ml/eval/acceptance.py
+++ b/ml/eval/acceptance.py
@@ -8,9 +8,11 @@
ml/runs/m5_gate_*.json M5 头门禁
ml/runs/mine/r*/gate.json R7 最后一轮左右比例 / 行程门禁
ml/runs/r4.json R4 浏览器推理 batch=8 P99 < 15 ms(r4 探针)
+ ml/runs/r9_browser.json R9 查看器整次规划(推理 + 前瞻 + 安全层)P99 < 45 ms、饥饿节拍 < 1%(训练好的模型,bench 阶段测)
webapp/models/*.meta.json 被评测的模型不能是 --random 探针
2026-10-18 新增(审查:§9 验收表在流水线里没有执行点)。
-2026-10-18 第五轮:加 R4、随机探针模型拒绝(N-5)、R8 出题不足额判不通过(N-4);R2 改为参考项(N-2:阈值与池大小耦合、未校准)。"""
+2026-10-18 第五轮:加 R4、随机探针模型拒绝(N-5)、R8 出题不足额判不通过(N-4);R2 改为参考项(N-2:阈值与池大小耦合、未校准)。
+2026-10-18 第六轮:加 R9(审查 B-1:纯推理口径会放过整周期丢步的配置)与安全层两把尺对照(B-2:同一批题浏览器 / Node 差 > 3× 标红)。"""
import argparse, glob, json, os, re
import torch
@@ -59,6 +61,9 @@ def main():
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(随机初始化同结构模型实测)')
+ r9 = jload('ml/runs/r9_browser.json'); rr = (r9 or {}).get('result') or {}
+ add('R9 查看器整次规划 P99(ms)/ 饥饿节拍占比', [rr.get('planP99'), rr.get('starve_rate')] if r9 else 'missing', bool(r9 and r9.get('pass')),
+ '< 45 ms 且 < 1%(训练好的模型、30 道贴柱题,ml.eval.r4_probe --viewer)')
pb = jload('ml/runs/policy_bench.json')
if pb is None:
add('部署形态评测', 'missing', False, '先跑 bash ml/pipeline.sh bench')
@@ -78,7 +83,12 @@ def main():
add('R8 对抗集 3 s 内恢复率', r8['recoveryRate'], r8['recoveryRate'] >= 0.95, '≥ 0.95')
add('R8 活锁', r8['livelock'], r8['livelock'] == 0, '= 0')
else: add('R8 对抗集', 'missing', False, 'bench 需含 r8')
- add('推理延迟 P99(Node CPU,参考)', max((v.get('inferMsP99') or 0) for v in sets.values()), None, '浏览器以 __POLICY.latency() 为准')
+ add('推理延迟 P99(Node CPU,参考)', max((v.get('inferMsP99') or 0) for v in sets.values()), None, '浏览器以 r9_browser.json 为准')
+ nc = sets.get('near_column_pos') or {}
+ bs, ns = rr.get('safetyP99'), nc.get('safetyMsP99')
+ ratio = (bs / ns) if (bs is not None and ns) else None
+ add('安全层 P99 浏览器 / Node(同一批贴柱题)', [bs, ns, None if ratio is None else round(ratio, 2)], None if ratio is None else ratio <= 3,
+ '≤ 3×;超了看 certQueriesP99 / certMemoHitRate / certFailWhy 定位')
# 3) 数据 / 训练 / 后训练门禁
r2 = jload('ml/data/r2.json')
diff --git a/ml/eval/check_r2.py b/ml/eval/check_r2.py
index da8e5ec..ea8cc50 100644
--- a/ml/eval/check_r2.py
+++ b/ml/eval/check_r2.py
@@ -6,8 +6,8 @@
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 而不报错。"""
+ 自检(第七轮起每次自动做):--pool 50000 与 4 倍池 200000 各测一次,label_std_norm 差 < 0.02 记 pool_check.independent=true。
+2026-10-18 新增(审查:R2 无执行点)。默认 5000 查询 × 50000 池,分块 256:GPU 峰值 < 0.5 GB,连同 4 倍池自检 CPU 约 5–10 分钟、GPU 约 1 分钟;近邻对为空时输出 pass=null 而不报错。"""
import argparse, json, math
import torch
@@ -18,13 +18,34 @@ 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=5000); ap.add_argument('--pool', type=int, default=50000)
+ ap.add_argument('--pool2', type=int, default=0, help='池无关性自检的大池(默认 4×pool)')
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()
d = Samples(a.samples, device=a.device, split='train')
+ P2 = a.pool2 if a.pool2 else 4 * a.pool
+ r1 = measure(d, a, a.pool)
+ if r1.get('pairs', 0) == 0:
+ out = r1
+ else:
+ # 2026-10-18 第七轮(遗留 C-3):池无关性自检并入每次运行——同一批查询、池放大 4 倍再测一次,
+ # 截距差 < 0.02 才说明 label_std_norm 这把尺可信(independent=true);判定用大池的数(近邻更近、截距更稳)。
+ r2 = measure(d, a, P2) if min(P2, d.n) > r1['pool'] else None
+ out = dict(r2 or r1)
+ out['pool_check'] = {'pool_small': r1['pool'], 'norm_small': r1['label_std_norm'],
+ 'pool_big': r2['pool'] if r2 else None, 'norm_big': r2['label_std_norm'] if r2 else None,
+ 'diff': abs(r2['label_std_norm'] - r1['label_std_norm']) if r2 else None,
+ 'independent': (abs(r2['label_std_norm'] - r1['label_std_norm']) < 0.02) if r2 else None,
+ 'note': '样本数不足 4 倍池时只测一次' if not r2 else '差 < 0.02 → 判据与池无关,R2_STRICT=1 可以放心打开'}
+ print(json.dumps(out, indent=1))
+ json.dump(out, open(a.out, 'w'), indent=1)
+
+
+def measure(d, a, pool):
g = torch.Generator(device=a.device); g.manual_seed(a.seed)
- P = min(a.pool, d.n); Q = min(a.queries, d.n)
- pi = torch.randperm(d.n, device=a.device, generator=g)[:P]; qi = torch.randperm(d.n, device=a.device, generator=g)[:Q]
+ P = min(pool, d.n); Q = min(a.queries, d.n)
+ qi = torch.randperm(d.n, device=a.device, generator=g)[:Q] # 查询集与池大小无关(先抽查询)
+ pi = torch.randperm(d.n, device=a.device, generator=g)[:P]
X = d.X.float(); mu, sd = X[pi].mean(0), X[pi].std(0).clamp(min=1e-6)
Xp, Xq = (X[pi] - mu) / sd, (X[qi] - mu) / sd
tp, tq = d.TID[pi], d.TID[qi]
@@ -35,8 +56,7 @@ 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, 'pool': P, 'rule': 'label_std_norm < 0.10', 'note': '近邻对不足(样本太少),未判定'}
- print(json.dumps(out)); json.dump(out, open(a.out, 'w'), indent=1); return
+ return {'pairs': 0, 'label_std_mean': None, 'pass': None, 'pool': P, 'rule': 'label_std_norm < 0.10', 'note': '近邻对不足(样本太少),未判定'}
# 与池无关的口径:全部有效近邻对(取距离较近的一半,排除远处非线性段)上回归 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)
@@ -50,13 +70,11 @@ def main():
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()), '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)
+ return {'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 为旧口径(随池大小变),仅参考'}
if __name__ == '__main__':
diff --git a/ml/eval/r4_probe.py b/ml/eval/r4_probe.py
index 6c4f4ec..e00742a 100644
--- a/ml/eval/r4_probe.py
+++ b/ml/eval/r4_probe.py
@@ -9,11 +9,24 @@
判据:部署口径 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)给。"""
+范围说明:R4 判据只看纯推理(随机权重只能代表推理开销)。
+2026-10-18 第六轮(审查 B-1/B-2/B-3):
+ --viewer:在真查看器里跑 autotest(与 Node bench 同一批 30 道贴立柱题),记录整次规划 planMs(推理 + 前瞻 + 安全层)、
+ 节拍间隔与饥饿(丢步)次数。r4 阶段用随机模型跑一次,只作参考(随机策略的安全层路径不代表真模型);
+ bench 阶段用训练好的模型跑,写 ml/runs/r9_browser.json,判据 = 方案 R9:planMs P99 < 45 ms(60 ms 周期留 15 ms 余量)且饥饿节拍 < 1%。
+ 本地有 webapp/vendor/ort/(bash ml/pipeline.sh setup 会放好)就用本地 ort,不依赖 CDN(第六轮实测 jsdelivr 间歇不可达)。
+ WebGPU 与查看器两种配置用有界面窗口打开(无头模式没有真 GPU 适配器,第六轮实测 backend not found),跑完自动关闭。"""
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
+PLAN_LIMIT_MS = 45.0 # R9:整次规划 P99(60 ms 周期留 15 ms 给渲染与 tick)
+STARVE_LIMIT = 0.01 # R9:饥饿节拍占比上限
+
+
+def ort_local():
+ if os.environ.get('ORT_LOCAL') in ('0', '1'): return os.environ['ORT_LOCAL'] == '1'
+ return os.path.exists(os.path.join(ROOT, 'webapp', 'vendor', 'ort', 'ort.min.js'))
def find_browser():
@@ -64,28 +77,47 @@ def kill_tree(p):
except Exception: p.kill()
-def one(srv, browser, model_url, ep, threads, proxy, n, timeout_s):
+def open_page(srv, browser, url, timeout_s, headed=False):
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]
+ args = [browser] + ([] if headed else ['--headless=new']) + [f'--user-data-dir={prof}', '--no-first-run', '--no-default-browser-check',
+ '--disable-extensions', '--enable-unsafe-webgpu', '--disable-background-timer-throttling', '--disable-renderer-backgrounding',
+ '--window-size=1280,800', 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 内页面没有回报'}
+ return srv.result if got else {'error': f'{timeout_s} s 内页面没有回报'}
+
+
+def one(srv, browser, model_url, ep, threads, proxy, n, timeout_s):
+ q = urllib.parse.urlencode({'model': model_url, 'ep': ep, 'threads': threads, 'proxy': proxy, 'n': n, **({'ortLocal': '1'} if ort_local() else {})})
+ r = open_page(srv, browser, f'http://127.0.0.1:{srv.port}/webapp/r4_probe.html?{q}', timeout_s, headed=(ep == 'webgpu'))
r = dict(r or {}, config=f'{ep}·threads={threads}·proxy={proxy}')
print(json.dumps(r, ensure_ascii=False), flush=True)
return r
+def viewer(srv, browser, model_url, n, probe):
+ """真查看器 autotest:整次规划耗时 / 节拍 / 饥饿(R9 口径)"""
+ q = urllib.parse.urlencode({'policy': model_url, 'autotest': n, **({'probe': '1'} if probe else {}), **({'ortLocal': '1'} if ort_local() else {})})
+ r = open_page(srv, browser, f'http://127.0.0.1:{srv.port}/webapp/index.html?{q}', 120 + 12 * n, headed=True)
+ r = dict(r or {})
+ if r.get('planP99') is not None:
+ sr = (r.get('starve') or 0) / max(1, r.get('ticks') or 1)
+ r['starve_rate'] = round(sr, 4)
+ r['r9_pass'] = r['planP99'] < PLAN_LIMIT_MS and sr < STARVE_LIMIT
+ print('viewer', json.dumps({k: v for k, v in r.items() if k != 'per'}, 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)
+ ap.add_argument('--viewer', action='store_true', help='只跑查看器 autotest(R9,bench 阶段用训练好的模型)')
+ ap.add_argument('--viewer-n', type=int, default=30); ap.add_argument('--no-viewer-ref', action='store_true')
a = ap.parse_args()
+ if a.viewer: return main_viewer(a)
out = {'rule': f'部署口径 batch=8 P99 < {LIMIT_MS:g} ms', 'model': a.model, 'pass': None}
br = find_browser()
if not br:
@@ -96,8 +128,11 @@ def main():
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
+ if not a.no_viewer_ref: # 随机模型在查看器里的整次规划耗时:仅参考,不参与 R4 判定
+ out['viewer_ref'] = viewer(srv, br, mu, a.viewer_n, True)
+ out['viewer_ref_note'] = '随机权重:安全层路径不代表真模型,R9 以 bench 阶段 r9_browser.json 为准'
+ srv.httpd.shutdown()
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:
@@ -115,5 +150,22 @@ def main():
print(f"R4:{ {True: '通过', False: '未通过', None: '未判定'}[out['pass']] } 部署口径 P99 {d.get('p99')} ms({d.get('config')})")
+def main_viewer(a):
+ meta = json.load(open(a.model.replace('.onnx', '.meta.json'), encoding='utf-8'))
+ out = {'rule': f'R9:查看器整次规划 planMs P99 < {PLAN_LIMIT_MS:g} ms 且饥饿节拍 < {STARVE_LIMIT:.0%}', 'model': a.model, 'pass': None}
+ br = find_browser()
+ if not br: out['note'] = '没找到 Edge / Chrome(可设 R4_BROWSER=浏览器路径)'
+ else:
+ srv = Srv(); mu = '/' + os.path.relpath(os.path.abspath(a.model), ROOT).replace(os.sep, '/')
+ r = viewer(srv, br, mu, a.viewer_n, bool(meta.get('random_init'))); srv.httpd.shutdown()
+ out.update(browser=br, result=r)
+ out['pass'] = r.get('r9_pass')
+ if out['pass'] is None: out['note'] = r.get('error') or '页面没有给出整次规划耗时'
+ 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)
+ r = out.get('result') or {}
+ print(f"R9:{ {True: '通过', False: '未通过', None: '未判定'}[out['pass']] } planMs P99 {r.get('planP99')} ms(推理 {r.get('inferP99')} / 安全层 {r.get('safetyP99')}),饥饿 {r.get('starve_rate')}")
+
+
if __name__ == '__main__':
main()
diff --git a/ml/export_onnx.py b/ml/export_onnx.py
index 79e989d..9977abd 100644
--- a/ml/export_onnx.py
+++ b/ml/export_onnx.py
@@ -1,8 +1,10 @@
"""导出部署包(方案 §8.4):
python -m ml.export_onnx --ckpt ml/runs/current.pt --out webapp/models/traj_policy.onnx [--fp16]
-输出 traj_policy.onnx(输入 feat[1,83]、z[K,16,6];输出 act[K,16,6] 归一化增量、value[K]、sub[K,13])
+输出 traj_policy.onnx(输入 feat[1 或 K,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 行特征。
+2026-10-18 第六轮:feat 轴改为动态(1 行 = 主推理编码一次;K 行 = 前瞻逐行编码),meta.feat_dyn=true。
+ 第五轮的 enc_once 模型 feat 固定 1 行,运行时前瞻只能逐行推(ik-policy.js ortInfer 已兼容),重新导出即可。
--random 导出的是延迟探针(meta.random_init=true):查看器 / bench / 验收都会拒绝把它当策略用。"""
import argparse, json, os
import numpy as np
@@ -38,7 +40,7 @@ def main():
os.makedirs(os.path.dirname(a.out) or '.', exist_ok=True)
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={'z': {0: 'K'}, 'act': {0: 'K'}, 'value': {0: 'K'}, 'sub': {0: 'K'}},
+ dynamic_axes={'feat': {0: 'B'}, '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
@@ -54,8 +56,14 @@ def main():
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))
+ f8 = torch.randn(8, F_DIM) # 前瞻口径:8 行不同特征,须与 sample() 逐行一致
+ o8 = s.run(None, {'feat': f8.numpy(), 'z': z.numpy()})
+ with torch.no_grad(): r8 = m.sample(f8, z)
+ err_rows = max(float(np.abs(o8[i] - r8[i].numpy()).max()) for i in range(3))
+ assert err_rows < 1e-3, f'feat 为 K 行时与 sample 不一致:{err_rows}'
+ err = max(err, err_rows)
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), 'enc_once': True, '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, 'feat_dyn': True, 'eval': ck.get('eval'), 'max_abs_err_vs_torch': err}
json.dump(meta, open(a.out.replace('.onnx', '.meta.json'), 'w'), indent=1)
print(json.dumps({'onnx': a.out, 'MB': os.path.getsize(a.out) / 1e6, 'max_abs_err_vs_torch': err}))
diff --git a/ml/mining/loop.sh b/ml/mining/loop.sh
index d0f18d5..93d61fa 100755
--- a/ml/mining/loop.sh
+++ b/ml/mining/loop.sh
@@ -10,7 +10,7 @@ cd "$(dirname "$0")/../.."
PY=${PYTHON:-python}
ROUNDS=${1:-6}; STEPS=${2:-1000}; N=${3:-50000}
mkdir -p ml/runs/mine
-[ -f ml/runs/gate_tasks.jsonl ] || $PY -m ml.mining.mine_round --make-gate 2000 --seed 777 --out ml/runs
+[ -f ml/runs/gate_tasks.jsonl ] || $PY -m ml.mining.mine_round --make-gate ${MINE_GATE_N:-2000} --seed 777 --out ml/runs
[ -f ml/runs/current.pt ] || cp ml/runs/apg/best.pt ml/runs/current.pt
SAMPLES="ml/data/samples"; FIXED=""; small=0
for ((r=1; r<=ROUNDS; r++)); do
diff --git a/ml/model/policy.py b/ml/model/policy.py
index 8b97afb..f41e6f3 100644
--- a/ml/model/policy.py
+++ b/ml/model/policy.py
@@ -129,12 +129,14 @@ class Policy(nn.Module):
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%);这里只编码一次再展开给解码器。"""
+ 此前部署把同一观测复制 K=8 行喂 sample(),encoder 被重复算 8 次(约占总计算 29%);这里只编码一次再展开给解码器。
+ 2026-10-18 第六轮:feat 行数也可以等于 K(前瞻:8 个候选末态各一行特征),此时逐行编码,结果与 sample(feat, z) 一致;
+ expand 对 1→K 与 K→K 都成立,所以 ONNX 里 feat 轴是动态的(meta.feat_dyn)。"""
mem = self.encode(feat1)
v, s = self.heads(mem)
K = z.shape[0]
if self.head == 'wta':
- act, _ = self.wta_modes(mem)
+ act, _ = self.wta_modes(mem[:1])
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)
diff --git a/ml/monitor.py b/ml/monitor.py
index 0001175..17d543d 100644
--- a/ml/monitor.py
+++ b/ml/monitor.py
@@ -298,7 +298,7 @@ class Job:
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()
- e = dict(os.environ, FROM=frm, TO=to, MONITOR='0', PYTHONUNBUFFERED='1', PYTHONIOENCODING='utf-8', PIPE_OWNER='网页')
+ e = dict(os.environ, FROM=frm, TO=to, MONITOR='0', PYTHONUNBUFFERED='1', PYTHONIOENCODING='utf-8', PYTHONUTF8='1', PIPE_OWNER='网页')
py = train_python()
if ' ' not in py: e['PYTHON'] = py.replace('\\', '/')
for k, v in (env or {}).items():
diff --git a/ml/pipeline.sh b/ml/pipeline.sh
index f2fd69f..301caab 100755
--- a/ml/pipeline.sh
+++ b/ml/pipeline.sh
@@ -11,9 +11,11 @@
# apg APG 首轮微调(从选定模型开始)
# mine 挖错循环(3–6 轮,含门禁回滚)
# export 导出 ONNX(fp32)到 webapp/models/
-# bench 部署形态评测(Node,策略 + 安全层,含 R8 对抗集)
+# bench 部署形态评测(Node,策略 + 安全层,含 R8 对抗集)+ 查看器里同一批贴柱题的整次规划耗时(R9 → ml/runs/r9_browser.json)
# accept 验收汇总(方案 §9.2 各项 + R2/R7/R8/R10)→ ml/runs/acceptance.json
# all 一键无人值守 kin → accept:已完成阶段自动跳过(断了再跑一次即续跑)、阶段超时、门槛自动分支,并在后台打开训练监控
+# selftest 本机自检约 5 分钟(Windows 进程生命周期 + 断点续跑),写 ml/runs/selftest.json(bash ml/selftest.sh)
+# smoke 全流程冒烟约 40–70 分钟:在仓库旁的副本里用极小模型把 kin → accept 跑通,并出 2000 条任务的数据统计,写 ml/runs/smoke.json(bash ml/smoke.sh)
# 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
@@ -26,6 +28,9 @@
# r4 自动探针(独立文件,不碰 webapp/models)、启动前自检 torch。
set -e -o pipefail # pipefail:`node ... | tee` 里 node 失败也算失败
cd "$(dirname "$0")/.."
+# 2026-10-18 第六轮:所有 Python 子进程按 UTF-8 读写文件(Windows 默认 GBK,读 ensure_ascii=False 写出的中文 json 会 UnicodeDecodeError,
+# 第六轮本机实测 :110 即此因;逐处补 encoding 容易漏,统一用 UTF-8 模式兜住)
+export PYTHONUTF8=1 PYTHONIOENCODING=utf-8
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
@@ -74,7 +79,7 @@ trap 'exit 143' TERM INT HUP # 被停止 / 超时:先走 on_exit 记返
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
+ else run $PY -m ml.train --samples ml/data/samples --out ml/runs/bench_$tag --steps ${BENCH_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
}
@@ -82,8 +87,8 @@ 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
+ 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 ${M5_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 ${M5_N:-1000} --safety --out $o
$PY - "$tag" "$ck" <<'PY'
import json, sys
tag, ck = sys.argv[1], sys.argv[2]
@@ -101,12 +106,13 @@ sys.exit(0 if ok else 3)
PY
}
case $stage in
- setup) run $PY -m pip install -r ml/requirements.txt; (cd ml && npm install --no-audit --no-fund) ;;
+ setup) run $PY -m pip install -r ml/requirements.txt; (cd ml && npm install --no-audit --no-fund); run bash ml/prep/vendor_ort.sh ;;
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
+ bash ml/prep/vendor_ort.sh || echo "本地 ort 没放好(npm 不通?),探针改用 CDN"
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
+ run $PY -m ml.eval.r4_probe --model ml/runs/r4/traj_policy_r4.onnx --out ml/runs/r4.json ${R4_ARGS:-}
r=$($PY -c "import json;p=json.load(open('ml/runs/r4.json', encoding='utf-8')).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
@@ -139,14 +145,14 @@ case $stage in
|| { 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
+ run $PY -m ml.eval.check_r2 --samples ml/data/samples --out ml/data/r2.json ${R2_ARGS:-}
# 第五轮(审查 N-2):R2 判据与搜索池大小有关、阈值未经实测校准 → 默认只警告继续,R2_STRICT=1 才拦
if ! $PY -c "import json,sys;sys.exit(4 if json.load(open('ml/data/r2.json', encoding='utf-8')).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
+ m5) [ -f ml/runs/nc_gate.jsonl ] || run $PY -m ml.mining.mine_round --make-gate ${M5_N:-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 头再门禁一次,两种头都不过才停
@@ -154,14 +160,19 @@ case $stage in
$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; }
+ elif [ "${M5_STRICT:-1}" = 0 ]; then echo "M5:不通过,M5_STRICT=0(冒烟)→ 照常继续"; echo ml/runs/$TAG/best.pt > $SEL
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 ;;
+ run $PY -m ml.apg.apg_finetune --init $ck --samples ml/data/samples --out ml/runs/apg ${APG_FIRST_STEPS:+--steps $APG_FIRST_STEPS} $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:-} ;;
+ selftest) run bash ml/selftest.sh ;;
+ smoke) run bash ml/smoke.sh ;;
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 ;;
+ bench) run node tests/policy_bench.cjs webapp/models/traj_policy.onnx nc,route,r8 ${BENCH_ARGS:---nc1000} --out ml/runs/policy_bench.json
+ # R9(第六轮,审查 B-1/B-2):查看器里跑同一批 30 道贴柱题,整次规划 P99 < 45 ms、饥饿节拍 < 1%;结果由 accept 判定,这里不拦
+ run $PY -m ml.eval.r4_probe --viewer --model webapp/models/traj_policy.onnx --out ml/runs/r9_browser.json --viewer-n ${VIEWER_N:-30} ;;
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) # 默认 kin → accept;已完成(rc=0)的阶段自动跳过,从第一个没完成的继续。FRESH=1 从头来;FROM/TO 可只跑一段
diff --git a/ml/prep/vendor_ort.sh b/ml/prep/vendor_ort.sh
new file mode 100755
index 0000000..630662b
--- /dev/null
+++ b/ml/prep/vendor_ort.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+# 把 onnxruntime-web 1.20.1 放到 webapp/vendor/ort/(离线部署 &ortLocal=1、R4/R9 探针不依赖 CDN)。
+# 2026-10-18 第六轮(审查 A-3 / B-3):第六轮实测 jsdelivr 间歇不可达;WebGPU 需要 ort.webgpu.min.js 与 jsep 版 wasm。
+# 已存在且版本对就跳过。用法:bash ml/prep/vendor_ort.sh
+set -e
+cd "$(dirname "$0")/../.."
+V=1.20.1; D=webapp/vendor/ort
+if [ -f $D/VERSION ] && [ "$(cat $D/VERSION)" = $V ]; then echo "[ort] $D 已是 $V,跳过"; exit 0; fi
+T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
+(cd "$T" && npm pack onnxruntime-web@$V --silent >/dev/null && tar xzf onnxruntime-web-$V.tgz)
+mkdir -p $D
+for f in ort.min.js ort.webgpu.min.js ort-wasm-simd-threaded.wasm ort-wasm-simd-threaded.mjs ort-wasm-simd-threaded.jsep.wasm ort-wasm-simd-threaded.jsep.mjs; do
+ cp "$T/package/dist/$f" $D/
+done
+echo $V > $D/VERSION
+echo "[ort] 已放到 $D($(du -sh $D | cut -f1))"
diff --git a/ml/selftest.sh b/ml/selftest.sh
new file mode 100755
index 0000000..9e90000
--- /dev/null
+++ b/ml/selftest.sh
@@ -0,0 +1,70 @@
+#!/usr/bin/env bash
+# 本机自检(2026-10-18 第七轮,遗留 C-5 / C-6:Windows 生命周期与断点续跑只在沙箱验证过)。约 5–10 分钟。
+# 在仓库旁的副本(默认 ../<仓库名>_selftest)里做,不碰本仓库数据。结果写本仓库 ml/runs/selftest.json,每项 pass=true/false:
+# gen_resume 生成 6 条一口气 vs 先生成 3 条、把最后一行截掉一半、再续到 6 条 → 两份文件逐字节相同
+# prep_rerun 打包两次,第二次「待打包 0 个」
+# train_stop 训练 --stop-at 20 → rc 143、last.pt 第 20 步;续训到 40 步 rc 0
+# train_stopfile 训练中途写 ml/runs/STOP → 60 s 内 rc 143 且 last.pt 已存(控制台「停止」走的就是这条路)
+# train_term Git Bash 的 timeout 送 TERM 给训练 → 是否来得及存 last.pt(Windows 原生 python 未必收得到,结果决定控制台是否只能靠 STOP 文件)
+# apg_resume APG --stop-at 5 → rc 143;--resume 续跑 rc 0
+# tree_killed / survive_parent_exit 控制台的进程树停止、关窗口后子树存活(ml/selftest_proc.py)
+set -o pipefail
+cd "$(dirname "$0")/.."
+SRC=$(pwd); PY=${PYTHON:-python}; export PYTHONUTF8=1
+D=${SELFTEST_DIR:-$(dirname "$SRC")/$(basename "$SRC")_selftest}
+rm -rf "$D"; mkdir -p "$D"
+git ls-files -z | tar --null -T - -cf - | (cd "$D" && tar xf -)
+[ -d ml/node_modules ] && cp -r ml/node_modules "$D/ml/node_modules"
+cd "$D"; mkdir -p ml/runs ml/data
+R=ml/runs/selftest_items.jsonl; : > $R
+rec() { $PY -c "import json,sys;print(json.dumps({'item':sys.argv[1],'pass':json.loads(sys.argv[2]),'detail':sys.argv[3]},ensure_ascii=False))" "$1" "$2" "$3" >> $R; echo "[selftest] $1 → $2 $3"; }
+CFGS=ml/configs/smoke.json
+# 1 生成续写
+node ml/gen/gen_dataset.cjs --n 6 --seed 11 --out ml/data/a.jsonl 2>/dev/null
+node ml/gen/gen_dataset.cjs --n 3 --seed 11 --out ml/data/shard_0011.jsonl.part 2>/dev/null
+sz=$(wc -c < ml/data/shard_0011.jsonl.part); head -c $((sz - 40)) ml/data/shard_0011.jsonl.part > t && mv t ml/data/shard_0011.jsonl.part
+node ml/gen/gen_dataset.cjs --n 6 --seed 11 --out ml/data/shard_0011.jsonl.part 2>ml/runs/st_gen.log; mv ml/data/shard_0011.jsonl.part ml/data/shard_0011.jsonl
+cmp -s ml/data/a.jsonl ml/data/shard_0011.jsonl && rec gen_resume true "$(grep -o '续写[^,]*' ml/runs/st_gen.log | head -1)" || rec gen_resume false "续写结果与一口气生成不同"
+rm -f ml/data/a.jsonl
+node ml/gen/gen_dataset.cjs --n 40 --seed 12 --out ml/data/shard_0012.jsonl 2>/dev/null # 训练自检用的小数据
+# 2 打包二次运行
+bash ml/prep/run_prep.sh 4 >/dev/null 2>&1; bash ml/prep/run_prep.sh 4 > ml/runs/st_prep.log 2>&1
+grep -q "待打包 0 个" ml/runs/st_prep.log && rec prep_rerun true "" || rec prep_rerun false "$(head -c 200 ml/runs/st_prep.log)"
+T="--samples ml/data/samples --no-eval --config $CFGS --steps 40"
+step_of() { $PY -c "import torch;print(torch.load('$1',map_location='cpu',weights_only=False)['step'])" 2>/dev/null; }
+# 3 --stop-at
+$PY -m ml.train $T --out ml/runs/st_a --stop-at 20 > ml/runs/st_a.log 2>&1; rc=$?
+s=$(step_of ml/runs/st_a/last.pt)
+$PY -m ml.train $T --out ml/runs/st_a --resume ml/runs/st_a/last.pt >> ml/runs/st_a.log 2>&1; rc2=$?
+[ $rc = 143 ] && [ "$s" = 20 ] && [ $rc2 = 0 ] && rec train_stop true "rc 143 / 第 $s 步 / 续训 rc 0" || rec train_stop false "rc=$rc step=$s 续训rc=$rc2"
+# 4 STOP 文件
+rm -f ml/runs/STOP
+$PY -m ml.train --samples ml/data/samples --no-eval --config $CFGS --steps 1000000 --out ml/runs/st_b > ml/runs/st_b.log 2>&1 & pid=$!
+sleep 25; touch ml/runs/STOP; t0=$(date +%s)
+wait $pid; rc=$?; dt=$(( $(date +%s) - t0 )); rm -f ml/runs/STOP
+[ $rc = 143 ] && [ -f ml/runs/st_b/last.pt ] && rec train_stopfile true "写 STOP 后 ${dt}s 退出,第 $(step_of ml/runs/st_b/last.pt) 步" || rec train_stopfile false "rc=$rc ${dt}s"
+# 5 timeout TERM
+if command -v timeout >/dev/null; then
+ timeout -k 30 25 $PY -m ml.train --samples ml/data/samples --no-eval --config $CFGS --steps 1000000 --out ml/runs/st_c > ml/runs/st_c.log 2>&1; rc=$?
+ grep -q "收到停止请求" ml/runs/st_c.log && rec train_term true "rc=$rc,TERM 送达并存档" || rec train_term false "rc=$rc,TERM 没送到 python(Windows 原生进程常见)→ 阶段超时只能硬杀,进度靠每 10 分钟存档"
+else rec train_term false "没有 timeout 命令"; fi
+# 6 APG 续跑
+$PY -m ml.apg.apg_finetune --init ml/runs/st_a/last.pt --samples ml/data/samples --out ml/runs/st_apg --steps 10 --stop-at 5 > ml/runs/st_apg.log 2>&1; rc=$?
+$PY -m ml.apg.apg_finetune --init ml/runs/st_a/last.pt --samples ml/data/samples --out ml/runs/st_apg --steps 10 --resume ml/runs/st_apg/last.pt >> ml/runs/st_apg.log 2>&1; rc2=$?
+[ $rc = 143 ] && [ $rc2 = 0 ] && rec apg_resume true "" || rec apg_resume false "rc=$rc 续跑rc=$rc2(日志 $D/ml/runs/st_apg.log)"
+# 7 进程树 / 关窗口
+pj=$($PY ml/selftest_proc.py 2>ml/runs/st_proc.err | tail -1)
+echo "$pj" > ml/runs/st_proc.json
+k=$($PY -c "import json,sys;d=json.loads(sys.argv[1]);print(str(d.get('tree_killed')).lower(), str(d.get('survive_parent_exit')).lower())" "$pj" 2>/dev/null || echo "null null")
+rec tree_killed ${k% *} "$pj" ; rec survive_parent_exit ${k#* } ""
+mkdir -p "$SRC/ml/runs"
+$PY - "$R" "$SRC/ml/runs/selftest.json" "$D" <<'PY'
+import json, sys
+items = []
+for l in open(sys.argv[1], encoding='utf-8'):
+ try: items.append(json.loads(l))
+ except Exception: items.append({'item': 'parse', 'pass': False, 'detail': l.strip()[:200]})
+out = {'all_pass': all(i['pass'] is True for i in items), 'items': items, 'dir': sys.argv[3]}
+json.dump(out, open(sys.argv[2], 'w', encoding='utf-8'), indent=1, ensure_ascii=False)
+print('[selftest] 全部通过' if out['all_pass'] else '[selftest] 有未通过项,见 ' + sys.argv[2])
+PY
diff --git a/ml/selftest_proc.py b/ml/selftest_proc.py
new file mode 100644
index 0000000..0724988
--- /dev/null
+++ b/ml/selftest_proc.py
@@ -0,0 +1,71 @@
+"""本机自检的进程部分(2026-10-18 第七轮,遗留 C-5:Windows 生命周期从未实测)。由 ml/selftest.sh 调用。
+ tree :按控制台同一套标志(Windows: CREATE_NEW_PROCESS_GROUP|CREATE_NO_WINDOW;POSIX: 新会话)起 bash → python + node 两个孙进程,
+ 再用控制台同一套停止方法(Windows: taskkill /T /F;POSIX: 按会话 TERM)结束,检查孙进程是否全部消失。
+ detach :起一个"启动器"进程,它按同一标志拉起子树后立刻退出,检查子树是否还活着(等价于关掉控制台黑窗口后训练是否继续)。
+输出一行 JSON:{"tree_killed": bool, "survive_parent_exit": bool, ...}"""
+import json, os, subprocess, sys, time, tempfile, shutil
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, ROOT)
+from ml.monitor import find_bash, kill_session # noqa: E402
+
+FLAGS = {'creationflags': subprocess.CREATE_NEW_PROCESS_GROUP | 0x08000000} if os.name == 'nt' else {'start_new_session': True}
+
+
+def alive(pid):
+ if os.name == 'nt':
+ r = subprocess.run(['tasklist', '/FI', f'PID eq {pid}', '/NH'], capture_output=True, text=True)
+ return str(pid) in r.stdout
+ try: os.kill(pid, 0); return True
+ except OSError: return False
+
+
+def tree_cmd(d):
+ py = sys.executable.replace('\\', '/'); dd = d.replace('\\', '/')
+ return (f'"{py}" -c "import os,time;open(\'{dd}/p.pid\',\'w\').write(str(os.getpid()));time.sleep(300)" & '
+ f'node -e "require(\'fs\').writeFileSync(\'{dd}/n.pid\',String(process.pid));setInterval(()=>{{}},1000)" & wait')
+
+
+def wait_pids(d, t=20):
+ for _ in range(t * 4):
+ try: return int(open(os.path.join(d, 'p.pid')).read()), int(open(os.path.join(d, 'n.pid')).read())
+ except Exception: time.sleep(0.25)
+ return None, None
+
+
+def main():
+ bash = find_bash(); out = {'os': os.name, 'bash': bash}
+ if not bash: out['error'] = '找不到 bash'; print(json.dumps(out, ensure_ascii=False)); return
+ # tree
+ d = tempfile.mkdtemp(prefix='st_tree_')
+ p = subprocess.Popen([bash, '-c', tree_cmd(d)], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **FLAGS)
+ pp, pn = wait_pids(d)
+ out['tree_started'] = pp is not None and pn is not None
+ if out['tree_started']:
+ if os.name == 'nt': subprocess.run(['taskkill', '/T', '/F', '/PID', str(p.pid)], capture_output=True)
+ else: kill_session(p.pid)
+ time.sleep(3)
+ out['tree_left'] = {'python': alive(pp), 'node': alive(pn)}
+ out['tree_killed'] = not any(out['tree_left'].values())
+ for x in (pp, pn): # 清场
+ if alive(x):
+ if os.name == 'nt': subprocess.run(['taskkill', '/F', '/PID', str(x)], capture_output=True)
+ else: os.kill(x, 9)
+ shutil.rmtree(d, ignore_errors=True)
+ # detach:启动器拉起子树后立即退出
+ d = tempfile.mkdtemp(prefix='st_det_')
+ launcher = ('import subprocess,sys;' f'f={FLAGS!r};' 'subprocess.Popen([sys.argv[1],"-c",sys.argv[2]],stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,**f)')
+ subprocess.run([sys.executable, '-c', launcher, bash, tree_cmd(d)], timeout=30)
+ pp, pn = wait_pids(d)
+ time.sleep(3)
+ out['survive_parent_exit'] = bool(pp and pn and alive(pp) and alive(pn))
+ for x in (pp, pn):
+ if x and alive(x):
+ if os.name == 'nt': subprocess.run(['taskkill', '/F', '/PID', str(x)], capture_output=True)
+ else: os.kill(x, 9)
+ shutil.rmtree(d, ignore_errors=True)
+ print(json.dumps(out, ensure_ascii=False))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/ml/smoke.sh b/ml/smoke.sh
new file mode 100755
index 0000000..8894165
--- /dev/null
+++ b/ml/smoke.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+# 全流程冒烟(2026-10-18 第七轮,遗留 C-1:第 6–13 步从没跑过,第一次真跑要 15–28 h,中途才崩代价太大)。
+# 在仓库旁边建一份副本(默认 ../<仓库名>_smoke,可设 SMOKE_DIR),用极小参数把 kin → accept 整条 `all` 跑一遍:
+# 2000 条老师任务(够出真实的 keepRate / gapDrop / 间隙分位 / R2,作数据侧可行性依据)、极小模型、M5/APG/挖错各缩到几十题、bench 每集几题、查看器 3 题。
+# 训练侧只验证"每个阶段都能跑完、产物格式对、阶段之间衔接对",模型数值没有意义(门槛按冒烟放行:M5_STRICT=0 R4_STRICT=0 R2_STRICT=0);
+# 数据侧(stats / r2)数值有意义,汇总进 smoke.json。
+# 不碰本仓库的 ml/data、ml/runs、webapp/models。结果:本仓库 ml/runs/smoke.json(每阶段返回码与耗时)+ 副本里的完整日志。
+# 用法:bash ml/smoke.sh 预计 40–70 分钟(28 核 + 3090;生成 2000 条约 4 分钟,若门 1 触发共识约 20 分钟;其余阶段多数几分钟)
+set -e -o pipefail
+cd "$(dirname "$0")/.."
+SRC=$(pwd); PY=${PYTHON:-python}
+D=${SMOKE_DIR:-$(dirname "$SRC")/$(basename "$SRC")_smoke}
+echo "[smoke] 副本目录:$D"
+rm -rf "$D"; mkdir -p "$D"
+# 只拷受版本管理的文件(含未提交改动)+ 运行需要的本地依赖;数据与产物不拷
+git ls-files -z | tar --null -T - -cf - | (cd "$D" && tar xf -)
+for x in ml/node_modules webapp/vendor/ort webapp/generated; do [ -d "$x" ] && { mkdir -p "$D/$(dirname $x)"; cp -r "$x" "$D/$x"; }; done
+cd "$D"
+export N_TASKS=2000 NPROC=${SMOKE_NPROC:-$(( $(nproc) > 24 ? 24 : $(nproc) ))} TARGET_SAMPLES=1 CFG=ml/configs/smoke.json BENCH_STEPS=50 \
+ M5_N=32 M5_STRICT=0 AUTO_WTA=0 R4_STRICT=0 R2_STRICT=0 R2_ARGS="--queries 2000 --pool 15000" \
+ R4_ARGS="--viewer-n 3" APG_FIRST_STEPS=20 ROUNDS=1 APG_STEPS=20 MINE_N=128 MINE_GATE_N=64 \
+ BENCH_ARGS="--nc-n 3 --route-n 3 --r8-n 2" VIEWER_N=3 MONITOR=0 FRESH=1 PIPE_OWNER=冒烟
+t0=$(date +%s); rc=0
+bash ml/pipeline.sh all 2>&1 | tee ml/runs_smoke.log || rc=$?
+mkdir -p "$SRC/ml/runs"
+$PY - "$D" "$rc" "$(( $(date +%s) - t0 ))" "$SRC/ml/runs/smoke.json" <<'PY'
+import json, os, sys
+d, rc, sec, out = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]), sys.argv[4]
+p = os.path.join(d, 'ml', 'runs', 'pipeline.json')
+st = json.load(open(p, encoding='utf-8')) if os.path.exists(p) else {}
+stages = {k: {'rc': v.get('rc'), 'min': round((v['end'] - v['start']) / 60, 1) if v.get('end') and v.get('start') else None} for k, v in (st.get('stages') or {}).items()}
+acc = os.path.join(d, 'ml', 'runs', 'acceptance.json')
+def rd(*p):
+ try: return json.load(open(os.path.join(d, *p), encoding='utf-8'))
+ except Exception: return None
+stx = rd('ml', 'data', 'stats.json') or {}
+data = {k: stx.get(k) for k in ('tasks', 'keepRate', 'gapDrop', 'vertexMinGapMm', 'planMs', 'samplesPerTask', 'consensusDrop')}
+data['r2'] = rd('ml', 'data', 'r2.json'); data['gate1'] = rd('ml', 'runs', 'consistency.json')
+data['r4'] = rd('ml', 'runs', 'r4.json'); data['r9'] = rd('ml', 'runs', 'r9_browser.json')
+pb = rd('ml', 'runs', 'policy_bench.json') or {}
+data['r8_genS'] = (pb.get('r8_adversarial') or {}).get('genS')
+res = {'rc': rc, 'wall_s': sec, 'dir': d, 'stages': stages, 'acceptance_written': os.path.exists(acc),
+ 'data': data, 'log': os.path.join(d, 'ml', 'runs_smoke.log'),
+ 'note': '训练侧只看每阶段 rc=0 与 acceptance_written;data 段(2000 条任务的统计、R2、门 1、R4、R9 路径、R8 出题耗时)是可行性依据'}
+json.dump(res, open(out, 'w', encoding='utf-8'), indent=1, ensure_ascii=False)
+print('[smoke] rc=%d,%d 分钟;结果 %s' % (rc, sec // 60, out))
+PY
+exit $rc
diff --git a/ml/train.py b/ml/train.py
index de49725..fcfab9a 100644
--- a/ml/train.py
+++ b/ml/train.py
@@ -8,7 +8,7 @@ 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.common.ckpt import Saver, atomic_save, rng_state, set_rng_state
from ml.data import Samples, load_tasks
from ml.model.policy import build, wta_loss
from ml.model.meanflow import meanflow_loss, one_step
@@ -32,6 +32,7 @@ def main():
ap.add_argument('--samples', nargs='+', default=['ml/data/samples']); ap.add_argument('--out', default='ml/runs/base')
ap.add_argument('--config', default='ml/configs/base.json'); ap.add_argument('--steps', type=int, default=None)
ap.add_argument('--resume', default=None); ap.add_argument('--no-eval', action='store_true')
+ ap.add_argument('--stop-at', type=int, default=0, help='自检用:跑到第 N 步按停止请求处理(存 last.pt、rc 143)')
ap.add_argument('--device', default='cuda' if torch.cuda.is_available() else 'cpu'); ap.add_argument('--seed', type=int, default=0)
a = ap.parse_args()
cfg = json.load(open(a.config)); tc = cfg['train']
@@ -51,6 +52,7 @@ def main():
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, bad_evals = ck.get('step', 0), ck.get('best', -1.0), ck.get('bad_evals', 0)
+ if set_rng_state(ck.get('rng')): print('续训:随机数状态已恢复(batch 顺序与不断点一致)', flush=True)
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']))))
@@ -64,6 +66,7 @@ def main():
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, 'bad_evals': bad_evals, 'fp': kin.fp}
+ if name == 'last.pt': d['rng'] = rng_state()
if extra: d.update(extra)
atomic_save(d, os.path.join(a.out, name))
if name == 'last.pt': saver.mark()
@@ -100,7 +103,7 @@ def main():
if not torch.isfinite(loss):
print('非有限损失 → 训练发散(M5 门禁:触发 WTA 回退条件)', flush=True); save('diverged.pt', step); diverged = True; break
opt.step(); seen += X.shape[0]
- if saver.stop():
+ if saver.stop() or (a.stop_at and step + 1 >= a.stop_at):
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)
diff --git a/tests/policy_bench.cjs b/tests/policy_bench.cjs
index 29feb95..3340e82 100644
--- a/tests/policy_bench.cjs
+++ b/tests/policy_bench.cjs
@@ -7,7 +7,7 @@
* 恢复 = 首次换向锁后 3 s 内到达(无锁则按到达计);活锁 = 超时未到达且锁 ≥ 2 次
* 2026-10-18:--n 拆成 --nc-n / --route-n / --r8-n;输出 brakeRate(刹停次数 / 推理次数,验收 < 2%)。
* 判据:到达(TCP < 1 mm,pose 另需姿态 < 0.5°)且执行轨迹无干涉帧(每步端点+中点 collideFree,外加距离认证复核);
- * 超时 10 s。另报告推理 / 安全层耗时 P50/P99(Node CPU,仅作参考;浏览器延迟用查看器 ?policy=1 实测)。
+ * 超时 10 s。另报告推理 / 前瞻 / 安全层 / 整次规划耗时与距离查询数(Node CPU;浏览器同一批题由 ml.eval.r4_probe --viewer 在查看器里测,见 R9)。
* 依赖:cd ml && npm install(onnxruntime-node) */
'use strict';
const fs = require('fs'), path = require('path');
@@ -28,23 +28,8 @@ if (meta.feat_ver !== POL.FEAT_VER) { console.error('特征版本不符'); proce
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) {
- const rnd = lcg(seed);
- const rq = () => O.map(id => { const t = L[id]; return t.lo + rnd() * (t.hi - t.lo); });
- const d = (a, b) => a.map((v, i) => L[O[i]].cont ? w(b[i] - v) : b[i] - v);
- const segOK = (a, b) => { const dd = d(a, b); const m = Math.max(...dd.map(Math.abs)); const n = Math.max(1, Math.ceil(m)); 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 cases = []; let tries = 0;
- while (cases.length < N && tries < 4000000) {
- tries++;
- const b = rq(); if (!S.collideFree(b) || !nearColumn(b)) continue;
- let a = null;
- for (let t = 0; t < 200; t++) { const c = rq(); if (S.collideFree(c) && !segOK(c, b)) { a = c; break; } }
- if (a) cases.push({ q0: a, T: S.fk(b, 0), R: S.orient(b) });
- }
- return cases;
-}
+/* 贴立柱题移到 webapp/js/policy-cases.js(查看器 autotest 共用同一批题,2026-10-18 第六轮) */
+const ncCases = (N, seed) => require('../webapp/js/policy-cases.js').ncCases(S, N, seed);
function r8Cases(N, seed) {
const rnd = lcg(seed);
const rq = () => O.map(id => { const t = L[id]; return t.lo + rnd() * (t.hi - t.lo); });
@@ -77,12 +62,12 @@ 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, { encOnce: !!meta.enc_once });
+ const infer = POL.ortInfer(ort, sess, { encOnce: !!meta.enc_once, featDyn: !!meta.feat_dyn });
const cert = POL.makeCert(S, {});
const out = { model: MODEL, meta: { fp: meta.fp, eval: meta.eval } };
const runSet = async (name, cases, mode) => {
let ok = 0, reached = 0, colTasks = 0, colFrames = 0, uncert = 0, brakes = 0, locks = 0, plans = 0, recovered = 0, lockTasks = 0, livelock = 0;
- const inf = [], saf = [], times = [], fails = [];
+ const inf = [], saf = [], look = [], planT = [], cq = [], times = [], fails = []; let hits = 0; const why = {};
for (let ci = 0; ci < cases.length; ci++) {
const c = cases[ci];
const R = mode === 'pose' ? c.R : null;
@@ -101,24 +86,34 @@ const pct = (a, p) => { if (!a.length) return null; const s = a.slice().sort((x,
const { e, oe } = r.err(r.q);
const rc = r.done && e < 1 && oe < 0.5;
if (rc) reached++; if (cf) { colTasks++; colFrames += cf; } if (rc && !cf) { ok++; times.push(r.t); } else fails.push({ ci, err: +e.toFixed(1), oerr: +oe.toFixed(1), colFrames: cf, brakes: r.stats.brakes });
- inf.push(...r.stats.inferMs); saf.push(...r.stats.safetyMs); brakes += r.stats.brakes; locks += r.stats.locks; plans += r.stats.plans;
+ inf.push(...r.stats.inferMs); saf.push(...r.stats.safetyMs); look.push(...r.stats.lookaheadMs); planT.push(...r.stats.planMs); cq.push(...r.stats.certQ); hits += r.stats.certHit;
+ for (const k in r.stats.certWhy) why[k] = (why[k] || 0) + r.stats.certWhy[k]; brakes += r.stats.brakes; locks += r.stats.locks; plans += r.stats.plans;
if (tLock != null) { lockTasks++; if (rc && !cf && r.t - tLock <= 3.0) recovered++; } else if (rc && !cf) recovered++;
if (!rc && r.stats.locks >= 2) livelock++;
if ((ci + 1) % 50 === 0) process.stderr.write(`${name} ${ci + 1}/${cases.length}\n`);
}
out[name] = { n: cases.length, ok, reached, collisionTasks: colTasks, collisionFrames: colFrames, uncertifiedSegs: uncert, brakes, locks,
plans, brakeRate: plans ? +(brakes / plans).toFixed(4) : null, lockTasks, recovered3s: recovered, recoveryRate: cases.length ? +(recovered / cases.length).toFixed(4) : null, livelock,
- timeP50: pct(times, 0.5), inferMsP50: pct(inf, 0.5), inferMsP99: pct(inf, 0.99), safetyMsP50: pct(saf, 0.5), safetyMsP99: pct(saf, 0.99), fails: fails.slice(0, 15) };
+ timeP50: pct(times, 0.5), inferMsP50: pct(inf, 0.5), inferMsP99: pct(inf, 0.99), safetyMsP50: pct(saf, 0.5), safetyMsP99: pct(saf, 0.99),
+ // 2026-10-18 第六轮(审查 B-1/B-2):前瞻、整次规划耗时,每次规划的距离查询数与记忆命中率,认证失败原因——与查看器 autotest 同口径对照
+ lookaheadMsP99: pct(look, 0.99), planMsP50: pct(planT, 0.5), planMsP99: pct(planT, 0.99), certQueriesP50: pct(cq, 0.5), certQueriesP99: pct(cq, 0.99),
+ certMemoHitRate: (() => { const q = cq.reduce((a, b) => a + b, 0); return q + hits ? +(hits / (q + hits)).toFixed(3) : null; })(), certFailWhy: why, fails: fails.slice(0, 15) };
console.error(name, JSON.stringify({ ok, n: cases.length, collisionFrames: colFrames }));
};
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')) {
/* 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;
+ const want = +opt('--r8-n') || 50, tg = Date.now();
+ /* 2026-10-18 第七轮(遗留 C-4:R8 出题慢、耗时未知):出过的题按 碰撞指纹 + 题数 缓存到 ml/runs/,重跑 bench 不再重出 */
+ const cf = path.join(__dirname, `../ml/runs/r8_cases_${S.modelFingerprint()}_${want}.json`);
+ let cs = null; try { cs = JSON.parse(fs.readFileSync(cf, 'utf8')); } catch (e) {}
+ const cached = !!cs;
+ if (!cs) { cs = r8Cases(want, 8888); if (cs.length === want) try { fs.mkdirSync(path.dirname(cf), { recursive: true }); fs.writeFileSync(cf, JSON.stringify(cs)); } catch (e) {} }
+ const 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) });
+ Object.assign(out.r8_adversarial, { requested: want, genS: +genS.toFixed(1), casesCached: cached });
}
if (SETS.includes('nc1000')) await runSet('near_column_1000', ncCases(1000, 1), 'pos');
const s = JSON.stringify(out, null, 1);
diff --git a/webapp/js/ik-policy-glue.js b/webapp/js/ik-policy-glue.js
index 55f616b..482a9d2 100644
--- a/webapp/js/ik-policy-glue.js
+++ b/webapp/js/ik-policy-glue.js
@@ -6,6 +6,8 @@
* &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 导出的延迟探针模型(默认拒绝,防把随机策略当真模型跑)
+ * &autotest=N 自动跑 N 道贴立柱题(与 tests/policy_bench.cjs 同种子同题),结果 POST 到 &report=(默认 /r4/result);
+ * 由 python -m ml.eval.r4_probe --viewer 驱动,测部署形态的整次规划耗时与节拍(方案 R9)
* 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 与后台节流影响),
@@ -18,7 +20,10 @@
const qs = new URLSearchParams(location.search);
if (!qs.has('policy')) return;
const MODEL = qs.get('policy') && qs.get('policy') !== '1' ? qs.get('policy') : 'models/traj_policy.onnx';
-const ORT_BASE = qs.get('ortLocal') === '1' ? 'vendor/ort/' : 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.20.1/dist/';
+/* 2026-10-18 第六轮(审查 A-3):本地路径必须是绝对 URL——ORT 用 import() 加载 .mjs,相对前缀 'vendor/ort/' 会被当成裸模块名而失败 */
+const ORT_BASE = qs.get('ortLocal') === '1' ? new URL('vendor/ort/', location.href).href : 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.20.1/dist/';
+/* WebGPU 后端只在 ort.webgpu.min.js 里(ort.min.js 只有 wasm,第六轮实测 "[webgpu] backend not found" 即此因) */
+const ORT_JS = qs.get('ep') === 'webgpu' ? 'ort.webgpu.min.js' : 'ort.min.js';
const badge = () => document.getElementById('ikBadge');
const say = (t, cls) => { const b = badge(); if (b) { b.textContent = t; b.className = 'badge ' + (cls || 'wait'); } console.log('[policy]', t); };
let ready = false, runner = null, running = false, worker = null;
@@ -37,7 +42,7 @@ async function boot() {
if (meta.tcp0 && meta.tcp0.some((v, i) => Math.abs(v - S.TCP0[i]) > 0.01)) throw new Error('TCP 与训练时不同:' + S.TCP0 + ' vs ' + meta.tcp0);
const probe = !!meta.random_init;
if (probe && qs.get('probe') !== '1') throw new Error('这是随机初始化的延迟探针模型(meta.random_init),不是训练产物;仅测延迟时加 &probe=1');
- if (!window.ort) await loadScript(ORT_BASE + 'ort.min.js');
+ if (!window.ort) await loadScript(ORT_BASE + ORT_JS);
ort.env.wasm.wasmPaths = ORT_BASE;
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))));
@@ -49,7 +54,7 @@ async function boot() {
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 });
+ const infer = ArmPolicy.ortInfer(ort, sess, { encOnce: !!meta.enc_once, featDyn: !!meta.feat_dyn });
runner = new ArmPolicy.Runner(S, infer, {});
/* 预热:首次推理有编译开销 */
await infer(new Float32Array(8 * ArmPolicy.F_DIM), new Float32Array(8 * ArmPolicy.H * 6), 8);
@@ -65,18 +70,55 @@ 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], proxy, encOnce: !!meta.enc_once, probe }; }
+ const st = runner.stats;
+ return { inferP50: p(st.inferMs, .5), inferP99: p(st.inferMs, .99), safetyP50: p(st.safetyMs, .5), safetyP99: p(st.safetyMs, .99),
+ lookaheadP99: p(st.lookaheadMs, .99), planP50: p(st.planMs, .5), planP99: p(st.planMs, .99), certQP99: p(st.certQ, .99),
+ tickGapP99: p(TICKS.gaps, .99), tickGapMax: TICKS.gaps.length ? Math.max(...TICKS.gaps) : null, starve: TICKS.starve, ticks: TICKS.n,
+ threads: ort.env.wasm.numThreads, ep: ep[0], proxy, encOnce: !!meta.enc_once, featDyn: !!meta.feat_dyn, probe }; }
};
ready = true;
const how = ep[0] + ',' + ort.env.wasm.numThreads + ' 线程' + (proxy ? ',推理在 Worker' : ',推理在主线程');
if (probe) say('随机探针模型(仅测延迟,动作无意义):' + how, 'badb');
else say('策略模式就绪(' + how + '):拖动目标即由策略执行', 'ok');
+ if (qs.get('autotest')) autotest(+qs.get('autotest') || 30, meta);
} catch (e) { say('策略模式不可用:' + e.message, 'badb'); console.error(e); }
}
+/* 节拍统计(R9):tick 间隔、饥饿次数(执行时钟到了但队列为空、又没到达 = 实际丢步) */
+const TICKS = { gaps: [], last: 0, starve: 0, n: 0 };
+/* autotest:同一批贴立柱题(PolicyCases.ncCases,与 Node bench 同种子 20261017)逐题跑,每题限时 10 s,结果 POST 回驱动方 */
+async function autotest(N, meta) {
+ const S = window.IK.solver;
+ if (!window.PolicyCases) await loadScript(new URL('js/policy-cases.js', location.href).href);
+ const cases = PolicyCases.ncCases(S, N, 20261017), per = [];
+ const t0 = performance.now();
+ for (let i = 0; i < cases.length; i++) {
+ const c = cases[i]; stop(); await new Promise(r => setTimeout(r, 50));
+ window.IK.setJoints(c.q0, false); runner.reset(c.q0); runner.setGoal(c.T, null);
+ const s0 = { plans: runner.stats.plans, brakes: runner.stats.brakes }, ts = performance.now(); let col = 0;
+ TICKS.last = 0; start();
+ while (running && performance.now() - ts < 10000) {
+ await new Promise(r => setTimeout(r, 100));
+ if (runner.q && !S.collideFree(runner.q)) col++;
+ }
+ stop();
+ const { e } = runner.err(runner.q);
+ per.push({ i, reached: runner.done && e < 1, err: +e.toFixed(1), colSamples: col, plans: runner.stats.plans - s0.plans, brakes: runner.stats.brakes - s0.brakes, s: +((performance.now() - ts) / 1000).toFixed(2) });
+ }
+ const lat = window.__POLICY.latency();
+ const res = { kind: 'viewer_autotest', model: MODEL, probe: !!meta.random_init, n: cases.length, reached: per.filter(x => x.reached).length,
+ collisionTasks: per.filter(x => x.colSamples).length, brakes: runner.stats.brakes, plans: runner.stats.plans, certMemoHits: runner.stats.certHit,
+ certFailWhy: runner.stats.certWhy, wallS: +((performance.now() - t0) / 1000).toFixed(1), coi: self.crossOriginIsolated, ua: navigator.userAgent, ...lat, per };
+ say('autotest 完成:到达 ' + res.reached + '/' + res.n + ',整次规划 P99 ' + res.planP99 + ' ms', 'ok');
+ try { await fetch(qs.get('report') || '/r4/result', { method: 'POST', body: JSON.stringify(res) }); } catch (e) { console.warn(e); }
+ window.__POLICY.autotest = res;
+}
function start() { if (running) return; running = true; worker.postMessage(ArmPolicy.DT * 1000); }
function stop() { running = false; if (worker) worker.postMessage(0); }
function onTick() {
if (!running) return;
+ const tn = performance.now(); if (TICKS.last) TICKS.gaps.push(tn - TICKS.last); TICKS.last = tn; TICKS.n++;
+ if (TICKS.gaps.length > 20000) TICKS.gaps.splice(0, 10000);
+ if (!runner.queue.length && !runner.done && runner.goal) TICKS.starve++;
if (runner.needPlan()) runner.plan().catch(e => { console.error(e); stop(); say('策略推理出错:' + e.message, 'badb'); });
const q = runner.tick();
if (q) window.IK.setJoints(q, false);
diff --git a/webapp/js/ik-policy.js b/webapp/js/ik-policy.js
index 3a22b80..785f782 100644
--- a/webapp/js/ik-policy.js
+++ b/webapp/js/ik-policy.js
@@ -59,12 +59,14 @@ function makeCert(S, opt) {
const L = S.LIM, O = S.ORDER;
const memo = new Map();
const key = q => q.map(v => v.toFixed(5)).join(',');
- let queries = 0, budgetLeft = Infinity;
- const gm = q => { const k = key(q); let M = memo.get(k); if (!M) { M = S.gapMatrix(q, CAP); queries++; budgetLeft--; if (memo.size > 50000) memo.clear(); memo.set(k, M); } return M; };
+ let queries = 0, hits = 0, budgetLeft = Infinity, deadline = Infinity;
+ const clock = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
+ const gm = q => { const k = key(q); let M = memo.get(k); if (!M) { M = S.gapMatrix(q, CAP); queries++; budgetLeft--; if (memo.size > 50000) memo.clear(); memo.set(k, M); } else hits++; return M; };
const minGap = M => { let m = Infinity; for (let a = 0; a < 7; a++) for (let b = a + 2; b < 7; b++) m = Math.min(m, M[a * 7 + b]); return m; };
const inBox = q => O.every((id, i) => L[id].cont || (q[i] >= L[id].lo - 1e-6 && q[i] <= L[id].hi + 1e-6));
function seg(a, b, depth) {
if (budgetLeft <= 0) return false;
+ if (deadline !== Infinity && clock() > deadline) { budgetLeft = 0; return false; } // 超时与超预算同样判不过(→ 刹停,保守)
const Ma = gm(a), Mb = gm(b);
const dq = a.map((v, i) => Math.abs(b[i] - v));
let ok = true;
@@ -79,19 +81,22 @@ function makeCert(S, opt) {
const m = a.map((v, i) => 0.5 * (v + b[i]));
return seg(a, m, depth + 1) && seg(m, b, depth + 1);
}
- /* 折线(相邻点按短弧展开)→ {ok, queries, minGap, failAt, why};budget = 本次最多新增距离查询数(超出判不过,why='budget') */
- function path(pts, budget) {
+ /* 折线(相邻点按短弧展开)→ {ok, queries, minGap, failAt, why};budget = 本次最多新增距离查询数(超出判不过,why='budget')
+ 2026-10-18 第六轮(审查 B-2):可选 until = 墙钟截止时刻(performance.now 口径),到点即判不过。
+ 此前 Runner 只在两条候选之间看 30 ms 预算,单条候选内部 40 次查询 × 8 条可把安全层拖到 250 ms。 */
+ function path(pts, budget, until) {
const q0 = queries; let mg = Infinity;
- budgetLeft = budget || Infinity;
+ budgetLeft = budget || Infinity; deadline = until || Infinity;
const P = [pts[0].slice()];
for (let i = 1; i < pts.length; i++) P.push(P[i - 1].map((v, k) => L[O[k]].cont ? v + wrap180(pts[i][k] - v) : pts[i][k]));
for (let i = 0; i < P.length; i++) { if (!inBox(P[i])) return { ok: false, queries: queries - q0, minGap: mg, failAt: i, why: 'box' }; }
for (let i = 0; i < P.length; i++) mg = Math.min(mg, minGap(gm(P[i])));
- for (let i = 1; i < P.length; i++) if (!seg(P[i - 1], P[i], 0)) return { ok: false, queries: queries - q0, minGap: mg, failAt: i, why: budgetLeft <= 0 ? 'budget' : 'cert' };
- budgetLeft = Infinity;
+ for (let i = 1; i < P.length; i++) if (!seg(P[i - 1], P[i], 0)) { const why = deadline !== Infinity && clock() > deadline ? 'time' : budgetLeft <= 0 ? 'budget' : 'cert'; budgetLeft = Infinity; deadline = Infinity; return { ok: false, queries: queries - q0, minGap: mg, failAt: i, why }; }
+ budgetLeft = Infinity; deadline = Infinity;
return { ok: true, queries: queries - q0, minGap: mg, unwrapped: P };
}
- return { seg: (a, b) => { budgetLeft = Infinity; return seg(a, b, 0); }, path, leverTable: TL, gapMin: q => minGap(gm(q)), gm, clear: () => memo.clear() };
+ return { seg: (a, b) => { budgetLeft = Infinity; deadline = Infinity; return seg(a, b, 0); }, path, leverTable: TL, gapMin: q => minGap(gm(q)), gm, clear: () => memo.clear(),
+ counters: () => ({ queries, hits }) };
}
/* ---------------- 刹停段:从 q 以每步增量 v 起,按 ACC 均匀减速到 0 ---------------- */
@@ -174,10 +179,12 @@ class Runner {
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(超出按刹停处理)
+ certBudget: 40, safetyMs: 30, firstMs: 45 }, opts || {}); // 每条候选最多 40 次距离查询;安全层总预算 30 ms,第 1 条候选可用到 45 ms(超出按刹停处理)
this.cert = makeCert(S, { leverTable: this.o.leverTable, cap: 20 });
this.rng = makeRng(this.o.seed);
- this.stats = { plans: 0, inferMs: [], safetyMs: [], brakes: 0, refine: 0, lookahead: 0, locks: 0, projected: 0 };
+ /* 2026-10-18 第六轮(审查 B-1/B-2):耗时拆开记——inferMs 主推理、lookaheadMs 前瞻(第二次推理 + 8 份特征)、safetyMs 只含认证、
+ planMs 整次规划(R9 口径:必须 < 60 ms 周期);certQ 每次规划新增的距离查询数,certHit 记忆命中数,certWhy 认证失败原因计数 */
+ this.stats = { plans: 0, inferMs: [], lookaheadMs: [], safetyMs: [], planMs: [], certQ: [], certHit: 0, certWhy: {}, brakes: 0, refine: 0, lookahead: 0, locks: 0, projected: 0 };
this.reset(null);
}
reset(q0) {
@@ -224,13 +231,19 @@ class Runner {
const tail = brakeSteps(steps.length ? steps[steps.length - 1] : [0, 0, 0, 0, 0, 0]);
/* 刹停段各步同向等比减速 → 关节空间里是一条直线,认证终点一段即可 */
if (tail.length) { for (const d of tail) q = q.map((v, j) => v + d[j]); pts.push(q.slice()); }
- const c = this.cert.path(pts, this.o.certBudget);
+ const c = this.cert.path(pts, this.o.certBudget, this._until);
+ if (!c.ok) this.stats.certWhy[c.why || 'collide'] = (this.stats.certWhy[c.why || 'collide'] || 0) + 1;
return { ok: c.ok, tail };
}
async plan() {
if (!this.needPlan()) return;
this.planning = true;
- try { await this._plan(); } finally { this.planning = false; }
+ const t = now(), c0 = this.cert.counters();
+ try { await this._plan(); } finally {
+ this.planning = false; this._until = undefined;
+ const c1 = this.cert.counters();
+ this.stats.planMs.push(now() - t); this.stats.certQ.push(c1.queries - c0.queries); this.stats.certHit += c1.hits - c0.hits;
+ }
}
async _plan() {
const S = this.S, o = this.o, K = o.K;
@@ -258,9 +271,9 @@ class Runner {
for (let k = 0; k < K; k++) feat.set(f, k * F_DIM);
for (let i = 0; i < z.length; i++) z[i] = this.rng.n();
const t0 = now();
- const out = await this.infer(feat, z, K);
+ const out = await this.infer(feat, z, K, true); // true:K 行特征相同,enc_once 模型只送 1 行
this.stats.inferMs.push(now() - t0);
- const t1 = now();
+ let t1 = now();
/* 子目标:新目标/跳变后取价值最好的那条输出 */
let best = 0; for (let k = 1; k < K; k++) if (out.value[k] < out.value[best]) best = k;
if (this.needSub) {
@@ -303,8 +316,10 @@ class Runner {
}
let pool = cands;
if (this.lock && this.t < this.lock.until) { const f2 = cands.filter(c => Math.abs(c.j1) < 2 || Math.sign(c.j1) === this.lock.side); if (f2.length) pool = f2; }
- /* 短前瞻:冷启动 / 卡死时,各候选块末态再推一块,用其价值打分 */
- if (o.lookahead && (this.cold || stuck)) {
+ /* 短前瞻:冷启动(新目标 / 目标跳变)时,各候选块末态再推一块,用其价值打分。
+ 2026-10-18 第六轮:去掉"卡死时也前瞻"——与仿真 ml/sim/closed_loop.py(只在首块前瞻)对齐;
+ 前瞻要算 8 份特征(8 次 gapMatrix cap40,沙箱 Node 实测 P99 126 ms),卡死时再做会让整次规划连续超周期。卡死由换向锁处理。 */
+ if (o.lookahead && this.cold) {
this.stats.lookahead++;
const feat2 = new Float32Array(pool.length * F_DIM), z2 = new Float32Array(pool.length * H * 6);
pool.forEach((c, i) => {
@@ -312,17 +327,24 @@ class Runner {
feat2.set(features(S, { q: qa, qPrev: qb, T: this.goal.T, R: this.goal.R, prefix: [], sub: null }), i * F_DIM);
});
for (let i = 0; i < z2.length; i++) z2[i] = this.rng.n();
- const o2 = await this.infer(feat2, z2, pool.length);
+ const o2 = await this.infer(feat2, z2, pool.length, false); // 各行特征不同:必须逐行编码(第六轮修:此前 enc_once 下只送了第 1 行,前瞻价值全错)
pool.forEach((c, i) => { c.score = -2.0 * o2.value[i] - 0.5 * c.value; });
+ this.stats.lookaheadMs.push(now() - t1); t1 = now();
}
this.cold = false;
pool.sort((a, b) => b.score - a.score);
/* 安全层:依次认证,第一个通过即提交 */
+ /* 预算(第六轮,审查 B-2):浏览器实测安全层 P99 249 ms。拆开量(沙箱 Node,每次调用):gapMatrix cap20 3.6 ms / cap40 5.5 ms、
+ collideFree 1.5 ms、criticalPairs 3.1 ms,推理本身 1.5 ms——耗时在几何查询,不在模型。此前截止只在候选之间检查,
+ 单条候选最多 40 次查询(≈150 ms)不受限。现在墙钟截止下沉到认证内部:第 1 条候选截止 t1+firstMs,其余共享到 t1+safetyMs;
+ 到点判不过 → 沿上次已认证的刹停段停,安全性不变,只是可能多刹停。 */
let picked = null;
for (const c of pool) {
- if (now() - t1 > o.safetyMs && picked === null && c !== pool[0]) break;
+ if (c !== pool[0] && now() - t1 > o.safetyMs) break;
+ this._until = t1 + (c === pool[0] ? Math.max(o.firstMs, o.safetyMs) : o.safetyMs);
const v = this._certCommit(qe, c.steps); if (v.ok) { picked = c; this.brakeTail = v.tail; break; }
}
+ this._until = undefined;
this.stats.safetyMs.push(now() - t1);
if (picked) {
this.queue.push(...picked.steps); this.lastPlan = [qe.slice()].concat(picked.fut); this.brakeRun = 0;
@@ -338,12 +360,22 @@ const now = () => (typeof performance !== 'undefined' ? performance.now() : Date
/* ---------------- onnxruntime 适配(web: ort = window.ort;node: require('onnxruntime-node')) ---------------- */
/* 2026-10-18 第五轮:模型 meta.enc_once=true(编码一次、解码 K 次)时只送第 1 行特征 [1,F];
Runner 仍按 K 行组织特征(K 行本就相同),旧模型(enc_once 缺省)照旧送 [B,F]。 */
+/* 2026-10-18 第六轮:第 4 个参数 shared 表示 K 行特征相同(主推理)。enc_once 模型的 feat 轴是动态的(1 或 K 行):
+ shared → 送 1 行(编码一次);前瞻各行不同 → 送 B 行逐行编码。 */
function ortInfer(ort, session, opt) {
- const encOnce = !!(opt && opt.encOnce);
- return async (feat, z, B) => {
- 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 };
+ const encOnce = !!(opt && opt.encOnce), featDyn = !!(opt && opt.featDyn); // featDyn:meta.feat_dyn(第六轮导出),feat 轴可为 1 或 K
+ const run = async (ft, zz, B) => { const r = await session.run({ feat: ft, z: new ort.Tensor('float32', zz, [B, H, 6]) }); return { act: r.act.data, value: r.value.data, sub: r.sub.data }; };
+ return async (feat, z, B, shared) => {
+ if (!encOnce) return run(new ort.Tensor('float32', feat.slice(0, B * F_DIM), [B, F_DIM]), z, B);
+ if (shared !== false) return run(new ort.Tensor('float32', feat.slice(0, F_DIM), [1, F_DIM]), z, B);
+ if (featDyn) return run(new ort.Tensor('float32', feat.slice(0, B * F_DIM), [B, F_DIM]), z, B);
+ /* 第五轮导出的 enc_once 模型 feat 轴固定为 1:前瞻逐行各推一次(正确但慢,重新导出即可) */
+ const act = new Float32Array(B * H * 6), value = new Float32Array(B), sub = new Float32Array(B * 13);
+ for (let i = 0; i < B; i++) {
+ const r = await run(new ort.Tensor('float32', feat.slice(i * F_DIM, (i + 1) * F_DIM), [1, F_DIM]), z.slice(i * H * 6, (i + 1) * H * 6), 1);
+ act.set(r.act, i * H * 6); value[i] = r.value[0]; sub.set(r.sub, i * 13);
+ }
+ return { act, value, sub };
};
}
diff --git a/webapp/js/policy-cases.js b/webapp/js/policy-cases.js
new file mode 100644
index 0000000..e024ada
--- /dev/null
+++ b/webapp/js/policy-cases.js
@@ -0,0 +1,32 @@
+/* ============================================================================
+ * policy-cases.js —— 评测题目生成(Node 与浏览器共用,UMD)
+ * 2026-10-18 第六轮(审查 B-2):贴立柱题从 tests/policy_bench.cjs 移到这里,查看器 ?policy=…&autotest=N
+ * 与 Node bench 用同一个种子出同一批题,安全层耗时两边可以逐题对照(浏览器 vs Node 差 > 3× 要解释)。
+ * 算法与原 bench 逐字一致:同种子出的题与第五轮之前相同。
+ * ==========================================================================*/
+(function (root, factory) {
+ if (typeof module === 'object' && module.exports) module.exports = factory();
+ else root.PolicyCases = factory();
+}(typeof self !== 'undefined' ? self : this, function () {
+'use strict';
+function lcg(seed) { let s = seed >>> 0; return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; }
+/* 贴立柱任务:终点贴立柱(±4° 扰动即碰立柱)、起点到终点直线被挡;返回 [{q0, T, R}] */
+function ncCases(S, N, seed) {
+ const L = S.LIM, O = S.ORDER, w = S.wrap180, rnd = lcg(seed);
+ const rq = () => O.map(id => { const t = L[id]; return t.lo + rnd() * (t.hi - t.lo); });
+ const d = (a, b) => a.map((v, i) => L[O[i]].cont ? w(b[i] - v) : b[i] - v);
+ const segOK = (a, b) => { const dd = d(a, b); const m = Math.max(...dd.map(Math.abs)); const n = Math.max(1, Math.ceil(m)); 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 cases = []; let tries = 0;
+ while (cases.length < N && tries < 4000000) {
+ tries++;
+ const b = rq(); if (!S.collideFree(b) || !nearColumn(b)) continue;
+ let a = null;
+ for (let t = 0; t < 200; t++) { const c = rq(); if (S.collideFree(c) && !segOK(c, b)) { a = c; break; } }
+ if (a) cases.push({ q0: a, T: S.fk(b, 0), R: S.orient(b) });
+ }
+ return cases;
+}
+return { lcg, ncCases };
+}));
diff --git a/webapp/models/README.md b/webapp/models/README.md
index f476dca..8baea74 100644
--- a/webapp/models/README.md
+++ b/webapp/models/README.md
@@ -1,5 +1,6 @@
-策略模型放这里(由 `python -m ml.export_onnx --fp16` 生成):
-- `traj_policy.onnx`
-- `traj_policy.meta.json`(碰撞模型指纹 / 特征版本 / TCP,查看器加载时校验)
+策略模型放这里(由 `bash ml/pipeline.sh export`,即 `python -m ml.export_onnx --ckpt ml/runs/current.pt` 生成,默认 fp32):
+- `traj_policy.onnx`:输入 `feat[1 或 K,83]`、`z[K,16,6]`;输出 `act[K,16,6]`、`value[K]`、`sub[K,13]`
+- `traj_policy.meta.json`:碰撞模型指纹、特征版本、TCP、`enc_once` / `feat_dyn`。查看器加载时会校验,`random_init` 为真的延迟探针模型会被拒绝
-查看器地址加 `?policy=1` 启用策略模式,详见 `ml/README.md` 第 8 步。
+查看器地址加 `?policy=1` 启用策略模式(离线加 `&ortLocal=1`,先跑 `bash ml/prep/vendor_ort.sh`),详见 `ml/README.md` 第 10 步。
+两个文件都不入库(`.gitignore`)。
diff --git a/webapp/r4_probe.html b/webapp/r4_probe.html
index 3b1342c..22e39be 100644
--- a/webapp/r4_probe.html
+++ b/webapp/r4_probe.html
@@ -12,8 +12,14 @@
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); });
+ /* 2026-10-18 第六轮:本地前缀用绝对 URL(ORT 以 import() 加载 .mjs,相对前缀会被当成裸模块名);WebGPU 后端只在 ort.webgpu.min.js 里 */
+ const BASE = qs.get('ortLocal') === '1' ? new URL('/webapp/vendor/ort/', location.href).href : 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.20.1/dist/';
+ res.ortBase = BASE;
+ if (res.ep === 'webgpu') { // 先看浏览器给不给适配器,区分"没有 GPU"与"ort 包不含 WebGPU"
+ res.hasGpu = !!navigator.gpu;
+ try { const ad = navigator.gpu && await navigator.gpu.requestAdapter(); res.adapter = ad ? ((ad.info && (ad.info.vendor + ' ' + ad.info.architecture)) || 'ok') : null; } catch (e) { res.adapter = 'error: ' + e; }
+ }
+ await new Promise((ok, no) => { const s = document.createElement('script'); s.src = BASE + (res.ep === 'webgpu' ? 'ort.webgpu.min.js' : '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 同一套默认值