确定性录制与回放
本章描述 roplat 的录制/回放子系统。它允许将系统运行时的每帧 Yield/Feed 数据录制到 .rlog 文件,并在之后确定性地重放。
模块结构
roplat/src/rhythm/replay/
├── mod.rs # 模块导出
├── frame.rs # FrameRecord + RhythmMeta 数据结构
├── rlog.rs # .rlog 文件读写(RlogWriter / RlogReader / FrameWriter)
├── recording.rs # RecordingRhythm<R> — 录制包装器
├── replay.rs # ReplayRhythm<Y,D> — 回放节律源
└── orchestrator.rs # ReplayOrchestrator + TurnToken — 多域全局序号调度
核心思路
回放边界画在 Rhythm 的 drive() 方法的两个接口点:
- 入口:
Yield— 节律向节点提供的输入数据 - 出口:
Feed— 节点处理后返回给节律的输出数据
录制 = 在这两个点插入 tap,透明记录每帧的 (Yield, Feed, timestamp)。
回放 = 用录制的 Yield 序列替代真实的外部输入,驱动节点执行,可选校验 Feed。
节点层完全无感知——Node::process() 不知道自己是在录制还是回放。
RecordingRhythm
RecordingRhythm<R> 包装任意 Rhythm,在 drive 的闭包中拦截每帧数据:
pub struct RecordingRhythm<R> {
inner: R,
rhythm_id: u16,
writer: Arc<FrameWriter>, // 共享的日志写入器
seq: Arc<AtomicU64>, // 全局序号计数器
}
关键实现:用一个拦截闭包 wrap 住 op_domain:
- 在调用真实
op_domain之前序列化 Yield 并记录时间戳 - 调用真实节点逻辑
op_domain(nodes, yield_val).await - 在调用之后序列化 Feed
- 写入
FrameRecord { global_seq, rhythm_id, timestamp_ns, yield_data, feed_data }
多个 RecordingRhythm 共享同一个 FrameWriter 和 AtomicU64 全局序号,保证跨域帧的全序关系。
ReplayRhythm
ReplayRhythm<Y, D> 用录制的帧数据替代真实节律源:
pub struct ReplayRhythm<Y, D> {
frames: Vec<FrameRecord>,
cursor: usize,
mode: ReplayMode,
timing: ReplayTiming,
}
三种回放模式
| 模式 | 节点执行? | Feed 校验? | 用途 |
|---|---|---|---|
Full { tolerance } |
是 | 是 | 回归测试:验证节点产出与录制一致 |
InputOnly |
是 | 否 | 修改算法后用录制输入测试 |
OutputOnly |
否 | — | 给下游 live 域提供录制的 Feed |
Feed 校验策略
FeedCheck::None— 不校验FeedCheck::Exact— 字节级严格比较FeedCheck::Tolerance(eps)— 将序列化字节视为 f64 数组,逐元素容差比较
时序控制
ReplayTiming::AsFast— 全速(测试用)ReplayTiming::Realtime— 按录制时的帧间隔 sleepReplayTiming::Scaled(factor)— 倍速/慢速
ReplayOrchestrator
全量回放时多个域按全局序号交错执行:
pub struct ReplayOrchestrator {
current_seq: Arc<AtomicU64>, // 全局当前序号
advance_notify: Arc<Notify>, // tokio Notify 广播
domain_frames: HashMap<u16, Vec<FrameRecord>>,
}
每个域拿到一个 TurnToken,在执行每帧前 wait_turn(expected_seq).await,执行后 advance() 推进全局序号并唤醒其他域。
.rlog 文件格式
JSON-lines 格式(便于调试和增量写入):
行 1: RlogHeader { magic: "RLOG", version: 2, rhythms: [...] }
行 2: FrameRecord { global_seq: 0, rhythm_id: 0, timestamp_ns: ..., yield_data: [...], feed_data: [...] }
行 3: FrameRecord { global_seq: 1, ... }
...
- 崩溃安全:流式追加,丢失最后未 flush 的帧不损坏已有数据
- Yield/Feed 数据使用 bincode 序列化为
Vec<u8>,然后嵌入 JSON
为什么同时记录 Yield 和 Feed
- 外部驱动节律(EventRhythm)的 Yield 是传感器数据,不可重构
- Feed 是节点的输出指令,回放时需要校验节点是否产生相同结果
OutputOnly模式需要录制的 Feed 直接提供给下游域- 完整的 (Yield, Feed) 对使 .rlog 本身就是系统行为的完整记录
实践注意
- 录制要求 Yield 和 Feed 类型实现
Serialize + DeserializeOwned(可通过#[roplat_msg]宏自动派生) - 高频大数据(如相机图像)录制会产生大量磁盘 IO,生产环境考虑选择性录制
- 跨平台回放时注意浮点确定性问题(同一平台不存在此问题)
RecordingRhythm的序列化开销约 1-10μs/帧,对于 ≤10kHz 的控制循环影响很小