ml: 按运行手册审查修订——头分目录不互相覆盖(ml/runs/<CFG>、selected_ckpt/CKPT);统一 $PYTHON(Windows Git Bash 可跑);gen 按实测样本/任务自动补量;R10 改终态判据(≥5k 样本/s≈60k 步≤3.5h);m5 纯策略/仿真安全层双口径;无老师题定长 6 s 口径;APG 展开随批时长、端点+中点铰链、余量 3 mm(投影同步);挖错池只用本轮;门禁加 R7;bench 加 R8 对抗集与刹停率、--nc-n/--route-n/--r8-n;新增 check_r2 与 acceptance;export 默认 fp32;出题分块防 OOM;val 为空保护

This commit is contained in:
Hark
2026-10-18 06:40:24 +08:00
committed by cbb
parent 8800a68cad
commit f8cdabb781
18 changed files with 493 additions and 156 deletions
+33 -18
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="generator" content="pandoc" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" />
<title>Transformer 避障运动策略:最终方案</title>
<title>Transformer 轨迹拟合训练方案</title>
<style>
html {
color: #1a1a1a;
@@ -252,7 +252,7 @@
</head>
<body>
<header id="title-block-header">
<h1 class="title">Transformer 避障运动策略:最终方案</h1>
<h1 class="title">Transformer 轨迹拟合训练方案</h1>
</header>
<nav id="TOC" role="doc-toc">
<ul>
@@ -613,8 +613,9 @@ K=8,二者共用骨干,切换约 1 天</td>
<tr class="odd">
<td>训练余量</td>
<td>越大越不易被认证拒绝</td>
<td>越大行程越长、贴柱越难</td>
<td><strong>5 mm</strong></td>
<td>越大行程越长、贴柱越难;高于老师轨迹自身间隙(实测最小间隙 p50 4.4 /
p90 5.9 mm)会与 BC 标签对抗</td>
<td><strong>3 mm</strong>(≈ 老师 p10),APG 碰撞项与运行时投影共用</td>
</tr>
<tr class="even">
<td>关节噪声增广 σ</td>
@@ -1230,18 +1231,19 @@ s),统计到达率、用时 ≤ 老师 × 1.3
<h3 id="71-主力apg-可微微调">7.1 主力:APG 可微微调</h3>
<pre><code>for 每个 batch(随机采样 q0 和目标,不需要老师):
q = q0
for t in 0..H-1 (H = 60 次推理 × 3 步 = 180 步 ≈ 3.6 s;长任务分段截断反传):
for t in 0..H-1 (H = 本批老师时长 p90 × 1.2,60–150 次推理 × 3 步 ≈ 3.6–9 s;无老师题按 6 s;每 10 次推理截断反传):
chunk = π(obs(q, 目标, 前缀), z) # 每条展开固定一个噪声 z
Δq = chunk[0:3] # 与部署一致
q = clamp(q + Δq)
累计 L_col(q), L_smooth(Δq)
L = w_tgt·L_target(q_H) + w_col·ΣL_col + w_smooth·ΣL_smooth (+ w_len·行程) + w_bc·L_BC
L = w_tgt·Σ_段末 L_target(q) + w_col·ΣL_col + w_smooth·ΣL_smooth (+ w_len·行程) + w_bc·L_BC
反传,梯度裁剪 ≤ 1.0,lr 1e-4</code></pre>
<ul>
<li>碰撞项 L_col = Σ ReLU(margin − 胶囊距离),margin =
<code>margin_mm</code> + 训练余量 5
mm(覆盖安全层认证的保守量);含臂×立柱、臂×台架、臂×臂(非相邻段)。</li>
<li>到达项:位置误差² + λ_rot·‖R − R*‖_F²。</li>
<li>碰撞项 L_col = Σ ReLU(margin − 胶囊距离),margin = 训练余量 3
mm(间隙已扣除 <code>margin_mm</code>);每步端点 +
中点;含臂×立柱、臂×台架、臂×臂(非相邻段)。</li>
<li>到达项:每个截断段末的位置误差² + λ_rot·‖R −
R*‖_F²;不做到达即停,段末项在整条展开上持续给出接近梯度。</li>
<li>平滑项:速度² + 加速度²。</li>
<li>BC 锚定项:保留预训练损失,防止多模态被压缩;每轮监控左右比例。</li>
<li>采样分布同 §4.3,B/C/D 类提到 60%,其中贴柱类 40%。</li>
@@ -1311,8 +1313,9 @@ ms,否则放 Web Worker 与推理流水线并行。</li>
</thead>
<tbody>
<tr class="odd">
<td><code>traj_policy.onnx</code>(fp16,约 9.8 MB)+
<code>traj_policy.meta.json</code>(指纹 / 特征版本 / TCP)</td>
<td><code>traj_policy.onnx</code>(fp32,约 20 MB;wasm / CPU 后端上
fp16 不保证更快)+ <code>traj_policy.meta.json</code>(指纹 / 特征版本 /
TCP)</td>
<td>PRM 路网、IK 候选大表</td>
</tr>
<tr class="even">
@@ -1464,6 +1467,11 @@ rt)</td>
</table>
<p>说明:30 例全对时,失败率的 95% 置信上界约 9.5%;扩展集 1000 例 0
失败时约 0.3%,因此以扩展集作为 30/30 的统计支撑。</p>
<p>口径:本表全部按部署形态(<code>tests/policy_bench.cjs</code>,与浏览器同一份运行时)或
held-out 闭环仿真(开仿真安全层)测量,由
<code>ml/eval/acceptance.py</code> 汇总;M5
门禁与挖错用的是关安全层的纯策略口径,二者互不替代。挖错题与门禁集没有老师参考,到达按定长
6 s 判,「到达用时」「关节行程」两项只在有老师参考的任务上统计。</p>
<hr />
<h2 id="10-当前存在的问题与风险">10. 当前存在的问题与风险</h2>
<h3 id="101-只能靠实测消除的风险按里程碑顺序">10.1
@@ -1489,7 +1497,9 @@ rt)</td>
<tr class="even">
<td>R2</td>
<td>时间律仍有不可观测成分,闭环起不了步</td>
<td>近邻样本标签方差 &lt; 10% 步长;起步失败率 = 0</td>
<td>近邻样本标签方差 &lt; 10%
步长(<code>ml/eval/check_r2.py</code>);起步失败率 = 0(闭环
<code>start_fail</code>)</td>
<td>进一步把剩余路程估计加入输入</td>
<td>M1 / M5</td>
</tr>
@@ -1497,7 +1507,7 @@ rt)</td>
<td>R3</td>
<td>距离认证过于保守,贴柱处拒绝率高,影响 30/30</td>
<td>near_column 上认证拒绝率统计;离线 10⁵ 段 0.01° 审计漏检 = 0</td>
<td>训练余量提高到 3–6 mm,接受行程变长;收紧杠杆界(按位形分区)</td>
<td>训练余量提高到 4–6 mm,接受行程变长;收紧杠杆界(按位形分区)</td>
<td>M2 / M7</td>
</tr>
<tr class="even">
@@ -1524,14 +1534,17 @@ rt)</td>
<tr class="odd">
<td>R7</td>
<td>APG 压缩多模态或拉长路径(ELMP 中路径 +23%)</td>
<td>每轮左右比例与行程门禁</td>
<td>每轮门禁:B/C 类左右绕行比例变化 ≤ 5 个百分点、行程中位数比 ≤
1.05(<code>ml/mining/gate.py</code>)</td>
<td>加大 BC 锚定权重;行程项</td>
<td>M6</td>
</tr>
<tr class="even">
<td>R8</td>
<td>卡死换向活锁</td>
<td>50 个"两侧都要绕远"对抗用例 3 s 内恢复 ≥ 95%,活锁 = 0</td>
<td>50 个"两侧都要绕远"对抗用例(J1 起终差 ≥
140°、终点贴立柱,部署运行时)首次换向锁后 3 s 内到达 ≥ 95%,活锁 =
0(<code>tests/policy_bench.cjs</code> r8)</td>
<td>子目标改为离散绕行锚点分类</td>
<td>M6 / M7</td>
</tr>
@@ -1545,8 +1558,10 @@ rt)</td>
<tr class="even">
<td>R10</td>
<td>训练耗时估计偏差</td>
<td>2000 步吞吐 ≥ 2 万样本/s</td>
<td>降 epoch 或增大 batch</td>
<td>60000 步 ≤ 3.5 h,即 2000 步测速 ≥ 5000 样本/s(3090 实测 6.2 k/s ≈
2.8 h)</td>
<td>FK 损失胶囊数 96 → 48;仍不够则步数降到 40000(MeanFlow JVP
受算力限制,增大 batch 无效)</td>
<td>M5</td>
</tr>
</tbody>
+13 -11
View File
@@ -55,7 +55,7 @@
| 挖错循环中 E2 错题请老师补路径 | 解决可微代价绕不出的局部极小 | 量小、离线 | **上** |
| 老师:PRM 路网 A* vs `solveDyn` | A*:确定性,同类任务左右选择一致,且是路网上的最短;`solveDyn` 覆盖更全 | A*:受路网分辨率限制,个别任务失败 | **A* 为主老师**,失败的任务用 `solveDyn`(固定种子)补 |
| 推理后端:WebGPU vs wasm | WebGPU 大模型快 | 本模型约 4M 参数,WebGPU 每次调度 33–67 μs × 150–300 次,调度开销反而大 | **wasm-SIMD 多线程**;M4 实测若 WebGPU P99 更低再换 |
| 训练余量 | 越大越不易被认证拒绝 | 越大行程越长、贴柱越难 | **5 mm** |
| 训练余量 | 越大越不易被认证拒绝 | 越大行程越长、贴柱越难;高于老师轨迹自身间隙(实测最小间隙 p50 4.4 / p90 5.9 mm)会与 BC 标签对抗 | **3 mm**(≈ 老师 p10),APG 碰撞项与运行时投影共用 |
| 关节噪声增广 σ | 覆盖偏离状态 | 过大产生无意义状态 | **1°** |
| 局部牛顿精修 | 末端精度到 < 1 mm | 无 | **上**,同样过安全层 |
@@ -311,17 +311,17 @@ L = MeanFlow 损失 1.0
```
for 每个 batch(随机采样 q0 和目标,不需要老师):
q = q0
for t in 0..H-1 (H = 60 次推理 × 3 步 = 180 步 ≈ 3.6 s;长任务分段截断反传):
for t in 0..H-1 (H = 本批老师时长 p90 × 1.2,60–150 次推理 × 3 步 ≈ 3.6–9 s;无老师题按 6 s;每 10 次推理截断反传):
chunk = π(obs(q, 目标, 前缀), z) # 每条展开固定一个噪声 z
Δq = chunk[0:3] # 与部署一致
q = clamp(q + Δq)
累计 L_col(q), L_smooth(Δq)
L = w_tgt·L_target(q_H) + w_col·ΣL_col + w_smooth·ΣL_smooth (+ w_len·行程) + w_bc·L_BC
L = w_tgt·Σ_段末 L_target(q) + w_col·ΣL_col + w_smooth·ΣL_smooth (+ w_len·行程) + w_bc·L_BC
反传,梯度裁剪 ≤ 1.0,lr 1e-4
```
- 碰撞项 L_col = Σ ReLU(margin − 胶囊距离),margin = `margin_mm` + 训练余量 5 mm(覆盖安全层认证的保守量);含臂×立柱、臂×台架、臂×臂(非相邻段)。
- 到达项:位置误差² + λ_rot·‖R − R*‖_F²。
- 碰撞项 L_col = Σ ReLU(margin − 胶囊距离),margin = 训练余量 3 mm(间隙已扣除 `margin_mm`);每步端点 + 中点;含臂×立柱、臂×台架、臂×臂(非相邻段)。
- 到达项:每个截断段末的位置误差² + λ_rot·‖R − R*‖_F²;不做到达即停,段末项在整条展开上持续给出接近梯度。
- 平滑项:速度² + 加速度²。
- BC 锚定项:保留预训练损失,防止多模态被压缩;每轮监控左右比例。
- 采样分布同 §4.3,B/C/D 类提到 60%,其中贴柱类 40%。
@@ -374,7 +374,7 @@ for 每个 batch(随机采样 q0 和目标,不需要老师):
| 有 | 没有 |
|---|---|
| `traj_policy.onnx`(fp16,约 9.8 MB)+ `traj_policy.meta.json`(指纹 / 特征版本 / TCP) | PRM 路网、IK 候选大表 |
| `traj_policy.onnx`(fp32,约 20 MB;wasm / CPU 后端上 fp16 不保证更快)+ `traj_policy.meta.json`(指纹 / 特征版本 / TCP) | PRM 路网、IK 候选大表 |
| 胶囊距离与认证(`collideFree`、包围球预筛、`pair2d`) | `solveDyn` / `routeRT` / RRT / A* |
| `fk` / `orient`、局部牛顿精修 `solveTo` 单步 | 全量 IK 枚举 |
@@ -431,6 +431,8 @@ for 每个 batch(随机采样 q0 和目标,不需要老师):
说明:30 例全对时,失败率的 95% 置信上界约 9.5%;扩展集 1000 例 0 失败时约 0.3%,因此以扩展集作为 30/30 的统计支撑。
口径:本表全部按部署形态(`tests/policy_bench.cjs`,与浏览器同一份运行时)或 held-out 闭环仿真(开仿真安全层)测量,由 `ml/eval/acceptance.py` 汇总;M5 门禁与挖错用的是关安全层的纯策略口径,二者互不替代。挖错题与门禁集没有老师参考,到达按定长 6 s 判,「到达用时」「关节行程」两项只在有老师参考的任务上统计。
---
## 10. 当前存在的问题与风险
@@ -440,15 +442,15 @@ for 每个 batch(随机采样 q0 和目标,不需要老师):
| # | 风险 | 门禁(不过不进下一步) | 回退 | 何时测 |
|---|---|---|---|---|
| R1 | 老师在相邻任务上左右绕行跳变,标签自相矛盾 | 起终点扰动 ±5 mm 的邻居任务同伦类一致率 ≥ 90% | 对 A* 边代价加左右偏好项,强制同区域选同侧 | M1 |
| R2 | 时间律仍有不可观测成分,闭环起不了步 | 近邻样本标签方差 < 10% 步长;起步失败率 = 0 | 进一步把剩余路程估计加入输入 | M1 / M5 |
| R3 | 距离认证过于保守,贴柱处拒绝率高,影响 30/30 | near_column 上认证拒绝率统计;离线 10⁵ 段 0.01° 审计漏检 = 0 | 训练余量提高到 3–6 mm,接受行程变长;收紧杠杆界(按位形分区) | M2 / M7 |
| R2 | 时间律仍有不可观测成分,闭环起不了步 | 近邻样本标签方差 < 10% 步长(`ml/eval/check_r2.py`);起步失败率 = 0(闭环 `start_fail`) | 进一步把剩余路程估计加入输入 | M1 / M5 |
| R3 | 距离认证过于保守,贴柱处拒绝率高,影响 30/30 | near_column 上认证拒绝率统计;离线 10⁵ 段 0.01° 审计漏检 = 0 | 训练余量提高到 4–6 mm,接受行程变长;收紧杠杆界(按位形分区) | M2 / M7 |
| R4 | 浏览器推理延迟 | M4 后立即导出 ONNX,wasm-SIMD 多线程测 batch=8 P99 < 15 ms | 减小模型(d=128,约 1.8M 参数);同时测 WebGPU,更快则换 | **M4(最大不确定项)** |
| R5 | MeanFlow 头在 6 轴、位姿目标下穿柱或训练不稳 | near_column 穿柱率(关安全层)≤ 2%;损失不发散 | 同骨干换 WTA K=8 头 | M5 |
| R6 | 位姿目标的多 IK 分支带来额外多峰 | 不同肘/腕分支子集到达率 ≥ 主集 − 2 个百分点 | 加目标分支标志 | M5 |
| R7 | APG 压缩多模态或拉长路径(ELMP 中路径 +23%) | 每轮左右比例与行程门禁 | 加大 BC 锚定权重;行程项 | M6 |
| R8 | 卡死换向活锁 | 50 个"两侧都要绕远"对抗用例 3 s 内恢复 ≥ 95%,活锁 = 0 | 子目标改为离散绕行锚点分类 | M6 / M7 |
| 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 |
| R10 | 训练耗时估计偏差 | 2000 步吞吐 ≥ 2 万样本/s | 降 epoch 或增大 batch | M5 |
| 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 已知缺陷
+94 -50
View File
@@ -3,32 +3,34 @@
目标:在你的机器上从零跑出 Transformer 避障策略,并在查看器里直接用上。
方案依据见 `docs/Transformer轨迹拟合训练方案.md`。所有命令在**仓库根目录**执行。
最省事的跑法是按阶段执行 `bash ml/pipeline.sh <阶段>`,每一阶段完成后看一眼本手册对应小节的「检查」再进下一步。
按阶段执行 `bash ml/pipeline.sh <阶段>`,每一阶段完成后看本手册对应小节的「检查」再进下一步。
| 阶段 | 命令 | 预计耗时(16 核 CPU + RTX 3090) | 产出 |
| 阶段 | 命令 | 预计耗时(28 核 CPU + RTX 3090) | 产出 |
|---|---|---|---|
| 1 环境 | `bash ml/pipeline.sh setup` | 10 分钟 | 依赖 |
| 2 对拍 | `bash ml/pipeline.sh kin` | 2 分钟 | `ml/assets/kin.json`、对拍 PASS |
| 2 对拍 | `bash ml/pipeline.sh kin` | 1 分钟(实测 7.6 s 对拍) | `ml/assets/kin.json`、对拍 PASS |
| 3 老师门禁 | `bash ml/pipeline.sh gate1` | 25–30 分钟(单核) | `ml/data/consistency.json` |
| 4 生成数据 | `bash ml/pipeline.sh gen` | 1–2 小时;加共识过滤 5–9 小时 | `ml/data/shard_*.jsonl`、`stats.json` |
| 5 打包样本 | `bash ml/pipeline.sh prep` | 15–30 分钟 | `ml/data/samples/` |
| 6 预训练 | `bash ml/pipeline.sh train` | 先 2000 步测速,正式约 1.5–6 小时 | `ml/runs/base/best.pt` |
| 7 M5 门禁 | `bash ml/pipeline.sh m5` | 10 分钟 | `ml/runs/m5_gate.json` |
| 8 APG 微调 | `bash ml/pipeline.sh apg` | 以前 100 步测速为准(预计 2–4 小时) | `ml/runs/apg/best.pt` |
| 4 生成数据 | `bash ml/pipeline.sh gen` | 不共识约 1 小时;共识约 4–5 小时 | `ml/data/shard_*.jsonl`、`stats.json` |
| 5 打包样本 | `bash ml/pipeline.sh prep` | 15–30 分钟 | `ml/data/samples/`、`ml/data/r2.json` |
| 6 预训练 | `bash ml/pipeline.sh train` | 测速 2000 步约 6 分钟,正式约 3 小时 | `ml/runs/base/best.pt` |
| 7 M5 门禁 | `bash ml/pipeline.sh m5` | 10–20 分钟 | `ml/runs/m5_gate_base.json` |
| 8 APG 微调 | `bash ml/pipeline.sh apg` | 以前 100 步速度为准(预计 2–4 小时) | `ml/runs/apg/best.pt` |
| 9 挖错循环 | `bash ml/pipeline.sh mine` | 每轮约 1–2 小时,3–6 轮 | `ml/runs/current.pt` |
| 10 导出 | `bash ml/pipeline.sh export` | 1 分钟 | `webapp/models/traj_policy.onnx` |
| 11 评测 | `bash ml/pipeline.sh bench` | 约 1 小时(Node CPU,1070 个任务) | `ml/runs/policy_bench.json` |
| 12 使用 | `python ml/serve.py` → 浏览器 `?policy=1` | — | — |
| 10 导出 | `bash ml/pipeline.sh export` | 1 分钟 | `webapp/models/traj_policy.onnx`(fp32) |
| 11 部署评测 | `bash ml/pipeline.sh bench` | 约 1 小时(Node CPU,1120 个任务) | `ml/runs/policy_bench.json` |
| 12 验收汇总 | `bash ml/pipeline.sh accept` | 10–20 分钟 | `ml/runs/acceptance.json` |
| 13 使用 | `python ml/serve.py` → 浏览器 `?policy=1` | — | — |
耗时除第 4 步外均为估算,以每步首次输出的速度为准。
第 2/4/5/6 步耗时为 RTX 3090 + 28 核实测换算,其余为估算,以每步首次输出的速度为准。
---
## 1. 环境
- Linux 或 WSL2;NVIDIA 驱动 + CUDA(`nvidia-smi` 能看到 3090)
- Linux / WSL2,或 **Windows 原生 + Git Bash**(实测第 2/4/5/6 步与闭环仿真可跑);NVIDIA 驱动 + CUDA(`nvidia-smi` 能看到 3090)
- Node ≥ 18,Python 3.10–3.12
- 先按 CUDA 版本装 PyTorch(https://pytorch.org ,例:`pip install torch --index-url https://download.pytorch.org/whl/cu124`),再:
- 脚本统一用 `$PYTHON`(默认 `python`)。Linux 上没有 `python` 命令时:`export PYTHON=python3`。Windows 不要用 `python3`(那是 WindowsApps 假壳)
- 先按 CUDA 版本装 PyTorch(https://pytorch.org ),再:
```bash
git pull
@@ -50,59 +52,88 @@ bash ml/pipeline.sh kin
bash ml/pipeline.sh gate1
```
看 `consistentByGateRule`:≥ 0.90 直接生成;< 0.90 时第 4 步会自动加 `--consensus 4`(过滤与邻居绕法不一致的样本,生成成本约 5 倍)。也可手动用 `CONSENSUS=0/1` 覆盖。
看 `consistentByGateRule`:≥ 0.90 直接生成;< 0.90 时第 4 步自动加 `--consensus 4`(每条任务另跑 4 个 ±5 mm 邻居,与多数绕法不一致的样本丢弃,生成成本约 5 倍)。可用 `CONSENSUS=0/1` 手动覆盖。
## 4. 生成老师数据
```bash
bash ml/pipeline.sh gen # 默认 5 万条、进程数 = CPU 核数
# 自定义:N_TASKS=50000 NPROC=16 CONSENSUS=1 bash ml/pipeline.sh gen
# 自定义:N_TASKS=50000 NPROC=28 CONSENSUS=1 TARGET_SAMPLES=1700000 bash ml/pipeline.sh gen
```
每条约 2 秒/核(不共识)。检查 `ml/data/stats.json`:`keepRate` 应在 0.95 左右,`byCatMode` 里 C 类(贴立柱)有足够样本,`projectedSamplesFor50k` 约 180 万。
不共识约 1.6 秒/条/核;共识约 8 秒/条/核。生成完自动统计;**样本数不足 `TARGET_SAMPLES`(默认 170 万)时按实测「样本/任务」自动补生成**(最多补 2 次),不用手动加 `N_TASKS`。
## 5. 打包训练样本
检查 `ml/data/stats.json`:
| 字段 | 不共识(实测) | 共识(估算) | 不正常时 |
|---|---|---|---|
| `keepRate` | ≈ 0.97 | 0.80–0.88 | 共识 < 0.75:把 `stats.json` 发我(老师左右跳变比预期多) |
| `samplesPerTask` | ≈ 36.5 | ≈ 36.5 | — |
| `samplesEvery3` | ≥ 170 万(自动补量后) | ≥ 170 万 | 补量后仍不足:发我 |
| `consensusDrop` | 0 | 约任务数的 10–20% | — |
| `vertexMinGapMm` | p50 ≈ 4.4、p90 ≈ 5.9 mm(p10 预计约 3) | 同 | p10 < 2.5 mm:发我(APG 与投影的 3 mm 余量要随之下调) |
| `byCatMode` | C 类(贴立柱)各模式都有数千条 | 同 | — |
## 5. 打包训练样本 + R2 检查
```bash
bash ml/pipeline.sh prep
```
每条轨迹按 50 Hz 时间律重放,每 3 步取一个样本(特征与部署同一份 JS 代码计算)。检查 `ml/data/samples/index.json`:`train_samples` 约 170 万、`val_samples` 约 9 万。
每条轨迹按 50 Hz 时间律重放,每 3 步取一个样本(特征与部署同一份 JS 代码计算),30% 样本加 σ = 1° 噪声增广。
检查 `ml/data/samples/index.json`:`train_samples` 约 170 万、`val_samples` 约 9 万(5% 任务划为 val)。
随后自动跑 R2 检查(`ml/data/r2.json`):近邻样本前 3 步标签差 `label_std_mean < 0.10`(10% 步长)为通过;不通过先发我,不要直接训练。
## 6. 预训练
```bash
bash ml/pipeline.sh train
bash ml/pipeline.sh train # MeanFlow 头 → ml/runs/bench_base/、ml/runs/base/
```
先跑 2000 步测吞吐(`ml/runs/bench/log.jsonl` 的 `samples_per_s`),再正式训练到 `configs/base.json` 的 60000 步或闭环到达率连续 5 次不升为止。
训练中每 2000 步打印一行 `EVAL {...}`:闭环(部署节奏)`success` 到达率、`collision_rate`、`time_ok_1p3`。
显存不够就把 `configs/base.json` 的 `batch` 改成 512。中断后加 `--resume ml/runs/base/last.pt` 续训:
先跑 2000 步测吞吐(`ml/runs/bench_base/log.jsonl` 的 `samples_per_s`,`eta_h` 是剩余小时数),再正式训练到 60000 步或闭环到达率连续 5 次不升为止。
- **通过线(R10):`samples_per_s` ≥ 5000,即 60000 步 ≤ 3.5 小时**。参考:3090、batch 1024 实测 6.2 k 样本/s ≈ 2.8 小时。MeanFlow 的 JVP 双前向使训练受算力限制,加大 batch 不会提高样本/s;低于通过线时把 `configs/base.json` 的 `fk_caps` 从 96 降到 48,仍不够再把 `steps` 降到 40000
- 训练中每 2000 步打印一行 `EVAL {...}`:val 任务闭环(部署节奏、不开安全层)`success`、`collision_rate`、`time_ok_1p3`、`start_fail`。时限 = 老师时长 × 2(至少 3 s)
- 显存不够把 `batch` 改成 512。中断后续训(日志追加;新训练会覆盖旧日志):
```bash
python -m ml.train --samples ml/data/samples --out ml/runs/base --resume ml/runs/base/last.pt
```
## 7. M5 门禁:MeanFlow 头是否可用
## 7. M5 门禁:选定动作头
```bash
bash ml/pipeline.sh m5
```
在 1000 个贴立柱任务上关安全层闭环运行。穿柱率 ≤ 2% 通过;不通过(或训练日志出现「训练发散」)则换预定回退的 WTA K=8 头重训:
1000 个贴立柱任务(无老师题,定长 6 s 时限)跑两遍,结果在 `ml/runs/m5_gate_base.json`:
- `policy_only`:关安全层的纯策略指标。**门禁只看这一项:穿柱率 ≤ 2% 通过**,通过后把该模型记为选定模型(`ml/runs/selected_ckpt`),后续阶段自动从它开始
- `with_sim_safety`:仿真里的近似安全层(候选逐步干涉就换下一条)。只作参考
三种口径的关系:M5 是纯策略能力;仿真安全层是近似;**部署能不能过只看第 11 步**(与浏览器同一份运行时,含投影、距离认证、刹停、预算、换向锁)。三者互不替代。
不通过(或训练日志出现「训练发散」)则换 WTA K=8 头。WTA 的产物在独立目录,不会覆盖 MeanFlow:
```bash
CFG=ml/configs/wta.json bash ml/pipeline.sh train && bash ml/pipeline.sh m5
CFG=ml/configs/wta.json bash ml/pipeline.sh train # → ml/runs/bench_wta/、ml/runs/wta/
CFG=ml/configs/wta.json bash ml/pipeline.sh m5 # → ml/runs/m5_gate_wta.json;通过则选定 wta
```
需要强制指定后续阶段的起点模型时用 `CKPT=路径 bash ml/pipeline.sh apg`。
## 8. APG 可微微调
```bash
bash ml/pipeline.sh apg
```
按部署节奏可微展开策略,直接优化到达、碰撞余量(5 mm)、平滑与行程,并保留 BC 锚定。每 500 步闭环验证,按到达率存 `best.pt`,完成后复制为 `ml/runs/current.pt`。
显存不够把 `configs/apg.json` 的 `batch` 降到 64。
按部署节奏(每次推理提交 3 步、50 Hz)可微展开策略,优化到达、碰撞余量、平滑与行程,并保留 BC 锚定。每 500 步闭环验证,按到达率存 `best.pt`,完成后复制为 `ml/runs/current.pt`。
- **展开长度**:每批覆盖该批老师时长 p90 × 1.2(无老师题按 6 s),在 60–150 次推理(3.6–9 s)之间;日志 `plans` 字段是本批实际次数
- **到达项**:每 10 次推理段末的 TCP 距离与姿态误差,没有「到达即停」。这是有意的:段末项在整条展开上持续给出接近目标的梯度
- **碰撞项**:每步端点 + 中点的逐胶囊对铰链 `ReLU(3 mm − 间隙)`。3 mm ≈ 老师轨迹最小间隙的 p10(p50 4.4 mm),避免碰撞项与 BC 标签对抗;运行时投影余量同为 3 mm
- 显存不够把 `configs/apg.json` 的 `batch` 降到 64,或把 `seg_plans` 从 10 降到 5
## 9. 挖错循环
@@ -110,18 +141,21 @@ bash ml/pipeline.sh apg
bash ml/pipeline.sh mine # 默认 6 轮;ROUNDS=3 APG_STEPS=1000 MINE_N=50000 可调
```
每轮:5 万道新题关安全层作答 → 错题分类 E1–E5 → 错题与邻居进 APG 采样池 → E2(局部极小)请老师补少量路径 → APG 微调 → 门禁集比较,不达标自动回滚。连续 2 轮提升 < 0.5 个百分点自动停止。每轮报告在 `ml/runs/mine/r*/report.md` 与 `gate.json`。
每轮:5 万道新题关安全层作答 → 错题分类 E1–E5 → **本轮**错题与邻居进 APG 采样池(不累积历史轮,旧错题由门禁的「上轮修好仍对 ≥ 95%」把关)→ E2(局部极小)请老师补少量路径 → APG 微调 → 门禁集比较,不达标自动回滚。连续 2 轮提升 < 0.5 个百分点自动停止。
门禁(`ml/runs/mine/r*/gate.json`):到达率不降、A 类降幅 ≤ 0.5 个百分点、上轮修好的题 ≥ 95% 仍对、**R7**:B/C 类左右绕行比例变化 ≤ 5 个百分点、行程中位数比 ≤ 1.05。挖错题与门禁集都是无老师题,到达按定长 6 s 判,`time_ok_1p3` 与行程比只在有老师参考的题上统计(门禁集上为 null)。
## 10. 导出
```bash
bash ml/pipeline.sh export # fp16 ONNX + meta.json → webapp/models/
bash ml/pipeline.sh export # fp32 ONNX + meta.json → webapp/models/(约 20 MB)
```
fp16 转换报错时去掉 `--fp16` 手动导出 fp32(约 20 MB,同样可用):
默认 fp32:部署走 wasm 与 Node CPU,fp16 在 CPU 后端上不保证更快。只有第 13 步用 `&ep=webgpu` 实测 `__POLICY.latency()` 明显受益时,才另导出 fp16 做对比:
```bash
python -m ml.export_onnx --ckpt ml/runs/current.pt --out webapp/models/traj_policy.onnx
pip install onnxconverter-common
python -m ml.export_onnx --ckpt ml/runs/current.pt --out webapp/models/traj_policy_fp16.onnx --fp16
```
## 11. 部署形态评测(策略 + 安全层)
@@ -130,11 +164,20 @@ python -m ml.export_onnx --ckpt ml/runs/current.pt --out webapp/models/traj_poli
bash ml/pipeline.sh bench
```
跑与浏览器同一份运行时(`webapp/js/ik-policy.js`):贴立柱 30 例(pos、pose 各一遍)、直线被挡 40 例、一次性贴立柱扩展集 1000 例。
验收看 `ml/runs/policy_bench.json`:各集合 `collisionFrames` 必须为 0;`near_column_*` 的 `ok` 30/30;`near_column_1000` 0 失败为目标。
Node 里的推理/安全层耗时只作参考,浏览器延迟用第 12 步实测。
跑与浏览器同一份运行时(`webapp/js/ik-policy.js`):贴立柱 30 例(pos、pose 各一遍)、直线被挡 40 例、R8 对抗 50 例(J1 起终差 ≥ 140°、终点贴立柱、两侧都要绕远)、一次性贴立柱扩展集 1000 例。各集合数量可用 `--nc-n` / `--route-n` / `--r8-n` 单独调。
## 12. 在查看器里使用
看 `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 步实测。
## 12. 验收汇总
```bash
bash ml/pipeline.sh accept
```
自己补跑 val 全部任务的闭环(开仿真安全层):可达任务到达率 ≥ 98%、D 类停靠距离 ≤ 老师 + 5 mm、起步失败 = 0;再读第 5–11 步产物核对 R2、R7、R8、R10、M5 与部署形态各项。逐项打印「通过 / 未过」,结果在 `ml/runs/acceptance.json`。
## 13. 在查看器里使用
```bash
python -m arm.viewer.build_viewer # 若 webapp/generated/00_data.js 还没生成(查看器原有构建步骤)
@@ -147,24 +190,23 @@ python ml/serve.py # 带跨源隔离头的本地服务,wasm
- 工具偏移 ≠ 0 时自动交回原规划器
- 离线环境:把 onnxruntime-web 的 `ort.min.js` 与 wasm 文件放到 `webapp/vendor/ort/`,地址加 `&ortLocal=1`
## 13. 回传给我
每完成一步把对应文件发我即可,我据此判断下一步或改代码:
## 14. 回传给我
| 步骤 | 文件 |
|---|---|
| 2 | 对拍输出(终端 JSON) |
| 3–5 | `ml/data/consistency.json`、`ml/data/stats.json`、`ml/data/samples/index.json` |
| 6 | `ml/runs/bench/log.jsonl`(测速)、`ml/runs/base/log.jsonl` |
| 7 | `ml/runs/m5_gate.json` |
| 3–5 | `ml/data/consistency.json`、`ml/data/stats.json`、`ml/data/samples/index.json`、`ml/data/r2.json` |
| 6 | `ml/runs/bench_base/log.jsonl`(测速)、`ml/runs/base/log.jsonl` |
| 7 | `ml/runs/m5_gate_*.json` |
| 8–9 | `ml/runs/apg/log.jsonl`、`ml/runs/mine/r*/report.json` 与 `gate.json` |
| 11–12 | `ml/runs/policy_bench.json`、浏览器 `__POLICY.latency()` 输出 |
| 11–13 | `ml/runs/policy_bench.json`、`ml/runs/acceptance.json`、浏览器 `__POLICY.latency()` 输出 |
## 常见问题
- **指纹不符**:`collide.json` 变了。依次重跑第 2、4、5 步并重训。
- **训练发散**(loss 出现 nan/inf,自动停在 `diverged.pt`):先把 `lr` 降到 5e-4 重试一次,仍发散就走 WTA 回退(第 7 步)。
- **显存不足**:降 `batch`;APG 还可把 `seg_plans` 从 10 降到 5。
- **显存/内存不足**:降 `batch`;APG 还可把 `seg_plans` 降到 5。出题与间隙计算已分块(每块 512 个位形)。
- **小样本摸底**:几百条任务时 val 可能为空,训练会提示并跳过闭环验证。
- **浏览器只有 1 线程**:没走 `ml/serve.py`(缺 COOP/COEP 头)。
- **查看器徽标「策略模式不可用」**:看控制台报错,多为模型文件缺失、指纹或 TCP 不符。
@@ -172,19 +214,21 @@ python ml/serve.py # 带跨源隔离头的本地服务,wasm
```
ml/
pipeline.sh 一条龙阶段脚本
gen/ 老师数据:sampler / teacher(确定性 PRM A*)/ timelaw / gen_dataset / consistency / stats / run_parallel.sh
prep/ export_kin.cjs(→ assets/kin.json)、build_dataset.cjs(→ samples/*.npy)、run_prep.sh
pipeline.sh 一条龙阶段脚本(环境变量见文件头)
gen/ 老师数据:sampler / teacher(确定性 PRM A*)/ cert.cjs(距离下界认证,实现在 ik-policy.js)/
pathdev.cjs(同伦类判定,共识过滤与一致性门禁用)/ timelaw / gen_dataset / consistency / stats / run_parallel.sh
prep/ export_kin.cjs(→ assets/kin.json)、build_dataset.cjs(→ samples/*.npy)、npy.cjs(.npy 写出)、run_prep.sh
common/ kin_torch.py(可微运动学 + 胶囊间隙)、features.py(83 维特征,与 JS 逐项一致)
model/ policy.py(Transformer + MeanFlow / WTA 头)、meanflow.py
data.py train.py 样本加载、预训练
sim/closed_loop.py 部署节奏闭环仿真(验证 / 挖错 / 门禁)
apg/apg_finetune.py APG 可微微调
mining/ tasks.py(无老师出题)、mine_round.py、gate.py、loop.sh
eval/ dump_parity.cjs + check_parity.py(JS↔PyTorch 对拍)
mining/ tasks.py(无老师出题)、mine_round.py、gate.py(含 R7)、loop.sh
eval/ dump_parity.cjs + check_parity.py(JS↔PyTorch 对拍)、check_r2.py(R2)、acceptance.py(验收汇总)
toy/toy_bimodal.py 2D 双模态对照实验(动作头选型依据)
export_onnx.py serve.py
configs/ base.json(MeanFlow)、wta.json(回退)、apg.json
webapp/js/ik-policy.js 运行时:特征、距离下界认证、刹停、投影、精修、Runner(浏览器与 Node 共用)
webapp/js/ik-policy-glue.js 查看器接入(?policy=1)
tests/policy_bench.cjs 部署形态评测
tests/policy_bench.cjs 部署形态评测(nc / route / r8 / nc1000)
```
+15 -8
View File
@@ -2,7 +2,10 @@
python -m ml.apg.apg_finetune --init ml/runs/base/best.pt --samples ml/data/samples --out ml/runs/apg [--pool ml/runs/mine/pool.jsonl]
每条展开固定一个噪声 z(确定性可微映射);每次推理提交 3 步(已提交前缀 3 步,与部署一致);
截断反传:每 seg_plans 次推理(=3·seg_plans 步)反传一次并截断状态梯度。
损失:段末到达项 + Σ碰撞铰链 ReLU(margin_train − 间隙)(逐胶囊对)+ 平滑(速度²+加速度²)+ 行程 + BC 锚定(MeanFlow 损失)。"""
损失:段末到达项 + Σ碰撞铰链 ReLU(margin_train − 间隙)(逐胶囊对,端点+中点)+ 平滑(速度²+加速度²)+ 行程 + BC 锚定(MeanFlow 损失)。
2026-10-18:展开长度不再定长 60 次推理(3.6 s,会截掉老师时长 > 3.6 s 的绕远题),改为覆盖本批 exec_s p90×1.2,上限 plans_max;
"到达"仍是每 seg_plans 段末的距离项(无到达即停),属有意设计:段末项给出持续的接近梯度。
margin_train 5 → 3 mm:老师轨迹自身最小间隙 p50 4.4 / p90 5.9 mm,5 mm 会让约一半 BC 标签与碰撞项对抗;3 mm ≈ 老师 p10。"""
import argparse, json, math, os, random, time
import torch
@@ -45,7 +48,7 @@ def main():
opt = torch.optim.AdamW(model.parameters(), lr=C['lr'], betas=(0.9, 0.95), weight_decay=0.0)
amp = dev.startswith('cuda') and C.get('amp') == 'bf16'
step = torch.tensor(STEP, device=dev)
log = open(os.path.join(a.out, 'log.jsonl'), 'a')
log = open(os.path.join(a.out, 'log.jsonl'), 'w')
base, _ = evaluate(model.eval(), kin, val_tasks, dev)
print('EVAL0', json.dumps(base), flush=True); log.write(json.dumps({'eval': base, 'step': 0}) + '\n')
best = base['success']
@@ -62,9 +65,12 @@ def main():
subs = torch.zeros(B, 2, 6, device=dev); nsub = torch.zeros(B, dtype=torch.long, device=dev); sub_t = torch.zeros(B, device=dev)
opt.zero_grad(set_to_none=True)
stats = {'col': 0.0, 'tgt': 0.0, 'smooth': 0.0}
nseg = math.ceil(C['plans'] / C['seg_plans'])
# 展开长度随本批任务时长:覆盖本批 exec_s 的 p90 × 1.2(无老师参考题按 6 s),夹在 [plans, plans_max]
exb = torch.nan_to_num(ex, nan=6.0)
n_plans = int(min(C['plans_max'], max(C['plans'], math.ceil(1.2 * torch.quantile(exb, 0.9).item() / (3 * 0.02)))))
nseg = math.ceil(n_plans / C['seg_plans'])
seg_loss = torch.zeros((), device=dev); prev_d = torch.zeros(B, 6, device=dev)
for p in range(C['plans']):
for p in range(n_plans):
tnow = p * 3 * 0.02
r = queue.shape[1]
pre = torch.zeros(B, 3, 6, device=dev); pre[:, :r] = queue[:, :r]
@@ -92,15 +98,16 @@ def main():
for k in range(3):
d = queue[:, k]
qp = q; q = kin.clamp_box(q + d)
G = kin.gaps(q, M)
# 碰撞铰链:端点 + 中点(与仿真/运行时的离散检查一致)
G = kin.gaps(torch.cat([q, 0.5 * (q + qp)], 0), M)
hinge = torch.relu(M - G['gap'])
col = torch.zeros(B, device=dev).index_add(0, G['b'], hinge)
col = torch.zeros(2 * B, device=dev).index_add(0, G['b'], hinge).view(2, B).sum(0)
sm = ((d / step) ** 2).sum(-1) + (((d - prev_d) / step) ** 2).sum(-1)
prev_d = d
seg_loss = seg_loss + C['w_col'] * col.mean() + C['w_smooth'] * sm.mean() + C['w_len'] * d.abs().sum(-1).mean()
stats['col'] += col.mean().item(); stats['smooth'] += sm.mean().item()
queue = commit
if (p + 1) % C['seg_plans'] == 0 or p == C['plans'] - 1:
if (p + 1) % C['seg_plans'] == 0 or p == n_plans - 1:
xf = kin.cum_xf(q)
pe = kin.fk(q, xf); Rq = kin.orient(q, xf).reshape(B, 9)
pose = (R.abs().sum(-1) > 0).float()
@@ -122,7 +129,7 @@ def main():
opt.step()
with torch.no_grad():
e, oe = goal_err(kin, q, T, R)
rec = {'it': it, 'err_mm_mean': e.mean().item(), 'reach_frac': ((e < 1) & (oe < 0.5)).float().mean().item(), 'bc': l_bc.item(),
rec = {'it': it, 'plans': n_plans, 'err_mm_mean': e.mean().item(), 'reach_frac': ((e < 1) & (oe < 0.5)).float().mean().item(), 'bc': l_bc.item(),
'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()
+2 -2
View File
@@ -1,8 +1,8 @@
{
"apg": {
"batch": 128, "lr": 1e-4, "steps": 3000, "grad_clip": 1.0, "amp": "bf16",
"plans": 60, "seg_plans": 10,
"margin_train_mm": 5.0,
"plans": 60, "plans_max": 150, "seg_plans": 10,
"margin_train_mm": 3.0,
"w_tgt": 1.0, "w_rot": 0.3, "w_col": 2.0, "w_smooth": 0.02, "w_len": 0.001, "w_bc": 0.5,
"cat_weights": {"A": 0.4, "B": 1.0, "C": 1.6, "D": 0.6, "Dr": 0.6},
"eval_every": 500, "eval_tasks": 512
+104
View File
@@ -0,0 +1,104 @@
"""验收汇总(方案 §9.2 + §10.1 R2/R7/R8/R10):把各阶段产物里的判据集中核对,输出 ml/runs/acceptance.json。
python -m ml.eval.acceptance --ckpt ml/runs/current.pt --samples ml/data/samples --out ml/runs/acceptance.json
自己跑的一项:held-out(val 划分)全部任务闭环仿真(开仿真安全层)——可达任务到达率 ≥ 98%、D 类停靠距离 ≤ 老师 + 5 mm、起步失败 = 0。
其余项读已有文件(缺文件记为 missing,不算通过):
ml/runs/policy_bench.json 部署形态:各集合穿模帧 = 0;near 30/30(pos、pose);route 全部到达;nc1000 0 失败;刹停率 < 2%;R8 恢复 ≥ 95%、活锁 = 0
ml/data/r2.json R2 近邻标签 std < 10% 步长
ml/runs/bench_*/log.jsonl R10 吞吐 ≥ 5000 样本/s(≈ 60000 步 ≤ 3.5 h)
ml/runs/m5_gate_*.json M5 头门禁
ml/runs/mine/r*/gate.json R7 最后一轮左右比例 / 行程门禁
2026-10-18 新增(审查:§9 验收表在流水线里没有执行点)。"""
import argparse, glob, json, os, re
import torch
def jload(p):
try: return json.load(open(p, encoding='utf-8'))
except Exception: return None
def main():
from ml.common.kin_torch import Kin
from ml.model.policy import build
from ml.data import load_tasks
from ml.sim.closed_loop import evaluate
ap = argparse.ArgumentParser()
ap.add_argument('--ckpt', required=True); ap.add_argument('--samples', default='ml/data/samples')
ap.add_argument('--out', default='ml/runs/acceptance.json')
ap.add_argument('--device', default='cuda' if torch.cuda.is_available() else 'cpu')
a = ap.parse_args()
items = []
def add(name, value, ok, rule):
items.append({'item': name, 'value': value, 'pass': (None if ok is None else bool(ok)), 'rule': rule})
# 1) held-out 闭环(仿真安全层)
ck = torch.load(a.ckpt, map_location=a.device)
model = build(ck['cfg']).to(a.device); model.load_state_dict(ck['model']); model.eval()
kin = Kin(device=a.device)
val = [t for t in load_tasks([a.samples]) if t['split'] == 'val']
reach = [t for t in val if t.get('reached') and t['cat'] != 'D']
dock = [t for t in val if t['cat'] == 'D']
if reach:
r, _ = evaluate(model, kin, reach, a.device, safety=True)
add('held-out 可达任务到达率', r['success'], r['success'] >= 0.98, '≥ 0.98(仿真安全层)')
add('起步失败率(R2)', r['start_fail'], r['start_fail'] == 0, '= 0')
else:
add('held-out 可达任务到达率', None, False, 'val 中无可达任务')
if dock:
_, pers = evaluate(model, kin, dock, a.device, safety=True)
err = torch.cat([p['err'] for _, p in pers])
ref = torch.tensor([t.get('err') if t.get('err') is not None else float('inf') for t in dock])
rate = (err <= ref + 5).float().mean().item()
add('D 类停靠距离 ≤ 老师 + 5 mm 的比例', rate, rate >= 0.98, '≥ 0.98')
# 2) 部署形态
pb = jload('ml/runs/policy_bench.json')
if pb is None:
add('部署形态评测', 'missing', False, '先跑 bash ml/pipeline.sh bench')
else:
sets = {k: v for k, v in pb.items() if isinstance(v, dict) and 'n' in v}
cf = sum(v['collisionFrames'] for v in sets.values())
add('部署形态穿模帧(全部集合)', cf, cf == 0, '= 0')
for k in ('near_column_pos', 'near_column_pose', 'route40', 'near_column_1000'):
if k in sets: add(f'{k} 通过', f"{sets[k]['ok']}/{sets[k]['n']}", sets[k]['ok'] == sets[k]['n'], '全部通过')
else: add(f'{k} 通过', 'missing', False, '全部通过')
plans = sum(v.get('plans', 0) for v in sets.values()); br = sum(v.get('brakes', 0) for v in sets.values())
add('刹停发生率(刹停 / 推理)', br / plans if plans else None, (br / plans < 0.02) if plans else False, '< 0.02')
r8 = sets.get('r8_adversarial')
if r8:
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() 为准')
# 3) 数据 / 训练 / 后训练门禁
r2 = jload('ml/data/r2.json')
add('R2 近邻标签 std(步长比例)', r2['label_std_mean'] if r2 else 'missing', bool(r2 and r2['pass']), '< 0.10')
sps = None
for f in sorted(glob.glob('ml/runs/bench_*/log.jsonl')):
recs = [json.loads(l) for l in open(f) if '"samples_per_s"' in l]
if recs: sps = max(sps or 0, recs[-1]['samples_per_s'])
add('R10 训练吞吐(样本/s)', sps, sps is not None and sps >= 5000, '≥ 5000(≈ 60000 步 ≤ 3.5 h)')
m5 = [jload(f) for f in sorted(glob.glob('ml/runs/m5_gate_*.json'))]
m5 = [m for m in m5 if m]
add('M5 头门禁', [{'head': m['head'], 'collision_rate': m['policy_only']['collision_rate']} for m in m5] or 'missing',
any(m['pass'] for m in m5), '纯策略贴立柱穿柱率 ≤ 0.02')
rnum = lambda p: int((re.findall(r'[\\/]r(\d+)[\\/]gate\.json$', p) or ['0'])[0])
gates = sorted(glob.glob('ml/runs/mine/r*/gate.json'), key=rnum)
if gates:
gj = jload(gates[-1])
add('R7 左右比例变化(pp)/ 行程比', [gj.get('R7_left_share_shift_pp'), gj.get('R7_travel_median_ratio')], gj.get('R7_ok'), '≤ 5 pp 且 ≤ 1.05')
else: add('R7 门禁', 'missing', False, '先跑 mine')
hard = [i for i in items if i['pass'] is not None]
out = {'ckpt': a.ckpt, 'all_pass': all(i['pass'] for i in hard), 'failed': [i['item'] for i in hard if not i['pass']], 'items': items}
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)
for i in items:
mark = {True: '通过', False: '未过', None: '参考'}[i['pass']]
print(f"[{mark}] {i['item']}: {i['value']} ({i['rule']})")
print('全部通过' if out['all_pass'] else '未通过:' + '、'.join(out['failed']))
if __name__ == '__main__':
main()
+46
View File
@@ -0,0 +1,46 @@
"""方案 R2 数据侧检查:近邻样本的标签是否自洽(时间律不可观测成分会让相同输入对应不同动作)。
python -m ml.eval.check_r2 --samples ml/data/samples --out ml/data/r2.json
做法:特征逐维标准化;随机取 Q 个查询样本,在 P 个样本池里找最近邻(排除同一任务,避免同轨迹相邻帧冒充"近邻");
取距离最近的 5% 近邻对,比较前 3 步(实际提交的动作)标签:std ≈ RMS(ΔY)/√2,单位为每步最大步长 STEP 的比例。
判据:label_std < 0.10(即 < 10% 步长)。起步失败率在闭环仿真结果的 start_fail 字段(acceptance 汇总)。
2026-10-18 新增(审查:R2 无执行点)。GPU 上约 1 分钟;CPU 上把 --pool 降到 50000。"""
import argparse, json, math
import torch
from ml.data import Samples
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--samples', nargs='+', default=['ml/data/samples']); ap.add_argument('--out', default='ml/data/r2.json')
ap.add_argument('--queries', type=int, default=20000); ap.add_argument('--pool', type=int, default=200000)
ap.add_argument('--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')
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]
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]
best_d = torch.empty(Q, device=a.device); best_j = torch.empty(Q, dtype=torch.long, device=a.device)
for s in range(0, Q, 1024):
D = torch.cdist(Xq[s:s + 1024], Xp)
D = torch.where(tq[s:s + 1024, None] == tp[None], torch.full_like(D, float('inf')), D)
v, j = D.min(1); best_d[s:s + 1024] = v; best_j[s:s + 1024] = j
fin = torch.isfinite(best_d)
thr = torch.quantile(best_d[fin], a.frac)
m = fin & (best_d <= thr)
Yq, Yp = d.Y[qi][m, :3].float(), d.Y[pi][best_j[m], :3].float()
rms = (Yq - Yp).pow(2).mean(-1).mean(-1).sqrt() # 每对、6 轴 × 3 步平均
std = (rms / math.sqrt(2))
out = {'pairs': int(m.sum()), 'nn_dist_threshold': thr.item(), 'label_std_mean': std.mean().item(),
'label_std_p90': torch.quantile(std, 0.9).item(), 'pass': std.mean().item() < 0.10,
'rule': 'label_std_mean < 0.10(10% 步长)'}
print(json.dumps(out, indent=1))
json.dump(out, open(a.out, 'w'), indent=1)
if __name__ == '__main__':
main()
+3 -2
View File
@@ -3,7 +3,7 @@
const fs = require('fs');
const files = process.argv.slice(2);
const by = {}; let total = 0, keep = 0, steps = 0, samples = 0, padTail = 0;
const q = a => { a.sort((x, y) => x - y); return a.length ? { p50: +a[a.length >> 1].toFixed(1), p90: +a[Math.floor(a.length * 0.9)].toFixed(1), max: +a[a.length - 1].toFixed(1) } : null; };
const q = a => { a.sort((x, y) => x - y); return a.length ? { p10: +a[Math.floor(a.length * 0.1)].toFixed(1), p50: +a[a.length >> 1].toFixed(1), p90: +a[Math.floor(a.length * 0.9)].toFixed(1), max: +a[a.length - 1].toFixed(1) } : null; };
const plan = [], certMs = [], travel = [], exec = [], gaps = [];
for (const f of files) for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
if (!line) continue; const r = JSON.parse(line); total++;
@@ -17,5 +17,6 @@ for (const f of files) for (const line of fs.readFileSync(f, 'utf8').split('\n')
const out = { tasks: total, keep, keepRate: +(keep / total).toFixed(3), byCatMode: by,
planMs: q(plan), certMs: q(certMs), vertexMinGapMm: q(gaps), travelDeg: q(travel), execS: q(exec),
samplesEvery3: samples, samplesPerTask: +(samples / keep).toFixed(1), paddedSampleShare: +(padTail / samples).toFixed(3),
projectedSamplesFor50k: Math.round(samples / total * 50000) };
projectedSamplesFor50k: Math.round(samples / total * 50000),
consensusDrop: Object.values(by).reduce((a, b) => a + b.consensusDrop, 0) };
console.log(JSON.stringify(out, null, 1));
+18 -4
View File
@@ -1,4 +1,6 @@
"""门禁(方案 §7.2 第 6 步):新模型须 ①门禁集到达率不降 ②A 类降幅 ≤ 0.5 个百分点 ③上一轮修好的错题 95% 仍对。
"""门禁(方案 §7.2 第 6 步):新模型须 ①门禁集到达率不降 ②A 类降幅 ≤ 0.5 个百分点 ③上一轮修好的错题 95% 仍对
④R7:B/C 类(需绕行)左右比例变化 ≤ 5 个百分点 ⑤R7:两者都到达的题行程中位数比 ≤ 1.05。
门禁集为无老师题,到达按定长 6 s 口径(见 closed_loop.NOREF_SEC)。
python -m ml.mining.gate --old cur.pt --new cand.pt --tasks ml/runs/gate_tasks.jsonl --out gate.json [--fixed prev_fixed.jsonl]
通过则把 cand 复制为 --current(默认 ml/runs/current.pt)。gate.json 含 accept / gain_pp,loop.sh 据此决定继续或停止。"""
import argparse, json, shutil
@@ -38,15 +40,27 @@ def main():
if ft:
r2, p2 = evaluate(load(a.new, a.device), kin, ft, a.device)
keep = sum(per_task_success(p2)) / len(ft)
accept = gain >= 0 and a_drop <= 0.5 and (keep is None or keep >= 0.95)
out = {'old': ro, 'new': rn, 'gain_pp': gain, 'A_drop_pp': a_drop, 'fixed_kept': keep, 'accept': accept}
# R7:左右比例与行程
cat = [t['cat'] for t in tasks]
so_side = torch.cat([p['side'] for _, p in po]); sn_side = torch.cat([p['side'] for _, p in pn])
det = torch.tensor([c in ('B', 'C') for c in cat])
left = lambda sd, ok: (((sd > 0) & det & ok).sum() / ((det & ok).sum().clamp(min=1))).item()
ok_o, ok_n = torch.tensor(so, dtype=torch.bool), torch.tensor(sn, dtype=torch.bool)
lr_shift = abs(left(sn_side, ok_n) - left(so_side, ok_o)) * 100
both = ok_o & ok_n
tro = torch.cat([p['travel'] for _, p in po]); trn = torch.cat([p['travel'] for _, p in pn])
travel_ratio = (trn[both].median() / tro[both].median().clamp(min=1e-6)).item() if both.any() else None
r7 = lr_shift <= 5.0 and (travel_ratio is None or travel_ratio <= 1.05)
accept = gain >= 0 and a_drop <= 0.5 and (keep is None or keep >= 0.95) and r7
out = {'old': ro, 'new': rn, 'gain_pp': gain, 'A_drop_pp': a_drop, 'fixed_kept': keep,
'R7_left_share_shift_pp': lr_shift, 'R7_travel_median_ratio': travel_ratio, 'R7_ok': r7, 'accept': accept}
# 本轮新修好的题(旧错新对)留给下一轮做"仍对"检查
fixed_now = [t for t, x, y in zip(tasks, so, sn) if (not x) and y]
with open(a.out.replace('.json', '_fixed.jsonl'), 'w') as f:
for t in fixed_now: f.write(json.dumps(t) + '\n')
json.dump(out, open(a.out, 'w'), indent=1)
if accept: shutil.copy(a.new, a.current)
print(json.dumps({k: out[k] for k in ('gain_pp', 'A_drop_pp', 'fixed_kept', 'accept')}))
print(json.dumps({k: out[k] for k in ('gain_pp', 'A_drop_pp', 'fixed_kept', 'R7_left_share_shift_pp', 'R7_travel_median_ratio', 'accept')}))
if __name__ == '__main__':
+9 -7
View File
@@ -1,29 +1,31 @@
#!/usr/bin/env bash
# 后训练主循环(方案 §7):APG 首轮 → 挖错 3–6 轮(每轮:挖错 → [E2 老师补路径] → APG 微调 → 门禁/回滚)
# bash ml/mining/loop.sh [轮数=6] [每轮 APG 步数=1000] [每轮出题数=50000]
# 2026-10-18:APG 采样池只用本轮错题(此前累积所有历史轮,后期分布偏向老错题);老错题的保持由门禁"上轮修好仍对"负责。
# 统一 $PYTHON;门禁加 R7(左右比例 / 行程)。
set -e
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 ] || python -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 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
D=ml/runs/mine/r$r; mkdir -p $D
python -m ml.mining.mine_round --ckpt ml/runs/current.pt --seed $((1000+r)) --n $N --out $D
$PY -m ml.mining.mine_round --ckpt ml/runs/current.pt --seed $((1000+r)) --n $N --out $D
# E2(局部极小)请老师补少量路径(唯一保留的后训练老师用途)
if [ -s $D/e2_tasks.jsonl ]; then
node ml/gen/gen_dataset.cjs --tasks $D/e2_tasks.jsonl --seed $((5000+r)) --out ml/data/e2/shard_e2_r$r.jsonl
node ml/prep/build_dataset.cjs ml/data/e2/shard_e2_r$r.jsonl ml/data/samples_e2
SAMPLES="ml/data/samples ml/data/samples_e2"
fi
POOLS=$(ls ml/runs/mine/r*/pool.jsonl)
python -m ml.apg.apg_finetune --init ml/runs/current.pt --samples $SAMPLES --pool $POOLS --out ml/runs/apg_r$r --steps $STEPS
python -m ml.mining.gate --old ml/runs/current.pt --new ml/runs/apg_r$r/best.pt --tasks ml/runs/gate_tasks.jsonl --out $D/gate.json $FIXED
$PY -m ml.apg.apg_finetune --init ml/runs/current.pt --samples $SAMPLES --pool $D/pool.jsonl --out ml/runs/apg_r$r --steps $STEPS
$PY -m ml.mining.gate --old ml/runs/current.pt --new ml/runs/apg_r$r/best.pt --tasks ml/runs/gate_tasks.jsonl --out $D/gate.json $FIXED
FIXED="--fixed $D/gate_fixed.jsonl"
gain=$(python -c "import json;print(json.load(open('$D/gate.json'))['gain_pp'])")
gain=$($PY -c "import json;print(json.load(open('$D/gate.json'))['gain_pp'])")
echo "round $r gain ${gain} pp"
if python -c "import sys;sys.exit(0 if $gain < 0.5 else 1)"; then small=$((small+1)); else small=0; fi
if $PY -c "import sys;sys.exit(0 if $gain < 0.5 else 1)"; then small=$((small+1)); else small=0; fi
if [ $small -ge 2 ]; then echo "连续 2 轮提升 < 0.5 pp,停止"; break; fi
done
echo "最终模型:ml/runs/current.pt"
+12 -5
View File
@@ -1,20 +1,27 @@
"""无老师出题(PyTorch):A 均匀 / B 直线被挡 / C 贴立柱 / D 工作空间外;pos/pose 各半。"""
"""无老师出题(PyTorch):A 均匀 / B 直线被挡 / C 贴立柱 / D 工作空间外;pos/pose 各半。
2026-10-18:无老师题没有参考时长与行程,exec_s/travel 置 None(此前写死 3.0/0.0 造成假时长预算);
闭环仿真对这类题按定长 6 s 口径判到达,time_ok_1p3 / travel_ratio 只在有老师参考的题上统计。"""
import math
import torch
from ml.common.features import wrap180
def min_gap_chunked(kin, q, cap=0.5, chunk=512):
"""分块算最小间隙:随机位形每个约 2800 个幸存胶囊对、峰值约 1.5 MB/位形,一次 4 万位形会爆内存/显存(2026-10-18 实测出题 OOM),每块 512"""
return torch.cat([kin.gaps(q[i:i + chunk], cap)['gm'].min(-1).values for i in range(0, q.shape[0], chunk)])
def rand_q(kin, n, g):
u = torch.rand(n, 6, device=kin.lo.device, generator=g)
return kin.lo + u * (kin.hi - kin.lo)
def free_q(kin, n, g, chunk=4096):
def free_q(kin, n, g, chunk=1024):
out = []
while sum(x.shape[0] for x in out) < n:
q = rand_q(kin, chunk, g)
ok = kin.gaps(q, 0.5)['gm'].min(-1).values > 0
ok = min_gap_chunked(kin, q) > 0
out.append(q[ok])
return torch.cat(out)[:n]
@@ -23,7 +30,7 @@ def straight_blocked(kin, a, b, n=24):
d = torch.where(kin.cont, wrap180(b - a), b - a)
s = torch.linspace(0, 1, n, device=a.device)
pts = (a[:, None] + s[None, :, None] * d[:, None]).reshape(-1, 6)
return (kin.gaps(pts, 0.5)['gm'].min(-1).values <= 0).view(a.shape[0], n).any(-1)
return (min_gap_chunked(kin, pts) <= 0).view(a.shape[0], n).any(-1)
def column_gap(kin, q):
@@ -44,7 +51,7 @@ def sample_tasks(kin, n, seed, mix=(0.3, 0.3, 0.3, 0.1)):
Rb = kin.orient(b, xf).reshape(-1, 9)
for i in range(q0.shape[0]):
tasks.append({'cat': cat, 'mode': 'pose' if pose[i] else 'pos', 'q0': q0[i].tolist(), 'T': Tb[i].tolist(),
'R': Rb[i].tolist() if pose[i] else None, 'exec_s': 3.0, 'travel': 0.0})
'R': Rb[i].tolist() if pose[i] else None, 'exec_s': None, 'travel': None})
if want[0]:
emit('A', free_q(kin, want[0], g), free_q(kin, want[0], g))
for cat, k in (('B', want[1]), ('C', want[2])):
+66 -27
View File
@@ -3,49 +3,88 @@
# setup 安装 Python/Node 依赖(torch 需先按 CUDA 版本装好)
# kin 导出运动学/碰撞模型 + JS↔PyTorch 对拍(必须 PASS)
# gate1 老师一致性门禁(决定生成时是否加 --consensus 4)
# gen 生成 5 万条老师轨迹(NPROC 进程;CONSENSUS=1 时加共识过滤)
# prep 打包训练样本
# train 预训练(先 2000 步摸吞吐,再正式训练)
# m5 MeanFlow 头门禁:贴立柱集关安全层穿柱率 ≤ 2%(不过 → 用 configs/wta.json 重训)
# apg APG 首轮微调
# gen 生成老师轨迹(NPROC 进程;CONSENSUS=1 时加共识过滤;样本不足 TARGET_SAMPLES 自动补生成)
# prep 打包训练样本 + R2 标签一致性检查
# train 预训练(先 2000 步摸吞吐,再正式训练);输出目录随头分开:ml/runs/<CFG 名>
# m5 头门禁:贴立柱集穿柱率 ≤ 2%(纯策略口径),同时报仿真安全层口径;通过则记为选定模型
# apg APG 首轮微调(从选定模型开始)
# mine 挖错循环(3–6 轮,含门禁回滚)
# export 导出 ONNX(fp16)到 webapp/models/
# bench 部署形态评测(Node,策略 + 安全层)
# all 依次执行 kin → bench
# export 导出 ONNX(fp32)到 webapp/models/
# bench 部署形态评测(Node,策略 + 安全层,含 R8 对抗集)
# accept 验收汇总(方案 §9.2 各项 + R2/R7/R8/R10)→ ml/runs/acceptance.json
# all 依次执行 kin → accept
# 可调环境变量:PYTHON(默认 python;Linux 无 python 时设 PYTHON=python3)、NPROC、N_TASKS、TARGET_SAMPLES、
# CFG(ml/configs/base.json | wta.json)、CKPT(覆盖后续阶段的起点模型)、CONSENSUS
# 2026-10-18:按另一台机器的审查改——头分目录不互相覆盖、统一 $PYTHON(Windows Git Bash 可跑)、
# gen 自动补量、m5 双口径、export 默认 fp32、新增 accept。
set -e
cd "$(dirname "$0")/.."
PY=${PYTHON:-python}
command -v "$PY" >/dev/null 2>&1 && "$PY" -c "import sys" 2>/dev/null || { echo "找不到可用的 Python($PY),请设 PYTHON=..."; exit 1; }
export PYTHON=$PY
NPROC=${NPROC:-$(nproc)}
N_TASKS=${N_TASKS:-50000}
TARGET_SAMPLES=${TARGET_SAMPLES:-1700000}
CFG=${CFG:-ml/configs/base.json}
TAG=$(basename "$CFG" .json)
SEL=ml/runs/selected_ckpt
ckpt() { if [ -n "${CKPT:-}" ]; then echo "$CKPT"; elif [ -f $SEL ]; then cat $SEL; else echo ml/runs/$TAG/best.pt; fi; }
stage=${1:-help}
run() { echo "== $*"; "$@"; }
case $stage in
setup) run pip install -r ml/requirements.txt; (cd ml && npm install --no-audit --no-fund) ;;
kin) run node ml/prep/export_kin.cjs; run node ml/eval/dump_parity.cjs 2000; run python -m ml.eval.check_parity ;;
setup) run $PY -m pip install -r ml/requirements.txt; (cd ml && npm install --no-audit --no-fund) ;;
kin) run node ml/prep/export_kin.cjs; run node ml/eval/dump_parity.cjs 2000; run $PY -m ml.eval.check_parity ;;
gate1) mkdir -p ml/data; run node ml/gen/consistency.cjs 300 4242 | tee ml/data/consistency.json ;;
gen) per=$(( (N_TASKS + NPROC - 1) / NPROC ))
if [ -z "${CONSENSUS:-}" ] && [ -f ml/data/consistency.json ]; then
CONSENSUS=$(python -c "import json;print(1 if json.load(open('ml/data/consistency.json'))['consistentByGateRule']<0.9 else 0)")
CONSENSUS=$($PY -c "import json;print(1 if json.load(open('ml/data/consistency.json'))['consistentByGateRule']<0.9 else 0)")
echo "一致性门禁结果 → CONSENSUS=$CONSENSUS"
fi
extra=""; [ "${CONSENSUS:-0}" = "1" ] && extra="--consensus 4"
run bash ml/gen/run_parallel.sh $per $NPROC 1 "$extra" ;;
prep) run bash ml/prep/run_prep.sh $NPROC ;;
train) run python -m ml.train --samples ml/data/samples --out ml/runs/bench --steps 2000 --no-eval --config $CFG
run python -m ml.train --samples ml/data/samples --out ml/runs/base --config $CFG ;;
m5) [ -f ml/runs/nc_gate.jsonl ] || run python -m ml.mining.mine_round --make-gate 1000 --seed 4321 --mix 0,0,1,0 --name nc_gate.jsonl --out ml/runs
run python -m ml.sim.closed_loop --ckpt ml/runs/base/best.pt --tasks ml/runs/nc_gate.jsonl --n 1000 --out ml/runs/m5_gate.json
python - <<'PY'
import json; r = json.load(open('ml/runs/m5_gate.json'))
print('贴立柱集(关安全层)穿柱率 %.2f%%,到达率 %.2f%%' % (100 * r['collision_rate'], 100 * r['success']))
print('M5 门禁:' + ('通过' if r['collision_rate'] <= 0.02 else '不通过 → CFG=ml/configs/wta.json bash ml/pipeline.sh train'))
run bash ml/gen/run_parallel.sh $per $NPROC 1 "$extra"
# 补量:共识过滤会降低保留率,样本不足 TARGET_SAMPLES 时按实测"样本/任务"补生成(最多 2 次)
s0=$((1 + NPROC))
for k in 1 2; do
need=$($PY -c "import json,math;s=json.load(open('ml/data/stats.json'));d=$TARGET_SAMPLES-s['samplesEvery3'];print(0 if d<=0 else math.ceil(d/(s['samplesEvery3']/s['tasks'])*1.05))")
[ "$need" = "0" ] && break
echo "样本不足目标 $TARGET_SAMPLES,补生成 $need 条任务"
run bash ml/gen/run_parallel.sh $(( (need + NPROC - 1) / NPROC )) $NPROC $s0 "$extra"
s0=$((s0 + NPROC))
done ;;
prep) run bash ml/prep/run_prep.sh $NPROC
run $PY -m ml.eval.check_r2 --samples ml/data/samples --out ml/data/r2.json ;;
train) run $PY -m ml.train --samples ml/data/samples --out ml/runs/bench_$TAG --steps 2000 --no-eval --config $CFG
run $PY -m ml.train --samples ml/data/samples --out ml/runs/$TAG --config $CFG ;;
m5) [ -f ml/runs/nc_gate.jsonl ] || run $PY -m ml.mining.mine_round --make-gate 1000 --seed 4321 --mix 0,0,1,0 --name nc_gate.jsonl --out ml/runs
ck=${CKPT:-ml/runs/$TAG/best.pt}
run $PY -m ml.sim.closed_loop --ckpt $ck --tasks ml/runs/nc_gate.jsonl --n 1000 --out ml/runs/m5_${TAG}_policy.json
run $PY -m ml.sim.closed_loop --ckpt $ck --tasks ml/runs/nc_gate.jsonl --n 1000 --safety --out ml/runs/m5_${TAG}_safety.json
$PY - "$TAG" "$ck" <<'PY'
import json, sys
tag, ck = sys.argv[1], sys.argv[2]
p = json.load(open(f'ml/runs/m5_{tag}_policy.json')); s = json.load(open(f'ml/runs/m5_{tag}_safety.json'))
ok = p['collision_rate'] <= 0.02
out = {'head': tag, 'ckpt': ck, 'pass': ok,
'policy_only': {k: p[k] for k in ('n', 'success', 'collision_rate', 'start_fail')},
'with_sim_safety': {k: s[k] for k in ('n', 'success', 'collision_rate', 'brakes_mean', 'start_fail')},
'note': '门禁只看 policy_only(纯策略穿柱率);with_sim_safety 为仿真近似安全层,部署口径以 bench 为准'}
json.dump(out, open(f'ml/runs/m5_gate_{tag}.json', 'w'), indent=1)
print('[%s] 纯策略穿柱率 %.2f%%、到达 %.2f%%;加仿真安全层 穿柱 %.2f%%、到达 %.2f%%' % (
tag, 100 * p['collision_rate'], 100 * p['success'], 100 * s['collision_rate'], 100 * s['success']))
if ok:
open('ml/runs/selected_ckpt', 'w').write(ck); print('M5 门禁:通过 → 后续阶段使用', ck)
else:
print('M5 门禁:不通过' + (' → CFG=ml/configs/wta.json bash ml/pipeline.sh train && CFG=ml/configs/wta.json bash ml/pipeline.sh m5' if tag != 'wta' else '(两种头都未过,把 m5_gate_*.json 发我)'))
sys.exit(3)
PY
;;
apg) run python -m ml.apg.apg_finetune --init ml/runs/base/best.pt --samples ml/data/samples --out ml/runs/apg; cp ml/runs/apg/best.pt ml/runs/current.pt ;;
apg) ck=$(ckpt); run $PY -m ml.apg.apg_finetune --init $ck --samples ml/data/samples --out ml/runs/apg; cp ml/runs/apg/best.pt ml/runs/current.pt ;;
mine) run bash ml/mining/loop.sh ${ROUNDS:-6} ${APG_STEPS:-1000} ${MINE_N:-50000} ;;
export) ck=ml/runs/current.pt; [ -f $ck ] || ck=ml/runs/base/best.pt
run python -m ml.export_onnx --ckpt $ck --out webapp/models/traj_policy.onnx --fp16 ;;
bench) run node tests/policy_bench.cjs webapp/models/traj_policy.onnx nc,route --nc1000 --out ml/runs/policy_bench.json ;;
all) for s in kin gate1 gen prep train m5 apg mine export bench; do bash ml/pipeline.sh $s; done ;;
*) sed -n '2,16p' "$0" ;;
export) ck=ml/runs/current.pt; [ -f $ck ] || ck=$(ckpt)
run $PY -m ml.export_onnx --ckpt $ck --out webapp/models/traj_policy.onnx ;;
bench) run node tests/policy_bench.cjs webapp/models/traj_policy.onnx nc,route,r8 --nc1000 --out ml/runs/policy_bench.json ;;
accept) ck=ml/runs/current.pt; [ -f $ck ] || ck=$(ckpt)
run $PY -m ml.eval.acceptance --ckpt $ck --samples ml/data/samples --out ml/runs/acceptance.json ;;
all) for s in kin gate1 gen prep train m5 apg mine export bench accept; do bash ml/pipeline.sh $s; done ;;
*) sed -n '2,20p' "$0" ;;
esac
+1 -1
View File
@@ -34,7 +34,7 @@ for (const line of lines) {
const P = r.path;
const tp = timeParam(P);
const traj = tp.traj, taus = tp.taus, N = traj.length - 1;
tasks.push({ tid, id: r.id, cat: r.cat, mode: r.mode, q0: r.q0, T: r.T, R: r.R, exec_s: N * POL.DT, travel: r.travel_deg, reached: r.reached, split });
tasks.push({ tid, id: r.id, cat: r.cat, mode: r.mode, q0: r.q0, T: r.T, R: r.R, exec_s: N * POL.DT, travel: r.travel_deg, reached: r.reached, err: r.err, split });
const vt = tp.cum; // 顶点 τ
const inter = P.slice(1, -1).map((p, i) => ({ p, tau: vt[i + 1] }));
for (let t = 0; t <= N; t += 3) {
+1 -1
View File
@@ -5,4 +5,4 @@ cd "$(dirname "$0")/../.."
P=${1:-$(nproc)}
mkdir -p ml/data/samples
ls ml/data/shard_*.jsonl | xargs -P "$P" -I{} node ml/prep/build_dataset.cjs {} ml/data/samples
python3 -m ml.data --index ml/data/samples
${PYTHON:-python} -m ml.data --index ml/data/samples
+31 -9
View File
@@ -17,11 +17,15 @@ def tasks_to_tensors(tasks, device):
q0 = torch.tensor([t['q0'] for t in tasks], dtype=torch.float32, device=device)
T = torch.tensor([t['T'] for t in tasks], dtype=torch.float32, device=device)
R = torch.tensor([t['R'] if t.get('R') else [0.] * 9 for t in tasks], dtype=torch.float32, device=device)
ex = torch.tensor([t.get('exec_s', 3.0) or 3.0 for t in tasks], dtype=torch.float32, device=device)
tr = torch.tensor([t.get('travel', 0.0) or 0.0 for t in tasks], dtype=torch.float32, device=device)
# 无老师参考(挖错/门禁题)时 exec_s/travel 为 None → NaN;调用方据此区分口径
ex = torch.tensor([t['exec_s'] if t.get('exec_s') else float('nan') for t in tasks], dtype=torch.float32, device=device)
tr = torch.tensor([t['travel'] if t.get('travel') else float('nan') for t in tasks], dtype=torch.float32, device=device)
return q0, T, R, ex, tr
NOREF_SEC = 6.0 # 无老师参考题的定长时限(s)
def goal_err(kin, q, T, R):
xf = kin.cum_xf(q)
p = kin.fk(q, xf); Rq = kin.orient(q, xf).reshape(-1, 9)
@@ -64,7 +68,8 @@ def rollout(model, kin, tasks, device, K=8, safety=False, seed=0, max_sec=None,
q0, T, R, ex, tr = tasks_to_tensors(tasks, device)
B = q0.shape[0]
step = torch.tensor(STEP, device=device)
limit = torch.clamp(2 * ex, min=3.0) if max_sec is None else torch.full_like(ex, max_sec)
has_ref = torch.isfinite(ex)
limit = torch.where(has_ref, torch.clamp(2 * ex, min=3.0), torch.full_like(ex, NOREF_SEC)) if max_sec is None else torch.full_like(ex, max_sec)
nmax = int(math.ceil(limit.max().item() / DT))
q, qp = q0.clone(), q0.clone()
queue = torch.zeros(B, 0, 6, device=device)
@@ -73,6 +78,8 @@ def rollout(model, kin, tasks, device, K=8, safety=False, seed=0, max_sec=None,
done = torch.zeros(B, dtype=torch.bool, device=device); t_done = torch.full((B,), float('nan'), device=device)
col = torch.zeros(B, device=device); travel = torch.zeros(B, device=device); brakes = torch.zeros(B, device=device)
traj = [q.clone()] if return_traj else None
n_start = int(round(0.5 / DT)); moved_start = None # R2 起步失败:0.5 s 内关节总行程 < 1°
j1_acc = torch.zeros(B, device=device); j1_max = torch.zeros(B, device=device); j1_min = torch.zeros(B, device=device)
cold = True
for it in range(nmax):
tnow = it * DT
@@ -138,6 +145,8 @@ def rollout(model, kin, tasks, device, K=8, safety=False, seed=0, max_sec=None,
d = torch.where(done[:, None], torch.zeros_like(d), d)
qp = q; q = kin.clamp_box(q + d)
travel += d.abs().sum(-1)
j1_acc = j1_acc + d[:, 0]; j1_max = torch.maximum(j1_max, j1_acc); j1_min = torch.minimum(j1_min, j1_acc)
if it + 1 == n_start: moved_start = travel.clone()
if return_traj: traj.append(q.clone())
mid = qp + 0.5 * (q - qp)
gm = kin.gaps(torch.cat([q, mid], 0), 0.5)['gm'].min(-1).values
@@ -148,26 +157,39 @@ def rollout(model, kin, tasks, device, K=8, safety=False, seed=0, max_sec=None,
t_done = torch.where(newly, torch.full_like(t_done, (it + 1) * DT), t_done); done |= newly
if done.all(): break
ok = done & (t_done <= limit)
res = {'n': B, 'success': ok.float().mean().item(),
'time_ok_1p3': (ok & (t_done <= 1.3 * ex)).float().mean().item(),
if moved_start is None: moved_start = travel.clone()
e_end, _ = goal_err(kin, q, T, R)
start_fail = (moved_start < 1.0) & ~(done & (t_done <= 0.5))
okr = ok & has_ref & torch.isfinite(tr)
res = {'n': B, 'n_ref': int(has_ref.sum()), 'success': ok.float().mean().item(),
'time_ok_1p3': (ok & has_ref & (t_done <= 1.3 * ex)).float().sum().item() / max(1, int(has_ref.sum())) if has_ref.any() else None,
'collision_rate': (col > 0).float().mean().item(), 'collision_steps_mean': col.mean().item(),
'travel_ratio_median': (travel[ok] / tr[ok].clamp(min=1)).median().item() if ok.any() and (tr > 0).any() else None,
'time_mean_s': t_done[ok].mean().item() if ok.any() else None, 'brakes_mean': brakes.mean().item()}
per = {'done': done.cpu(), 't_done': t_done.cpu(), 'col': col.cpu(), 'travel': travel.cpu()}
'travel_ratio_median': (travel[okr] / tr[okr].clamp(min=1)).median().item() if okr.any() else None,
'time_mean_s': t_done[ok].mean().item() if ok.any() else None, 'brakes_mean': brakes.mean().item(),
'start_fail': start_fail.float().mean().item(),
'time_budget': 'teacher×2(≥3 s)' if bool(has_ref.all()) else ('fixed %.0f s' % (max_sec or NOREF_SEC) if not has_ref.any() else 'mixed')}
# 绕行侧:J1 累计位移的正向/负向极值哪个更大(+1 / −1),供 R7 左右比例门禁
side = torch.where(j1_max >= -j1_min, torch.ones_like(j1_max), -torch.ones_like(j1_max))
per = {'done': done.cpu(), 't_done': t_done.cpu(), 'col': col.cpu(), 'travel': travel.cpu(), 'err': e_end.cpu(),
'side': side.cpu(), 'start_fail': start_fail.cpu(), 'brakes': brakes.cpu()}
if return_traj: per['traj'] = torch.stack(traj, 1).cpu()
return res, per
def evaluate(model, kin, tasks, device, K=8, safety=False, bs=256, seed=0):
if not tasks: # 小样本摸底时 val 可能为空
return {'n': 0, 'success': float('nan'), 'collision_rate': float('nan'), 'start_fail': float('nan'), 'brakes_mean': float('nan')}, []
agg, pers = {}, []
for i in range(0, len(tasks), bs):
r, p = rollout(model, kin, tasks[i:i + bs], device, K=K, safety=safety, seed=seed + i)
w = r['n']; pers.append((r, p))
for k, v in r.items():
if k == 'n' or v is None: continue
if k == 'n' or v is None or isinstance(v, str): continue
agg[k] = agg.get(k, 0.0) + v * w
n = sum(r['n'] for r, _ in pers)
out = {k: v / n for k, v in agg.items()}; out['n'] = n
out['n_ref'] = sum(r['n_ref'] for r, _ in pers)
out['time_budget'] = pers[0][0]['time_budget'] if len({r['time_budget'] for r, _ in pers}) == 1 else 'mixed'
return out, pers
+7 -3
View File
@@ -1,6 +1,7 @@
"""预训练(方案 §6):MeanFlow 一步头 + FK 损失 + 平滑 + 价值头 + 子目标头。
python -m ml.train --samples ml/data/samples --out ml/runs/base [--config ml/configs/base.json]
吞吐摸底:python -m ml.train --samples ml/data/samples --out ml/runs/bench --steps 2000 --no-eval
吞吐摸底:python -m ml.train --samples ml/data/samples --out ml/runs/bench_base --steps 2000 --no-eval
通过线(方案 R10):全程 60000 步 ≤ 3.5 h,即 samples_per_s ≥ 5000(3090 实测 6.2 k/s ≈ 2.8 h);日志 eta_h 为剩余小时数。
每 eval_every 步做一次闭环验证(ml/sim/closed_loop.py,部署节奏),按 success 存 best.pt;连续 patience_evals 次不升即停。"""
import argparse, json, math, os, time
import torch
@@ -54,7 +55,9 @@ def main():
amp = dev.startswith('cuda') and tc.get('amp') == 'bf16'
stepv = torch.tensor(STEP, device=dev)
fk_idx = torch.linspace(0, kin.cap_a.shape[0] - 1, tc['fk_caps'], device=dev).long()
log = open(os.path.join(a.out, 'log.jsonl'), 'a')
log = open(os.path.join(a.out, 'log.jsonl'), 'a' if a.resume else 'w') # 新训练覆盖旧日志,续训才追加
if not val_tasks and not a.no_eval:
print('没有 val 任务(样本太少),本次跳过闭环验证', flush=True); a.no_eval = True
bad_evals, t0, seen = 0, time.time(), 0
def save(name, step, extra=None):
d = {'model': model.state_dict(), 'opt': opt.state_dict(), 'cfg': cfg, 'step': step, 'best': best, 'fp': kin.fp}
@@ -95,7 +98,8 @@ def main():
if step % tc['log_every'] == 0:
el = time.time() - t0
rec = {'step': step, 'loss': loss.item(), 'mf': mf_raw.item(), 'fk_cm': l_fk.item(), 'smooth': l_sm.item(),
'value': l_val.item(), 'sub': l_sub.item(), 'gnorm': gn.item(), 'lr': lr_at(step), 'samples_per_s': seen / max(el, 1e-6)}
'value': l_val.item(), 'sub': l_sub.item(), 'gnorm': gn.item(), 'lr': lr_at(step), 'samples_per_s': seen / max(el, 1e-6),
'eta_h': (total - step - 1) * tc['batch'] / max(seen / max(el, 1e-6), 1e-6) / 3600}
print(json.dumps(rec), flush=True); log.write(json.dumps(rec) + '\n'); log.flush()
if not a.no_eval and (step + 1) % tc['eval_every'] == 0:
model.eval()
+36 -7
View File
@@ -1,8 +1,11 @@
/* 部署形态评测(方案 §9):策略 + 安全层(webapp/js/ik-policy.js 的 Runner,与浏览器同一份代码)
* node tests/policy_bench.cjs [model=webapp/models/traj_policy.onnx] [sets=nc,route] [--nc1000] [--out file.json]
* node tests/policy_bench.cjs [model=webapp/models/traj_policy.onnx] [sets=nc,route,r8] [--nc1000] [--nc-n 30] [--route-n 40] [--r8-n 50] [--out file.json]
* 集合:nc = near_column_bench 同款 30 个贴立柱任务(pos 与 pose 各跑一遍)
* route = route_bench 同款 40 个直线被挡任务(pos)
* nc1000= 一次性扩展贴立柱集 1000 例(新种子,验收用;加 --nc1000 开启)
* r8 = 方案 R8 对抗集 50 例:J1 起终差 ≥ 140°、直线被挡、终点贴立柱(两侧都要绕远);
* 恢复 = 首次换向锁后 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 实测)。
* 依赖:cd ml && npm install(onnxruntime-node) */
@@ -11,7 +14,8 @@ const fs = require('fs'), path = require('path');
const argv = process.argv.slice(2);
const flag = k => argv.includes(k);
const opt = k => { const i = argv.indexOf(k); return i >= 0 ? argv[i + 1] : null; };
const pos = argv.filter((a, i) => !a.startsWith('--') && !(i > 0 && argv[i - 1].startsWith('--out')));
const VALOPT = new Set(['--out', '--nc-n', '--route-n', '--r8-n']);
const pos = argv.filter((a, i) => !a.startsWith('--') && !(i > 0 && VALOPT.has(argv[i - 1])));
const MODEL = pos[0] || path.join(__dirname, '../webapp/models/traj_policy.onnx');
const SETS = (pos[1] || 'nc,route').split(',').concat(flag('--nc1000') ? ['nc1000'] : []);
let ort; try { ort = require(path.join(__dirname, '../ml/node_modules/onnxruntime-node')); } catch (e) { ort = require('onnxruntime-node'); }
@@ -40,6 +44,25 @@ function ncCases(N, seed) {
}
return cases;
}
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); });
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;
for (let t = 0; t < 200; t++) {
const a = rq(); if (!S.collideFree(a)) continue;
if (Math.abs(d(a, b)[0]) < 140 || segOK(a, b)) continue;
cases.push({ q0: a, T: S.fk(b, 0), R: null }); break;
}
}
return cases;
}
function routeCases(N) {
const rnd = lcg(12345);
const rq = () => O.map(id => { const t = L[id]; return t.lo + rnd() * (t.hi - t.lo); });
@@ -57,15 +80,17 @@ const pct = (a, p) => { if (!a.length) return null; const s = a.slice().sort((x,
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; const inf = [], saf = [], times = [], fails = [];
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 = [];
for (let ci = 0; ci < cases.length; ci++) {
const c = cases[ci];
const R = mode === 'pose' ? c.R : null;
const r = new POL.Runner(S, infer, { seed: 1000 + ci });
r.reset(c.q0); r.setGoal(c.T, R);
let prev = c.q0.slice(), cf = 0;
let prev = c.q0.slice(), cf = 0, tLock = null;
for (let k = 0; k < 500 && !r.done; k++) {
if (r.needPlan()) await r.plan();
if (tLock == null && r.stats.locks > 0) tLock = r.t;
const q = r.tick();
const mid = prev.map((v, j) => 0.5 * (v + q[j]));
if (!S.collideFree(q) || !S.collideFree(mid)) cf++;
@@ -75,15 +100,19 @@ 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;
inf.push(...r.stats.inferMs); saf.push(...r.stats.safetyMs); 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) };
console.error(name, JSON.stringify({ ok, n: cases.length, collisionFrames: colFrames }));
};
if (SETS.includes('nc')) { const c = ncCases(+opt('--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('--n') || 40), 'pos');
if (SETS.includes('nc')) { const c = ncCases(+opt('--nc-n') || 30, 20261017); await runSet('near_column_pos', c, 'pos'); await runSet('near_column_pose', c, 'pose'); }
if (SETS.includes('route')) await runSet('route40', routeCases(+opt('--route-n') || 40), 'pos');
if (SETS.includes('r8')) await runSet('r8_adversarial', r8Cases(+opt('--r8-n') || 50, 8888), 'pos');
if (SETS.includes('nc1000')) await runSet('near_column_1000', ncCases(1000, 1), 'pos');
const s = JSON.stringify(out, null, 1);
console.log(s);
+2 -1
View File
@@ -171,7 +171,8 @@ function makeRng(seed) {
class Runner {
constructor(S, infer, opts) {
this.S = S; this.infer = infer;
this.o = Object.assign({ K: 8, commit: 3, margin: 5, projMax: 0.5, refineMm: 5, arriveMm: 1, arriveDeg: 0.5,
this.o = Object.assign({ K: 8, commit: 3, margin: 3, /* 2026-10-18:投影余量 5→3 mm,与 APG margin_train 一致(老师最小间隙 p50 4.4 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(超出按刹停处理)
this.cert = makeCert(S, { leverTable: this.o.leverTable, cap: 20 });