跳转至

确定性录制与回放

本章描述 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

  1. 在调用真实 op_domain 之前序列化 Yield 并记录时间戳
  2. 调用真实节点逻辑 op_domain(nodes, yield_val).await
  3. 在调用之后序列化 Feed
  4. 写入 FrameRecord { global_seq, rhythm_id, timestamp_ns, yield_data, feed_data }

多个 RecordingRhythm 共享同一个 FrameWriterAtomicU64 全局序号,保证跨域帧的全序关系。

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 — 按录制时的帧间隔 sleep
  • ReplayTiming::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

  1. 外部驱动节律(EventRhythm)的 Yield 是传感器数据,不可重构
  2. Feed 是节点的输出指令,回放时需要校验节点是否产生相同结果
  3. OutputOnly 模式需要录制的 Feed 直接提供给下游域
  4. 完整的 (Yield, Feed) 对使 .rlog 本身就是系统行为的完整记录

实践注意

  1. 录制要求 Yield 和 Feed 类型实现 Serialize + DeserializeOwned(可通过 #[roplat_msg] 宏自动派生)
  2. 高频大数据(如相机图像)录制会产生大量磁盘 IO,生产环境考虑选择性录制
  3. 跨平台回放时注意浮点确定性问题(同一平台不存在此问题)
  4. RecordingRhythm 的序列化开销约 1-10μs/帧,对于 ≤10kHz 的控制循环影响很小