diff --git a/docs/experiment_round42_K8cb1_dump.json b/docs/experiment_round42_K8cb1_dump.json new file mode 100644 index 0000000..49f045e --- /dev/null +++ b/docs/experiment_round42_K8cb1_dump.json @@ -0,0 +1,107 @@ +{ + "config": { + "ckpt": "ml/runs/base_dag2_dag1/best.pt", + "samples": "ml/data/samples", + "tasks": null, + "split": "val", + "n": 256, + "K": 8, + "commit": 2, + "fb": 2.2, + "ki": 0.0, + "fb_scale": 0.0, + "fb_near": 0.2, + "fb_fallback": 0.0, + "kdecay": 0.9, + "kimax": 0.5, + "fb_every": 1, + "safety_try": 0, + "sel_w": 0.1, + "dls_safety": 1, + "fb_clear": 0.0, + "fb_cmin": 0.3, + "sub_refresh": 0, + "fb_block_gain": 0.5, + "dump": "ml/runs/round42_K8cb1_rows.json", + "out": "docs/experiment_round42_K8cb1_dump.json", + "trace": null, + "select": "endpoint", + "replan": "empty", + "safety": 1, + "partial": 1, + "inc_cap": 1, + "gap_margin": 0.0, + "cert_batch": 1, + "steps": 1, + "z_scale": 1.0, + "len_w": 0.0, + "backoff": 0, + "max_sec": null, + "seed": 0, + "dls": "analytic", + "device": "cuda" + }, + "batch_size": 256, + "batch_seed_rule": "same seed per batch", + "checkpoint_sha256": "f7f686fd8ee92fb6948fbcc4ee954636065ad87bb54f661c4f8f575bc16c2ce1", + "tasks_sha256": "249bd93457825cda98c59e64d9a096d3473c69aeb3553de1dea6d9e56be71772", + "kin_fp": "2ebcbf17", + "act_scale": 1.0, + "metrics": { + "n": 256, + "n_ref": 256, + "success": 0.5234375, + "collision_free_success": 0.5234375, + "time_ok_1p3": 0.265625, + "collision_rate": 0.0, + "collision_steps_mean": 0.0, + "travel_ratio_median": 2.615795850753784, + "time_mean_s": 2.486567258834839, + "brakes_mean": 19.5546875, + "start_fail": 0.0, + "goal_ratio_2s": 0.27920001745224, + "err_fail_med": 204.71360778808594, + "err_success_med": 0.21751396358013153, + "dls_share": 0.61328125, + "time_budget": "teacher×2(≥3 s)", + "speed_ratio_p50": 1.0000016689300537, + "speed_ratio_p95": 1.0000101327896118, + "speed_ratio_max": 1.0000101327896118, + "over_vmax_rate": 0.0, + "accel_ratio_p95": 1.0000016689300537, + "accel_ratio_max": 1.0000101327896118, + "reverse_p50": 0.24741201102733612, + "reverse_mean": 0.25991496443748474, + "reverse_moving_p50": 0.32581019401550293, + "moving_share_p50": 0.9587156176567078, + "brake_rate_mean": 0.10028044879436493, + "brake_rate_p50": 0.0051282052882015705, + "brake_rate_p90": 0.3153846263885498, + "stuck_share": 0.234375, + "n_plans_mean": 195.0, + "success_reachable": 0.5514403581619263, + "by_cat": { + "A": [ + 128, + 96 + ], + "B": [ + 74, + 20 + ], + "C": [ + 32, + 11 + ], + "D": [ + 13, + 0 + ], + "Dr": [ + 9, + 7 + ] + } + }, + "simulation_wall_s": 397.44336470001144 +} diff --git a/docs/experiment_round44_tieprobe64.json b/docs/experiment_round44_tieprobe64.json new file mode 100644 index 0000000..5e00d3e --- /dev/null +++ b/docs/experiment_round44_tieprobe64.json @@ -0,0 +1,111 @@ +{ + "config": { + "ckpt": "ml/runs/base_dag2_dag1/best.pt", + "samples": "ml/data/samples", + "tasks": null, + "split": "val", + "n": 64, + "K": 8, + "commit": 2, + "fb": 2.2, + "ki": 0.0, + "fb_scale": 0.0, + "fb_near": 0.2, + "fb_fallback": 0.0, + "kdecay": 0.9, + "kimax": 0.5, + "fb_every": 1, + "safety_try": 0, + "sel_w": 0.1, + "dls_safety": 1, + "fb_clear": 0.0, + "fb_cmin": 0.3, + "sub_refresh": 0, + "fb_block_gain": 0.5, + "dump": "ml/runs/round44_tieprobe64_rows.json", + "out": "docs/experiment_round44_tieprobe64.json", + "trace": null, + "select": "endpoint", + "replan": "empty", + "safety": 1, + "partial": 1, + "inc_cap": 1, + "gap_margin": 0.0, + "cert_batch": 1, + "steps": 1, + "z_scale": 1.0, + "len_w": 0.0, + "tie": 1, + "backoff": 0, + "max_sec": null, + "seed": 0, + "dls": "analytic", + "device": "cuda" + }, + "batch_size": 256, + "batch_seed_rule": "same seed per batch", + "checkpoint_sha256": "f7f686fd8ee92fb6948fbcc4ee954636065ad87bb54f661c4f8f575bc16c2ce1", + "tasks_sha256": "6185a312dbae3bf5b361c4b9ff3aa11b7329d9723cff2be9b9157563d6b6ec1c", + "kin_fp": "2ebcbf17", + "act_scale": 1.0, + "metrics": { + "n": 64, + "n_ref": 64, + "success": 0.40625, + "collision_free_success": 0.40625, + "time_ok_1p3": 0.234375, + "collision_rate": 0.0, + "collision_steps_mean": 0.0, + "travel_ratio_median": 2.358931303024292, + "time_mean_s": 2.3323075771331787, + "brakes_mean": 18.71875, + "start_fail": 0.0, + "goal_ratio_2s": 0.33309799432754517, + "err_fail_med": 229.02410888671875, + "err_success_med": 0.22094391286373138, + "dls_share": 0.515625, + "time_budget": "teacher×2(≥3 s)", + "speed_ratio_p50": 1.0000016689300537, + "speed_ratio_p95": 1.0000091791152954, + "speed_ratio_max": 1.0000101327896118, + "over_vmax_rate": 0.0, + "accel_ratio_p95": 1.0000016689300537, + "accel_ratio_max": 1.0000101327896118, + "reverse_p50": 0.25150150060653687, + "reverse_mean": 0.25861242413520813, + "reverse_moving_p50": 0.318452388048172, + "moving_share_p50": 0.9090909361839294, + "brake_rate_mean": 0.10696429014205933, + "brake_rate_p50": 0.017142856493592262, + "brake_rate_p90": 0.33142855763435364, + "stuck_share": 0.21875, + "n_plans_mean": 175.0, + "success_reachable": 0.4262295067310333, + "by_cat": { + "A": [ + 28, + 14 + ], + "B": [ + 21, + 6 + ], + "C": [ + 9, + 4 + ], + "D": [ + 3, + 0 + ], + "Dr": [ + 3, + 2 + ] + }, + "tie_opp_rate_mean": 0.7484821081161499, + "tie_changed_rate_mean": 0.07437500357627869, + "tie_opp_task_share": 1.0 + }, + "simulation_wall_s": 78.19308080000337 +} diff --git a/docs/experiment_round45_tie0.json b/docs/experiment_round45_tie0.json new file mode 100644 index 0000000..e43f8a7 --- /dev/null +++ b/docs/experiment_round45_tie0.json @@ -0,0 +1,108 @@ +{ + "config": { + "ckpt": "ml/runs/base_dag2_dag1/best.pt", + "samples": "ml/data/samples", + "tasks": null, + "split": "val", + "n": 256, + "K": 8, + "commit": 2, + "fb": 2.2, + "ki": 0.0, + "fb_scale": 0.0, + "fb_near": 0.2, + "fb_fallback": 0.0, + "kdecay": 0.9, + "kimax": 0.5, + "fb_every": 1, + "safety_try": 0, + "sel_w": 0.1, + "dls_safety": 1, + "fb_clear": 0.0, + "fb_cmin": 0.3, + "sub_refresh": 0, + "fb_block_gain": 0.5, + "dump": "ml/runs/round45_tie0_rows.json", + "out": "docs/experiment_round45_tie0.json", + "trace": null, + "select": "endpoint", + "replan": "empty", + "safety": 1, + "partial": 1, + "inc_cap": 1, + "gap_margin": 0.0, + "cert_batch": 1, + "steps": 1, + "z_scale": 1.0, + "len_w": 0.0, + "tie": 0, + "backoff": 0, + "max_sec": null, + "seed": 0, + "dls": "analytic", + "device": "cuda" + }, + "batch_size": 256, + "batch_seed_rule": "same seed per batch", + "checkpoint_sha256": "f7f686fd8ee92fb6948fbcc4ee954636065ad87bb54f661c4f8f575bc16c2ce1", + "tasks_sha256": "249bd93457825cda98c59e64d9a096d3473c69aeb3553de1dea6d9e56be71772", + "kin_fp": "2ebcbf17", + "act_scale": 1.0, + "metrics": { + "n": 256, + "n_ref": 256, + "success": 0.5234375, + "collision_free_success": 0.5234375, + "time_ok_1p3": 0.265625, + "collision_rate": 0.0, + "collision_steps_mean": 0.0, + "travel_ratio_median": 2.615795850753784, + "time_mean_s": 2.486567258834839, + "brakes_mean": 19.5546875, + "start_fail": 0.0, + "goal_ratio_2s": 0.27920001745224, + "err_fail_med": 204.71360778808594, + "err_success_med": 0.21751396358013153, + "dls_share": 0.61328125, + "time_budget": "teacher×2(≥3 s)", + "speed_ratio_p50": 1.0000016689300537, + "speed_ratio_p95": 1.0000101327896118, + "speed_ratio_max": 1.0000101327896118, + "over_vmax_rate": 0.0, + "accel_ratio_p95": 1.0000016689300537, + "accel_ratio_max": 1.0000101327896118, + "reverse_p50": 0.24741201102733612, + "reverse_mean": 0.25991496443748474, + "reverse_moving_p50": 0.32581019401550293, + "moving_share_p50": 0.9587156176567078, + "brake_rate_mean": 0.10028044879436493, + "brake_rate_p50": 0.0051282052882015705, + "brake_rate_p90": 0.3153846263885498, + "stuck_share": 0.234375, + "n_plans_mean": 195.0, + "success_reachable": 0.5514403581619263, + "by_cat": { + "A": [ + 128, + 96 + ], + "B": [ + 74, + 20 + ], + "C": [ + 32, + 11 + ], + "D": [ + 13, + 0 + ], + "Dr": [ + 9, + 7 + ] + } + }, + "simulation_wall_s": 317.56223549999413 +} diff --git a/docs/experiment_round45_tie1.json b/docs/experiment_round45_tie1.json new file mode 100644 index 0000000..78837af --- /dev/null +++ b/docs/experiment_round45_tie1.json @@ -0,0 +1,111 @@ +{ + "config": { + "ckpt": "ml/runs/base_dag2_dag1/best.pt", + "samples": "ml/data/samples", + "tasks": null, + "split": "val", + "n": 256, + "K": 8, + "commit": 2, + "fb": 2.2, + "ki": 0.0, + "fb_scale": 0.0, + "fb_near": 0.2, + "fb_fallback": 0.0, + "kdecay": 0.9, + "kimax": 0.5, + "fb_every": 1, + "safety_try": 0, + "sel_w": 0.1, + "dls_safety": 1, + "fb_clear": 0.0, + "fb_cmin": 0.3, + "sub_refresh": 0, + "fb_block_gain": 0.5, + "dump": "ml/runs/round45_tie1_rows.json", + "out": "docs/experiment_round45_tie1.json", + "trace": null, + "select": "endpoint", + "replan": "empty", + "safety": 1, + "partial": 1, + "inc_cap": 1, + "gap_margin": 0.0, + "cert_batch": 1, + "steps": 1, + "z_scale": 1.0, + "len_w": 0.0, + "tie": 1, + "backoff": 0, + "max_sec": null, + "seed": 0, + "dls": "analytic", + "device": "cuda" + }, + "batch_size": 256, + "batch_seed_rule": "same seed per batch", + "checkpoint_sha256": "f7f686fd8ee92fb6948fbcc4ee954636065ad87bb54f661c4f8f575bc16c2ce1", + "tasks_sha256": "249bd93457825cda98c59e64d9a096d3473c69aeb3553de1dea6d9e56be71772", + "kin_fp": "2ebcbf17", + "act_scale": 1.0, + "metrics": { + "n": 256, + "n_ref": 256, + "success": 0.50390625, + "collision_free_success": 0.50390625, + "time_ok_1p3": 0.27734375, + "collision_rate": 0.0, + "collision_steps_mean": 0.0, + "travel_ratio_median": 2.53989315032959, + "time_mean_s": 2.3586044311523438, + "brakes_mean": 17.7890625, + "start_fail": 0.0, + "goal_ratio_2s": 0.27920001745224, + "err_fail_med": 213.06358337402344, + "err_success_med": 0.22968679666519165, + "dls_share": 0.62109375, + "time_budget": "teacher×2(≥3 s)", + "speed_ratio_p50": 1.0000016689300537, + "speed_ratio_p95": 1.0000101327896118, + "speed_ratio_max": 1.0000101327896118, + "over_vmax_rate": 0.0, + "accel_ratio_p95": 1.0000016689300537, + "accel_ratio_max": 1.0000101327896118, + "reverse_p50": 0.25315314531326294, + "reverse_mean": 0.26444095373153687, + "reverse_moving_p50": 0.3198198080062866, + "moving_share_p50": 0.9772727489471436, + "brake_rate_mean": 0.09122596681118011, + "brake_rate_p50": 0.0, + "brake_rate_p90": 0.3282051384449005, + "stuck_share": 0.203125, + "n_plans_mean": 195.0, + "success_reachable": 0.5308641791343689, + "by_cat": { + "A": [ + 128, + 91 + ], + "B": [ + 74, + 19 + ], + "C": [ + 32, + 12 + ], + "D": [ + 13, + 0 + ], + "Dr": [ + 9, + 7 + ] + }, + "tie_opp_rate_mean": 0.7898838520050049, + "tie_changed_rate_mean": 0.055829327553510666, + "tie_opp_task_share": 1.0 + }, + "simulation_wall_s": 283.46215640001174 +} diff --git a/docs/探索日志.md b/docs/探索日志.md index 60b08c8..a88481d 100644 --- a/docs/探索日志.md +++ b/docs/探索日志.md @@ -31,7 +31,8 @@ | 序 | 候选 | 支撑证据 | 状态 | |---|---|---|---| -| 0 | **把"沿路最小间隙"接进候选打分**(#24) | 安全层的拒绝是真实几何(端点/中点穿模),而 `cert_batch=1` 已经在同一次 gaps 调用里算了 K 条候选各 `2·commit_n` 个点的间隙——**这些信息目前只用来判通过/拒绝,没进打分**。做法:`score = −e_end − w·relu(gap_ref − min_gap_path)`,让"会穿模"的候选在排序阶段就出局,而不是等安全层全拒。代价为零(同一批数),判据完全不放松 | 待实现(链跑完后动库) | +| 0 | ~~把"沿路最小间隙"接进候选打分~~(极端版 = `--tie 1`:只在"前缀最长"的并列集合内按余量定夺,零额外几何) | 轮次 45 两臂:到达 −1.95 pp(净 −5 题,McNemar p=0.125 与零不可分)、时限 +1.17 pp、刹停 −9%、行程 −0.076、**碰撞仍 0**;且分辨率被 `gcap=0.5 mm` 截顶锁死(并列 79% 但只有 5.6% 可分辨,与轮次 44 离线 5.6% 对上) | **判掉**:买余量卖进度,且作用在 `maxlen>0` 的人群——失败主人群是全拒(`maxlen=0`)。前置条件见 #28 | +| 0b | **#28 抬宽相位截顶 `gcap` 并与 `gap_margin` 解耦** | 现在 `--gap_margin 1.5` 会同时抬截顶值和验收门限,动一个必动另一个;`_coarse` 保留 `d ≤ cp_lim + cap` 的胶囊对 ⇒ 成本随壳层体积涨,而 GPU 周期 P50 已 33.5 ms | 未实现(要新增只改数值精度的参数,不许碰判据) | | 1 | **候选数翻倍 K=8→16** | 轮次 39 实测:到达 **+3.9 pp**、时限内 **+3.1 pp**、**刹车 −30%**(19.55→13.77)、碰撞仍 0、行程比不变 ⇒ 很多"全拒"是**没备选**而不是真没路 | **正向**;认证/打分成本要用独占微基准量完才能谈部署 | | 2 | **全拒时缩步顶上**(`--backoff`:首选候选按 1/2、1/4、1/8 并进同一次认证,只在原候选连第 1 步都不安全时启用) | 失败分类 63/109 是"被冻住"、未到组 37% 步为零运动;但 4 题 CPU 冒烟提示转化率只 ~13% | 代码与单测已落,256 题四臂待补跑(判读规则已预注册) | | 3 | **白费比例惩罚**(`select=ep_waste --len_w`,罚 `1−同轴净/总` × 当前误差) | 换向是**全局**现象(~1/4 的(步,轴)对在反向,到达/未到达两组差不多);净位移只占总行程 0.445 | 已实现+正对照单测(可手算 2/18);三臂排队 | @@ -108,6 +109,29 @@ - **对 `backoff` 的预期与判据**:全拒意味着"连第 1 步的端点或中点已经穿进障碍",缩步能不能救取决于**穿得有多深**(穿 0.1 mm 救得回,穿 3 mm 救不回)。这个数转储里没记(只记被选中那条),所以直接由 `backoff_mean`/`reject_all_mean` 来量: `reject_all_mean ≈ 原全拒次数`(=backoff_mean+brakes_mean,与轮次 33 cap 的 19.55 可比),`backoff_mean/(backoff_mean+brakes_mean)` 就是"缩步能救回的比例"。这是补跑(#22)要读的第一个数。 +## 轮次 45:`tie`(等长安全前缀按沿路最小间隙定夺)判掉——它买的是余量,卖的是进度,而且作用在错的人群上 + +- **做法**:`cert_batch=1` 已经在同一次几何调用里算了 K 条候选各 `2·commit_n` 个点的间隙,但这些信息只用来判通过/拒绝。`--tie 1` 让"前缀最长"的并列集合改由**沿路最小间隙**定夺;零额外几何调用,判据一个字没动(仍是 `gap > gap_margin`)。 +- **先测触发率再花钱**(阈值预注册:<1% 直接判掉,≥5% 才跑两臂)。n=64 探针:并列率 `tie_opp_rate=74.8%`、真的改选率 `tie_changed_rate=7.4%` ⇒ 过线,才投 256 题两臂。 +- **两臂 A/B(n=256、同 seed、同窗口背靠背,配方=轮次 42 的 `K8 + cert_batch=1 + inc_cap=1`)**: + + | 读数 | A `tie=0` | B `tie=1` | 差 | + |---|---|---|---| + | 到达 / 无碰撞到达 | 0.5234 | 0.5039 | **−1.95 pp** | + | 时限内(≤1.3×老师) | 0.2656 | 0.2773 | +1.17 pp | + | 执行碰撞率 | 0.0 | **0.0** | 判据未放松(硬门槛通过) | + | 刹停/题 | 19.55 | 17.79 | −9.0% | + | 卡住题占比(刹停>20%) | 23.4% | 20.3% | −3.1 pp | + | 行程比中位 | 2.6158 | 2.5399 | −0.076 | + | 到达均值用时 | 2.487 s | 2.359 s | −0.128 s | + +- **统计诚实**:逐题配对**丢 6 题、赢 1 题,净 −5**;McNemar 精确双侧 **p = 0.125** ⇒ 到达差异在这个分母上与零不可分。所以结论不是"它显著变差",而是**没有任何正向证据,且方向为负**;而刹停/行程/用时全部朝"更保守"的方向动,与到达同向恶化是一个自洽的签名,不是噪声。丢的 6 题里 **5 题在 A 类(开阔均匀区)**,唯一赢的那题在 C 类(贴立柱)。 +- **仪表与独立离线数对上了(这条比 A/B 本身更有说服力)**:本轮 256 题的改选率 **5.58%**,与轮次 44 纯离线量到的"执行点间隙落在 0.2–0.5 mm 的占比 **5.6%**"逐位同量级;并列率 79.0% 对上"满前缀 79.4%"。⇒ **余量能分辨的上界就是"有多少执行点真的贴进 0.5 mm 以内"**,被宽相位截顶 `gcap = max(0.5, gap_margin+0.5)` 锁死(凡沿途间隙都 >0.5 mm 的候选,`min_gap` 恒等于 0.5,排序上完全并列)。所以 5.6% 是这个家族在 cap=0.5 下的**天花板**,不是实现没调好。 +- **机理判读**:并列集合里"余量最大"的那条通常也是"位移最小"的那条。默认口径由选择器分数(终点误差)定夺 = 用进度换余量的风险由策略承担;`tie=1` 把判据整个换成余量 ⇒ 在根本没有障碍的 A 类纯亏进度。正确形态若要保留这条线,只能是"进度 + 余量"的加权,而加权又要一个新的自由系数——在当前 −5 的背景下不值得一轮。 +- **结构性发现(否掉的是实现,更是提问方式)**:#24 从提出起就**作用在错的人群**。失败主人群是"全拒(`maxlen=0`)"——未到达题 22.0% 的规划连第 1 步都不安全;而 `tie` 只在 `maxlen>0` 时触发。全拒人群的杠杆是 `backoff`(轮次 43 已判掉:能救回停步、救不回通路)。⇒ "选哪条活路"这件事,对已经走通的路影响很小,对走不通的路毫无影响。 +- **本轮踩到并修掉的实现洞**:tie 分支里我用了局部名 `nmax`,把主循环的"总步数"顶掉 ⇒ 进度回调 `%d` 收到张量直接炸(第一次 n=64 探针就是这么失败的)。修法是改名 `ntile`,并补一条守卫单测(tie=1 必须带 `progress` 跑通且 `total` 保持标量);**守卫做过变异检查**——把 `ntile` 换回 `nmax`,守卫立刻变红。计数器的正对照也可手算:夹具里两条候选永远全安全 ⇒ `tie_opp = tie_changed = n_plans`、两个比率恒 1。47 项测试全绿。 +- **不采纳**。前置条件记为 #28:先把"数值截顶 `gcap`"与"验收门限 `gap_margin`"解耦(现在 `--gap_margin 1.5` 会同时抬截顶值和门限,动一个必动另一个),再量 cap=0.5/2/5 mm 下的分辨率与几何耗时(`_coarse` 保留 `d ≤ cp_lim + cap` 的胶囊对,成本随壳层体积涨)。**但即使分辨率上来,人群错配也不会被修好**,所以这条只作为"贴边题(C 类)里选哪条活路"的低优先候选保留。 + ## #27:K=16 的增益**不集中在 B/C**——B 类缺口是策略能力,不是候选容量(轮次 44,逐题 dump) - K=8 与 K=16(都 `--cert_batch 1 --inc_cap 1`,同 256 题同 seed)逐题对齐后按类别: diff --git a/ml/eval/eval_cl.py b/ml/eval/eval_cl.py index 695b363..8ff4498 100644 --- a/ml/eval/eval_cl.py +++ b/ml/eval/eval_cl.py @@ -68,6 +68,10 @@ def run(model, kin, tasks, device, bs=256, **kw): if 'backoff' in flat and flat['backoff'].any(): # backoff=缩步顶上的次数;brakes 现在只记连最小档都不安全的 out['backoff_mean'] = flat['backoff'].mean().item() # ⇒ 原来的"全拒"事件数 = 两者之和 out['reject_all_mean'] = (flat['backoff'] + flat['brakes']).mean().item() + if 'tie_opp' in flat and flat['tie_opp'].any(): # tie=1 的自检:钩子可用率/真的改选率,分母与 brake_rate 同为规划次数 + out.update({'tie_opp_rate_mean': flat['tie_opp_rate'].mean().item(), + 'tie_changed_rate_mean': flat['tie_changed_rate'].mean().item(), + 'tie_opp_task_share': (flat['tie_opp'] > 0).float().mean().item()}) return out, per @@ -115,6 +119,7 @@ def main(): ap.add_argument('--steps', type=int, default=1, help='MeanFlow 采样段数;1=线上一口径(历史发布数字全是 1)') ap.add_argument('--z_scale', type=float, default=1.0, help='候选噪声缩放:>1 让 K 条候选更分散(测"全拒是没有备选"还是"真的没路")') ap.add_argument('--len_w', type=float, default=0.0, help="ep_len=罚累计归一行程;ep_waste=罚(1−净/总)×当前误差;0 与 endpoint 逐位相同") + ap.add_argument('--tie', type=int, default=0, help='1=等长安全前缀时按沿路最小间隙定夺(需 --cert_batch 1)') ap.add_argument('--backoff', type=int, default=0, help='全拒时把首选候选按 1/2、1/4…缩步重试的档数(需 --cert_batch 1)') ap.add_argument('--max_sec', type=float, default=None) ap.add_argument('--seed', type=int, default=0) @@ -142,7 +147,7 @@ def main(): safety_partial=(a.commit if (a.safety and a.partial) else 0), commit_n=a.commit, select=a.select, replan=a.replan, fb_gain=a.fb, fb_scale=a.fb_scale, fb_near=a.fb_near, fb_fallback=a.fb_fallback, fb_ki=a.ki, fb_decay=a.kdecay, fb_imax=a.kimax, fb_every=a.fb_every, safety_try=a.safety_try, sel_w=a.sel_w, dls_safety=a.dls_safety, fb_clear=a.fb_clear, fb_cmin=a.fb_cmin, sub_refresh=a.sub_refresh, fb_block_gain=a.fb_block_gain, - inc_cap=a.inc_cap, gap_margin=a.gap_margin, cert_batch=a.cert_batch, backoff=a.backoff, len_w=a.len_w, return_traj=bool(a.trace)) + inc_cap=a.inc_cap, gap_margin=a.gap_margin, cert_batch=a.cert_batch, backoff=a.backoff, len_w=a.len_w, tie=a.tie, return_traj=bool(a.trace)) wall = time.perf_counter() - t0 result = {**meta, 'metrics': r, 'simulation_wall_s': wall} print(json.dumps(json_finite(result), ensure_ascii=False, allow_nan=False), flush=True) @@ -150,7 +155,8 @@ def main(): Path(a.out).write_text(json.dumps(json_finite(result), indent=2, ensure_ascii=False, allow_nan=False) + '\n', encoding='utf-8') if a.dump: rows = [] - keys = ['done', 'ok', 't_done', 'limit', 'col', 'brakes', 'err', 'first_hit', 'dls_it', 'goal_ratio_2s', 'brake_rate', 'n_plans'] + keys = ['done', 'ok', 't_done', 'limit', 'col', 'brakes', 'err', 'first_hit', 'dls_it', 'goal_ratio_2s', 'brake_rate', 'n_plans', + 'tie_opp', 'tie_changed', 'tie_opp_rate', 'tie_changed_rate'] flat = {k: torch.cat([p[k] for _, p, _ in pers]).tolist() for k in keys} for i, t in enumerate(tasks): rows.append({**t, 'i': i, **{k: flat[k][i] for k in keys}}) diff --git a/ml/sim/closed_loop.py b/ml/sim/closed_loop.py index 263f702..74fb413 100644 --- a/ml/sim/closed_loop.py +++ b/ml/sim/closed_loop.py @@ -163,7 +163,7 @@ def rollout(model, kin, tasks, device, K=8, safety=False, seed=0, max_sec=None, qp0=None, steps=1, z_scale=1.0, select='consist', oracle=None, replan='r3', sub_rule='cross', fb_gain=0.0, fb_scale=0.0, fb_near=0.2, fb_fallback=0.0, safety_partial=0, commit_n=3, collect=None, fb_exec=0.0, exec_safety=0, fb_ki=0.0, fb_decay=0.9, fb_imax=0.5, fb_every=1, safety_try=0, sel_w=0.1, dls_safety=0, fb_clear=0.0, fb_cmin=0.3, sub_refresh=0, fb_block_gain=1.0, - inc_cap=0, gap_margin=0.0, cert_batch=0, backoff=0, len_w=0.0, progress=None): + inc_cap=0, gap_margin=0.0, cert_batch=0, backoff=0, len_w=0.0, tie=0, progress=None): """use_sub=False:不用子目标(sf=0);sub_override=(subs [B,M,6] 绝对关节角, n [B] 个数 或 need [B] bool):冷启动时用给定途经点代替子目标头; qp0 [B,6]:起点的上一帧(默认 = q0,即静止起步)。steps:MeanFlow 采样段数;z_scale:噪声缩放; select:consist(部署口径:冷启动价值前瞻、之后与上一条已提交计划的一致性)/ value(每次都价值前瞻)/ first(不选,取第 0 条)。 @@ -174,10 +174,11 @@ def rollout(model, kin, tasks, device, K=8, safety=False, seed=0, max_sec=None, gap_margin>0:安全层要求间隙大于该毫米余量而不是 >0;宽相位 cap 自动抬到 margin+0.5,否则 0.5 的截顶值会让余量门限恒假。 backoff>0:全拒(连第 1 步都不安全)时不再站住,把首选候选按 1/2、1/4…(共 backoff 档)缩步一起送进同一次认证, 取能通过的第 1 档执行;缩步候选不参与"前缀更长者优先"的比较,只在原候选全被拒时顶上。需 cert_batch=1。 + tie=1:等长安全前缀之间改用「沿路最小间隙更大」定夺(默认 0=沿用选择器分数顺序)。需 cert_batch=1。 select='ep_len' + len_w>0:在末态误差打分上再减 len_w·Σ|Δ|/STEP(每轴归一的候选累计行程)。§9.2 要行程≤规划器×1.10, 而实测行程比 2.4~2.6 没有任何机制在管;len_w=0 与 select='endpoint' 逐位相同(有回归断言)。 两者默认值下行为与第32轮逐位相同。""" - if backoff and not cert_batch: raise ValueError('backoff 目前只实现在 cert_batch=1 口径') + if (backoff or tie) and not cert_batch: raise ValueError('backoff/tie 目前只实现在 cert_batch=1 口径') g = torch.Generator(device=device); g.manual_seed(seed) q0, T, R, ex, tr = tasks_to_tensors(tasks, device) B = q0.shape[0] @@ -199,6 +200,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) bo = torch.zeros(B, device=device) # 缩步顶上的次数(backoff>0 才会计) + to = torch.zeros(B, device=device) # tie=1 才计:出现「等长安全前缀并列」的规划次数(钩子可用) + tx = torch.zeros(B, device=device) # tie=1 才计:并列里真的由余量改选了那条的次数(钩子有效) first_hit = torch.full((B,), float('nan'), device=device); dls_it = torch.full((B,), -1, dtype=torch.long, device=device) # 2026-10-20(轮次33):速度/加速度按「实际执行位移」算,不看指令——第30轮证明反馈是在限幅之后叠加的 vratio = torch.zeros(B, device=device); aratio = torch.zeros(B, device=device); prev_exec = torch.zeros(B, 6, device=device) @@ -363,6 +366,15 @@ def rollout(model, kin, tasks, device, K=8, safety=False, seed=0, max_sec=None, plen = pl_all[:, :ntry] maxlen = plen.max(-1).values best = plen.argmax(-1) # 与串行同口径:严格更大者胜 ⇒ 首个最大值 + if tie: # 等长前缀时不再只看选择器分数:取"沿路最小间隙更大"的那条(零额外几何调用,判据仍是 gap>0) + ci = torch.arange(2 * commit_n, device=device) + keep = (ci < maxlen[:, None]) | ((ci >= commit_n) & (ci < commit_n + maxlen[:, None])) # [B,2cn] 只看会被执行的那些点 + clr = gaps6[:, :ntry].where(keep[:, None, :], torch.full((), float('inf'), device=device)).amin(-1) + cand = torch.where(plen == maxlen[:, None], clr, torch.full_like(clr, -float('inf'))).argmax(-1) + ntile = (plen == maxlen[:, None]).sum(-1) # 并列最长的候选条数(勿用 nmax:那是主循环的总步数) + to += (ntile > 1) & (maxlen > 0) + tx += (cand != best) + best = cand if backoff: # 缩步候选只在全拒时顶上:不参与「前缀更长者优先」的比较,所以正常路径逐位不变 full0 = maxlen == 0 bb = pl_all[:, ntry:].argmax(-1) # 平手取缩得最少的(档 0 = 1/2) @@ -516,6 +528,9 @@ def rollout(model, kin, tasks, device, K=8, safety=False, seed=0, max_sec=None, per = {'done': done.cpu(), 'ok': ok.cpu(), 't_done': t_done.cpu(), 'limit': limit.cpu(), 'col': col.cpu(), 'travel': travel.cpu(), 'err': e_end.cpu(), 'side': side.cpu(), 'start_fail': start_fail.cpu(), 'brakes': brakes.cpu(), 'backoff': bo.cpu(), 'first_hit': first_hit.cpu(), 'dls_it': dls_it.cpu(), 'n_plans': nplan.cpu(), 'brake_rate': (brakes / nplan.clamp_min(1.0)).cpu(), + 'tie_opp': to.cpu(), 'tie_changed': tx.cpu(), # tie=1 才有意义:并列次数/改选次数(每题) + 'tie_opp_rate': (to / nplan.clamp_min(1.0)).cpu(), # 分母与 brake_rate 同口径 = 规划次数 + 'tie_changed_rate': (tx / nplan.clamp_min(1.0)).cpu(), 'goal_ratio_2s': ratio2.cpu(), 'speed_ratio': vratio.cpu(), 'accel_ratio': aratio.cpu(), # 口径与离线分解一致:换向的 (步,轴) 对数 / 活动的 (步,轴) 对数 ∈ [0,1](除以 6 个轴) 'reverse_rate': (rev / (6.0 * nact.clamp_min(1.0))).cpu(), diff --git a/tests/test_eval_cl.py b/tests/test_eval_cl.py index a1d5006..6e795dc 100644 --- a/tests/test_eval_cl.py +++ b/tests/test_eval_cl.py @@ -889,5 +889,82 @@ class TravelPenaltySelectTest(unittest.TestCase): self.assertAlmostEqual(float(per['moving_share'][0]), 2.0 / 3.0, places=5) +class TieByClearanceTest(unittest.TestCase): + """#24:两条候选的安全前缀一样长时,现在按"沿路最小间隙更大"定夺(零额外几何、判据不变)。""" + class StubModel: + act_scale = 1.0 + + def __init__(self, act): + self.act = act + + def sample_k(self, feat, K, z, steps=1): + B = feat.shape[0] + return (self.act.expand(B, K, 16, 6).clone(), torch.zeros(B, device=feat.device), + torch.zeros(B, 13, device=feat.device)) + + class ClearKin: + """间隙 = 0.4 − 0.2·q[0](恒为正 ⇒ 两条候选都全安全;走得越远间隙越小)。全部低于 0.5 的宽相位截顶值, + 否则一切都被 clamp 成 0.5 就分不出高低了。""" + def clamp_box(self, q): + return q + + def gaps(self, q, cap): + gm = (0.4 - 0.2 * q[:, 0]).clamp(min=0.0) + return {'gm': torch.stack([gm] * 49, 1)} + + def run1(self, tie, cert_batch=1): + big = [[0.2, 0, 0, 0, 0, 0]] * 16 # 走得远:间隙小,但分数并列第一 + small = [[0.1, 0, 0, 0, 0, 0]] * 16 # 走得近:间隙大 + acts = torch.tensor([big, small], dtype=torch.float32) + with patch('ml.sim.closed_loop.features', return_value=torch.zeros(1, 83)), \ + patch('ml.sim.closed_loop.goal_err', + side_effect=lambda kin, q, T, R: (torch.full((q.shape[0],), 10.0), torch.zeros(q.shape[0]))): + _, per = rollout(self.StubModel(acts), self.ClearKin(), [{'q0': [0] * 6, 'T': [10, 0, 0]}], 'cpu', + K=2, safety=True, max_sec=0.04, return_traj=True, use_sub=False, + select='endpoint', replan='empty', commit_n=2, safety_partial=2, + cert_batch=cert_batch, tie=tie) + return per + + def test_tie_prefers_the_wider_clearance(self): + p0 = self.run1(0) # 分数并列 ⇒ 稳定排序取第 0 条(走得远、余量小) + self.assertAlmostEqual(float(p0['traj'][0, 1, 0]), 0.2 * 1.2, places=5) + p1 = self.run1(1) # 改由余量定夺 ⇒ 取间隙更大的那条(0.1×STEP=0.12°) + self.assertAlmostEqual(float(p1['traj'][0, 1, 0]), 0.1 * 1.2, places=5) + torch.testing.assert_close(p0['brakes'], p1['brakes'], rtol=0, atol=0) # 安全性判据没动 + torch.testing.assert_close(p0['plans']['certified'], p1['plans']['certified'], rtol=0, atol=0) + + def test_tie_requires_batched_certification(self): + with self.assertRaisesRegex(ValueError, 'cert_batch'): + self.run1(1, cert_batch=0) + + def test_tie_counters_are_exact(self): + """钩子自检的期望值可手算:夹具里两条候选永远全安全 ⇒ 每次规划都是并列,且每次都改选 ⇒ + tie_opp = tie_changed = n_plans,两个比率恒 1。tie=0 时两个计数恒 0。""" + p0 = self.run1(0) + self.assertGreater(float(p0['n_plans'][0]), 0) # 夹具得真的跑过规划,否则下面的相等是 0==0 的假通过 + torch.testing.assert_close(p0['tie_opp'], torch.zeros_like(p0['tie_opp']), rtol=0, atol=0) + torch.testing.assert_close(p0['tie_changed'], torch.zeros_like(p0['tie_changed']), rtol=0, atol=0) + p1 = self.run1(1) + torch.testing.assert_close(p1['tie_opp'], p1['n_plans'], rtol=0, atol=0) + torch.testing.assert_close(p1['tie_changed'], p1['n_plans'], rtol=0, atol=0) + self.assertAlmostEqual(float(p1['tie_opp_rate'][0]), 1.0, places=6) + self.assertAlmostEqual(float(p1['tie_changed_rate'][0]), 1.0, places=6) + + def test_tie_block_does_not_shadow_loop_state(self): + """tie 分支里新增的局部名一旦顶掉主循环状态(真实踩过 nmax=总步数被改成张量), + 进度回调会立刻炸;所以 tie=1 必须带 progress 跑通,且 total 保持标量。""" + seen = [] + acts = torch.tensor([[[0.2, 0, 0, 0, 0, 0]] * 16, [[0.1, 0, 0, 0, 0, 0]] * 16], dtype=torch.float32) + with patch('ml.sim.closed_loop.features', return_value=torch.zeros(1, 83)), \ + patch('ml.sim.closed_loop.goal_err', + side_effect=lambda kin, q, T, R: (torch.full((q.shape[0],), 10.0), torch.zeros(q.shape[0]))): + rollout(self.StubModel(acts), self.ClearKin(), [{'q0': [0] * 6, 'T': [10, 0, 0]}], 'cpu', + K=2, safety=True, max_sec=0.04, use_sub=False, select='endpoint', replan='empty', + commit_n=2, safety_partial=2, cert_batch=1, tie=1, + progress=lambda step, total: seen.append((step, total))) + self.assertTrue(seen) + self.assertTrue(all(isinstance(s, int) and isinstance(t, int) for s, t in seen)) + + if __name__ == '__main__': unittest.main()