解读 02 · obs.rs:61 维观测是怎么拼出来的
解读对象:
duck-control/src/obs.rs(399 行)
需要的前置:《什么鸭公开课》第 04 课(61→14 契约)
模块注释自称"crate 里风险最高的代码"(obs.rs:3-6):一个 61 维浮点数组,每个下标都必须和训练时一致。偏移错了不会报错,只会得到一只"看起来正常但老摔跤"的鸭子,症状像调参问题而不是索引问题。
1. 一张写进注释的布局表
OBS_LEN = 61(obs.rs:51),布局是:陀螺仪 0..3、投影重力 3..6、关节位置(减 home 位)6..20、关节速度 20..34、上一次动作 34..48、命令块 48..61(obs.rs:13-20)。所有 alpha 策略都是 obs[1,61] → actions[1,14],所以只有一种布局——原型里的 51/54/49/85 维旧契约全被砍掉了。
Observation::build(obs.rs:167)用 split_first_chunk_mut::<N>() 把定长数组逐块"切"出来(obs.rs:182-188):每块是 [f32; N],宽度写进类型。填块用泛型函数 fill<const N>(block, values)(obs.rs:134),两边宽度不同就编译不过。注释点破了这个要害:zip 会在短的一侧静默停下,留下一段全零尾巴——而观测里的零意味着"关节在 home 位",策略会认真对待一台不存在的机器人。
2. 15 个关节、14 个槽位:一个映射管两个方向
机器人有 15 个关节,嘴(MOUTH_INDEX = 9,model.rs:31)不在任何策略里。joint_of(slot)(obs.rs:123)一行解决:if slot < MOUTH_INDEX { slot } else { slot + 1 }——嘴之前的槽位直通,之后的整体上移一格。妙处在复用:policy_joints(obs.rs:109)读的时候过这个映射把 15 压成 14,scatter_action(obs.rs:230)写的时候用同一个映射把 14 摊回 15。注释明说:两个方向走同一份代码,"就不可能对不上"。错一位的后果是第 9 号之后的关节全部错位,"既灾难性又完全无声"(obs.rs:228-229)。
还有一道编译期保险:const 块断言 OBS_LEN == duck_ipc_proto::POLICY_OBS_LEN(obs.rs:60-63)。同一个数在策略清单协议里还有一份副本,测试让两份副本漂移时直接编译失败。
3. 命令块:三个"确认过而不是假设"的细节
命令块 48..61 是唯一没有第二真相来源的部分,有三个坑(obs.rs:34-45):
- body x、y、yaw 硬编码为零(obs.rs:213-218)。它们在训练环境里未被绑定,全零就是"标准编码",不是占位符。
- 身体块内部是
z, roll, pitch顺序,不是z, pitch, roll(obs.rs:45)。写反的症状是"让它前倾它侧倾"。 - 头部目标是命令,不是输出叠加。原型在有的模式里把头目标再加到策略输出上,两头一起弯;这里只走观测通道。
每条都有测试钉住:unbound_body_axes_are_always_zero、the_body_block_is_z_roll_pitch(obs.rs:313、325)。测试里的值都是刻意可区分的,让"块挪了位置"表现为一个具体下标错了,而不是"机器人走路变差"。
你带走的收获
- sim2real 契约要"编译期断言 + 可区分值的偏移测试"双重钉死;运行期发现就晚了。
- 把宽度编码进类型(定长数组、
const N泛型),一整类布局错误从运行期移到编译期。 - 一对互逆的映射(读压缩/写摊开)共用同一个函数,是对齐偏差的根治法。
- 关节位置喂给策略要减 home 位——训练时看的是相对角,喂绝对角等于给 14 个输入加一个没见过的常数偏置。
- 上一次动作回填的是策略原始输出(obs.rs:197-199),缩放之后的那份不能回填。