Record & Replay

Capture a robot's execution and replay it later for debugging or regression testing. Unlike external bag tools, HORUS recording is built into the scheduler — zero serialization overhead, tick-perfect determinism, and mixed replay for what-if testing.

When To Use This

  • Debugging a bug that only reproduces with specific sensor data
  • Regression testing a new controller against recorded inputs
  • Comparing two algorithm versions on identical data
  • Capturing field data for offline analysis

Prerequisites

horus.toml

[package]
name = "record-replay-demo"
version = "0.1.0"
description = "Record and replay demonstration"

Complete Code

use horus::prelude::*;
use std::path::PathBuf;

#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, LogSummary)]
#[repr(C)]
struct SensorData {
    value: f32,
    timestamp_ns: u64,
}

#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, LogSummary)]
#[repr(C)]
struct ControlCmd {
    output: f32,
}

// ── Sensor (publishes simulated readings) ─────────────────

struct Sensor {
    pub_data: Topic<SensorData>,
    tick: u64,
}

impl Sensor {
    fn new() -> Result<Self> {
        Ok(Self {
            pub_data: Topic::new("sensor.data")?,
            tick: 0,
        })
    }
}

impl Node for Sensor {
    fn name(&self) -> &str { "sensor" }
    fn tick(&mut self) {
        let t = self.tick as f32 * 0.01;
        let _ = self.pub_data.send(SensorData {
            value: (t * 3.0).sin() * 5.0,
            timestamp_ns: horus::now().as_nanos() as u64,
        });
        self.tick += 1;
    }
}

// ── Controller (processes sensor data) ────────────────────

struct Controller {
    sub_data: Topic<SensorData>,
    pub_cmd: Topic<ControlCmd>,
}

impl Controller {
    fn new() -> Result<Self> {
        Ok(Self {
            sub_data: Topic::new("sensor.data")?,
            pub_cmd: Topic::new("ctrl.cmd")?,
        })
    }
}

impl Node for Controller {
    fn name(&self) -> &str { "controller" }
    fn tick(&mut self) {
        if let Some(data) = self.sub_data.recv() {
            let output = -0.5 * data.value; // Simple proportional
            let _ = self.pub_cmd.send(ControlCmd { output });
        }
    }
}

fn main() -> Result<()> {
    // ── Step 1: Record ────────────────────────────────────
    println!("=== Recording 5 seconds ===");
    let mut sched = Scheduler::new()
        .tick_rate(100_u64.hz())
        .with_recording();

    sched.add(Sensor::new()?).order(0).build()?;
    sched.add(Controller::new()?).order(1).build()?;
    sched.run_for(std::time::Duration::from_secs(5))?;

    let paths = sched.stop_recording()?;
    println!("Saved to: {:?}", paths);

    // ── Step 2: Full replay ───────────────────────────────
    // Replays ALL nodes exactly as recorded
    println!("\n=== Full replay ===");
    let sched_path = paths.iter()
        .find(|p| p.to_string_lossy().contains("scheduler@"))
        .expect("scheduler recording");
    let mut replay = Scheduler::replay_from(sched_path.clone())?;
    replay.run()?;

    // ── Step 3: Mixed replay (the powerful part) ──────────
    // Replay recorded sensor, run NEW controller live
    println!("\n=== Mixed replay: recorded sensor + live controller ===");
    let sensor_path = paths.iter()
        .find(|p| p.to_string_lossy().contains("sensor@"))
        .expect("sensor recording");

    let mut mixed = Scheduler::new()
        .tick_rate(100_u64.hz());
    mixed.add_replay(sensor_path.clone(), 0)?;      // Recorded sensor
    mixed.add(Controller::new()?).order(1).build()?; // Live controller
    mixed.run_for(std::time::Duration::from_secs(5))?;

    Ok(())
}

Understanding the Code

  • .with_recording() / recording=True captures every node's topic inputs and outputs each tick as raw shared memory bytes — zero serialization overhead
  • stop_recording() flushes to disk and returns file paths (one .horus file per node + one scheduler metadata file)
  • replay_from() loads the scheduler recording and replays all nodes with the original timing and data
  • add_replay() is the key differentiator from external bag tools — it replays one node's recorded outputs while running other nodes live, enabling regression testing without re-recording

CLI Workflows

# Record during any run
horus run --record my_session

# List and inspect
horus record list --long
horus record info my_session

# Full replay (all nodes)
horus record replay my_session
horus record replay my_session --speed 0.5 --start-tick 100

# Mixed replay (recorded sensor + live code)
horus record inject my_session --nodes sensor

# Compare two runs
horus record diff session_v1 session_v2

# Export for analysis
horus record export my_session --output data.csv --format csv

# Cleanup
horus record clean --max-age-days 30
horus record delete old_session

Common Errors

SymptomCauseFix
Empty recording.with_recording() not setAdd to scheduler builder or use --record CLI flag
Replay output differsCode changed between record and replayExpected for mixed replay; use replay_from for exact reproduction
inject --nodes X has no effectTopic name mismatchNames are case-sensitive and dot-separated
FileNotFoundError on replayWrong session nameRun horus record list to see available sessions

See Also