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 同一套默认值