feat(rvcsi): rvcsi-adapter-file (.rvcsi capture/replay) + rvcsi-ruvector (RF memory)

- rvcsi-adapter-file (ADR-095 FR1/FR10, D9): the `.rvcsi` JSONL capture format
  (CaptureHeader line + one CsiFrame per line), FileRecorder, FileReplayAdapter
  (a CsiSource — deterministic replay, preserves timestamps/ordering/validation
  verbatim, carries an unenforced replay_speed for the daemon/CLI), read_all().
  20 unit tests + 1 doctest.
- rvcsi-ruvector (ADR-095 FR8, D8) — standin for the production RuVector binding:
  deterministic embeddings (window_embedding = 32 resampled mean_amplitude bins +
  32 resampled phase_variance bins + [motion_energy, presence_score, quality_score,
  ln1p(frame_count)], L2-normalized, dim 68; event_embedding = 10-wide kind
  one-hot + confidence + ln1p(evidence count), dim 12), cosine_similarity, the
  RfMemoryStore trait + value objects (EmbeddingId/RecordKind/SimilarHit/
  DriftReport), and InMemoryRfMemory + JsonlRfMemory (file-backed append log,
  identical query semantics, latest-baseline-per-room-wins on reopen).
  20 unit tests + 1 doctest.

All rvcsi crates build and test together: core 29, dsp 28, events 18,
adapter-file 20(+1), adapter-nexmon 9, ruvector 20(+1) — 124 unit + 2 doc tests,
0 failures. forbid(unsafe_code) everywhere except rvcsi-adapter-nexmon (FFI).

https://claude.ai/code/session_01CdYAPvRTjcch6YrYf42n1z
This commit is contained in:
Claude
2026-05-13 00:03:27 +00:00
parent 46f701bca8
commit 6432dfbd2d
10 changed files with 2084 additions and 10 deletions
+144
View File
@@ -0,0 +1,144 @@
//! The `.rvcsi` capture container format (ADR-095 FR1/FR10, D9).
//!
//! A `.rvcsi` file is plain [JSONL]: the **first line** is a
//! [`CaptureHeader`] object describing the session; every **subsequent line**
//! is one [`rvcsi_core::CsiFrame`] serialized as JSON. This keeps the format
//! simple, deterministic, append-friendly and trivially debuggable with `head`
//! / `jq`.
//!
//! [JSONL]: https://jsonlines.org/
use rvcsi_core::{AdapterProfile, SessionId, SourceId, ValidationPolicy};
use serde::{Deserialize, Serialize};
/// Current `.rvcsi` capture format version. Written into every header and
/// checked on read.
pub const CAPTURE_VERSION: u32 = 1;
/// Header object — the first line of every `.rvcsi` capture file.
///
/// It records enough context to replay the session faithfully: the originating
/// session/source ids, the source's [`AdapterProfile`], the
/// [`ValidationPolicy`] that was in force, the calibration version (if any),
/// and an opaque `runtime_config_json` blob the caller may use for whatever it
/// likes (defaults to `"{}"`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CaptureHeader {
/// Capture format version (always [`CAPTURE_VERSION`] when written).
pub rvcsi_capture_version: u32,
/// Session this capture belongs to.
pub session_id: SessionId,
/// Source the frames were captured from.
pub source_id: SourceId,
/// Capability descriptor of the source at capture time.
pub adapter_profile: AdapterProfile,
/// Validation policy that was in force during capture.
pub validation_policy: ValidationPolicy,
/// Calibration version frames were processed against, if any.
pub calibration_version: Option<String>,
/// Opaque caller-supplied runtime config (JSON; default `"{}"`).
pub runtime_config_json: String,
/// Wall-clock creation time, nanoseconds since the Unix epoch (`0` if unknown).
pub created_unix_ns: u64,
}
impl CaptureHeader {
/// Build a header for `session_id` / `source_id` / `adapter_profile` with
/// sensible defaults: version [`CAPTURE_VERSION`], [`ValidationPolicy::default`],
/// no calibration version, `runtime_config_json == "{}"`, and
/// `created_unix_ns` taken from the system clock (or `0` if it is unavailable
/// or before the epoch).
pub fn new(session_id: SessionId, source_id: SourceId, adapter_profile: AdapterProfile) -> Self {
CaptureHeader {
rvcsi_capture_version: CAPTURE_VERSION,
session_id,
source_id,
adapter_profile,
validation_policy: ValidationPolicy::default(),
calibration_version: None,
runtime_config_json: "{}".to_string(),
created_unix_ns: now_unix_ns(),
}
}
/// Builder: override the validation policy.
pub fn with_validation_policy(mut self, policy: ValidationPolicy) -> Self {
self.validation_policy = policy;
self
}
/// Builder: set the calibration version.
pub fn with_calibration_version(mut self, version: impl Into<String>) -> Self {
self.calibration_version = Some(version.into());
self
}
/// Builder: set the opaque runtime config blob.
pub fn with_runtime_config_json(mut self, json: impl Into<String>) -> Self {
self.runtime_config_json = json.into();
self
}
/// Builder: pin `created_unix_ns` (useful for deterministic tests).
pub fn with_created_unix_ns(mut self, ns: u64) -> Self {
self.created_unix_ns = ns;
self
}
}
/// Best-effort "nanoseconds since the Unix epoch" using the system clock;
/// returns `0` when the clock is unavailable or set before the epoch.
fn now_unix_ns() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos().min(u128::from(u64::MAX)) as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use rvcsi_core::AdapterKind;
#[test]
fn header_defaults() {
let h = CaptureHeader::new(
SessionId(7),
SourceId::from("file:lab.rvcsi"),
AdapterProfile::offline(AdapterKind::File),
);
assert_eq!(h.rvcsi_capture_version, CAPTURE_VERSION);
assert_eq!(h.runtime_config_json, "{}");
assert!(h.calibration_version.is_none());
assert_eq!(h.validation_policy, ValidationPolicy::default());
}
#[test]
fn header_builders() {
let h = CaptureHeader::new(
SessionId(1),
SourceId::from("s"),
AdapterProfile::offline(AdapterKind::File),
)
.with_calibration_version("room@v2")
.with_runtime_config_json(r#"{"foo":1}"#)
.with_created_unix_ns(42);
assert_eq!(h.calibration_version.as_deref(), Some("room@v2"));
assert_eq!(h.runtime_config_json, r#"{"foo":1}"#);
assert_eq!(h.created_unix_ns, 42);
}
#[test]
fn header_json_roundtrips() {
let h = CaptureHeader::new(
SessionId(3),
SourceId::from("esp32"),
AdapterProfile::esp32_default(),
)
.with_created_unix_ns(123);
let json = serde_json::to_string(&h).unwrap();
let back: CaptureHeader = serde_json::from_str(&json).unwrap();
assert_eq!(h, back);
}
}
+340 -5
View File
@@ -1,7 +1,342 @@
//! # rvCSI file/replay adapter (skeleton — implemented by the adapters swarm agent)
//! # rvCSI file/replay adapter
//!
//! Records and replays `.rvcsi` capture sessions deterministically (ADR-095 D9).
#![forbid(unsafe_code)]
//! The `.rvcsi` capture container, its [`FileRecorder`], and the
//! [`FileReplayAdapter`] [`CsiSource`](rvcsi_core::CsiSource) (ADR-095 FR1/FR10,
//! D9).
//!
//! A `.rvcsi` file is plain [JSONL]: the first line is a [`CaptureHeader`]
//! describing the session; every subsequent line is one
//! [`rvcsi_core::CsiFrame`] serialized as compact JSON. The format is simple,
//! deterministic, append-friendly and trivially inspectable with `head` / `jq`.
//!
//! Typical use:
//!
//! ```no_run
//! use rvcsi_adapter_file::{CaptureHeader, FileRecorder, FileReplayAdapter};
//! use rvcsi_core::{AdapterKind, AdapterProfile, CsiSource, SessionId, SourceId};
//!
//! # fn demo() -> rvcsi_core::Result<()> {
//! let header = CaptureHeader::new(
//! SessionId(1),
//! SourceId::from("file:lab.rvcsi"),
//! AdapterProfile::offline(AdapterKind::File),
//! );
//! let mut rec = FileRecorder::create("lab.rvcsi", &header)?;
//! // rec.write_frame(&frame)?; ...
//! rec.finish()?;
//!
//! let mut replay = FileReplayAdapter::open("lab.rvcsi")?;
//! while let Some(frame) = replay.next_frame()? {
//! // hand `frame` downstream — its ValidationStatus is preserved as recorded
//! let _ = frame;
//! }
//! # Ok(())
//! # }
//! ```
//!
//! [JSONL]: https://jsonlines.org/
/// Placeholder so the crate compiles before the agent fills it in.
pub fn __rvcsi_adapter_file_placeholder() {}
#![forbid(unsafe_code)]
#![warn(missing_docs)]
mod format;
mod recorder;
mod replay;
pub use format::{CaptureHeader, CAPTURE_VERSION};
pub use recorder::FileRecorder;
pub use replay::FileReplayAdapter;
use std::path::Path;
use rvcsi_core::{CsiFrame, Result};
/// Read an entire `.rvcsi` capture into memory: its [`CaptureHeader`] and every
/// [`CsiFrame`] it contains, in recording order.
///
/// This is a convenience wrapper over [`FileReplayAdapter`]; for large captures
/// or streaming use, prefer iterating [`FileReplayAdapter`] directly. Errors are
/// the same as [`FileReplayAdapter::open`] / [`FileReplayAdapter::next_frame`]:
/// an [`rvcsi_core::RvcsiError::Io`] for a missing/unreadable file, an
/// [`rvcsi_core::RvcsiError::Parse`] (offset `0`) for a bad header, or an
/// [`rvcsi_core::RvcsiError::Parse`] carrying the 1-based line number for a
/// malformed frame line.
pub fn read_all(path: impl AsRef<Path>) -> Result<(CaptureHeader, Vec<CsiFrame>)> {
use rvcsi_core::CsiSource;
let mut adapter = FileReplayAdapter::open(path)?;
let header = adapter.header().clone();
let mut frames = Vec::new();
while let Some(frame) = adapter.next_frame()? {
frames.push(frame);
}
Ok((header, frames))
}
#[cfg(test)]
mod tests {
use super::*;
use rvcsi_core::{
AdapterKind, AdapterProfile, CsiSource, FrameId, RvcsiError, SessionId, SourceId,
ValidationStatus,
};
use std::fs::File;
use std::io::{Read, Write};
fn header() -> CaptureHeader {
CaptureHeader::new(
SessionId(1),
SourceId::from("it-test"),
AdapterProfile::offline(AdapterKind::File),
)
.with_created_unix_ns(0)
.with_calibration_version("room@v1")
.with_runtime_config_json(r#"{"window_ms":500}"#)
}
/// A small varied set of frames: two accepted (quality 0.9), two degraded
/// with reasons, one recovered — varying timestamps / channels / subcarrier
/// counts.
fn sample_frames() -> Vec<CsiFrame> {
let mut frames = Vec::new();
let mut f0 = CsiFrame::from_iq(
FrameId(0),
SessionId(1),
SourceId::from("it-test"),
AdapterKind::File,
1_000,
1,
20,
vec![1.0, 2.0, 3.0, 4.0],
vec![0.5, 0.5, 0.5, 0.5],
)
.with_rssi(-55);
f0.validation = ValidationStatus::Accepted;
f0.quality_score = 0.9;
frames.push(f0);
let mut f1 = CsiFrame::from_iq(
FrameId(1),
SessionId(1),
SourceId::from("it-test"),
AdapterKind::File,
2_000,
6,
40,
vec![0.1; 8],
vec![0.2; 8],
);
f1.validation = ValidationStatus::Degraded;
f1.quality_score = 0.4;
f1.quality_reasons = vec!["missing rssi".to_string(), "low snr".to_string()];
frames.push(f1);
let mut f2 = CsiFrame::from_iq(
FrameId(2),
SessionId(1),
SourceId::from("it-test"),
AdapterKind::File,
3_000,
11,
20,
vec![5.0, 6.0],
vec![1.0, -1.0],
)
.with_rssi(-70)
.with_noise_floor(-95);
f2.validation = ValidationStatus::Accepted;
f2.quality_score = 0.9;
frames.push(f2);
let mut f3 = CsiFrame::from_iq(
FrameId(3),
SessionId(1),
SourceId::from("it-test"),
AdapterKind::File,
2_500, // deliberately out of order — replay preserves it verbatim
6,
20,
vec![0.0; 3],
vec![0.0; 3],
);
f3.validation = ValidationStatus::Recovered;
f3.quality_score = 0.3;
frames.push(f3);
let mut f4 = CsiFrame::from_iq(
FrameId(4),
SessionId(1),
SourceId::from("it-test"),
AdapterKind::File,
4_000,
36,
80,
vec![2.0; 6],
vec![0.0; 6],
);
f4.validation = ValidationStatus::Degraded;
f4.quality_score = 0.5;
f4.quality_reasons = vec!["amplitude spike".to_string()];
frames.push(f4);
frames
}
#[test]
fn record_then_replay_roundtrips_exactly() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let header = header();
let frames = sample_frames();
let mut rec = FileRecorder::create(tmp.path(), &header).unwrap();
for f in &frames {
rec.write_frame(f).unwrap();
}
assert_eq!(rec.frames_written(), frames.len() as u64);
rec.finish().unwrap();
let mut adapter = FileReplayAdapter::open(tmp.path()).unwrap();
assert_eq!(adapter.header(), &header);
let mut got = Vec::new();
while let Some(f) = adapter.next_frame().unwrap() {
got.push(f);
}
assert_eq!(got, frames);
assert_eq!(adapter.health().frames_delivered, frames.len() as u64);
assert!(!adapter.health().connected);
}
#[test]
fn re_serializing_replayed_frames_is_byte_identical() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let header = header();
let frames = sample_frames();
let mut rec = FileRecorder::create(tmp.path(), &header).unwrap();
for f in &frames {
rec.write_frame(f).unwrap();
}
rec.finish().unwrap();
let mut original = String::new();
File::open(tmp.path()).unwrap().read_to_string(&mut original).unwrap();
// Round-trip the whole capture and re-emit it; bytes must match.
let (h, fs) = read_all(tmp.path()).unwrap();
let tmp2 = tempfile::NamedTempFile::new().unwrap();
let mut rec2 = FileRecorder::create(tmp2.path(), &h).unwrap();
for f in &fs {
rec2.write_frame(f).unwrap();
}
rec2.finish().unwrap();
let mut reemitted = String::new();
File::open(tmp2.path()).unwrap().read_to_string(&mut reemitted).unwrap();
assert_eq!(original, reemitted);
}
#[test]
fn read_all_matches_replay() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let header = header();
let frames = sample_frames();
let mut rec = FileRecorder::create(tmp.path(), &header).unwrap();
for f in &frames {
rec.write_frame(f).unwrap();
}
rec.finish().unwrap();
let (h, fs) = read_all(tmp.path()).unwrap();
assert_eq!(h, header);
assert_eq!(fs, frames);
}
#[test]
fn header_only_capture_has_no_frames() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let header = header();
FileRecorder::create(tmp.path(), &header).unwrap().finish().unwrap();
let mut adapter = FileReplayAdapter::open(tmp.path()).unwrap();
assert!(adapter.next_frame().unwrap().is_none());
let (h, fs) = read_all(tmp.path()).unwrap();
assert_eq!(h, header);
assert!(fs.is_empty());
}
#[test]
fn bad_header_line_is_parse_error_at_offset_zero() {
let tmp = tempfile::NamedTempFile::new().unwrap();
{
let mut f = File::create(tmp.path()).unwrap();
f.write_all(b"not json\n").unwrap();
}
match FileReplayAdapter::open(tmp.path()) {
Err(RvcsiError::Parse { offset, .. }) => assert_eq!(offset, 0),
other => panic!("expected Parse at offset 0, got {other:?}"),
}
match read_all(tmp.path()) {
Err(RvcsiError::Parse { offset, .. }) => assert_eq!(offset, 0),
other => panic!("expected Parse at offset 0, got {other:?}"),
}
}
#[test]
fn garbage_frame_after_good_frames_reports_line_number() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let header = header();
{
let mut f = File::create(tmp.path()).unwrap();
serde_json::to_writer(&mut f, &header).unwrap();
f.write_all(b"\n").unwrap();
// lines 2 + 3: good frames
let frames = sample_frames();
serde_json::to_writer(&mut f, &frames[0]).unwrap();
f.write_all(b"\n").unwrap();
serde_json::to_writer(&mut f, &frames[1]).unwrap();
f.write_all(b"\n").unwrap();
// line 4: garbage
f.write_all(b"{ not a frame }\n").unwrap();
}
let mut adapter = FileReplayAdapter::open(tmp.path()).unwrap();
assert!(adapter.next_frame().unwrap().is_some()); // line 2
assert!(adapter.next_frame().unwrap().is_some()); // line 3
match adapter.next_frame() {
Err(RvcsiError::Parse { offset, .. }) => assert_eq!(offset, 4),
other => panic!("expected Parse at line 4, got {other:?}"),
}
}
#[test]
fn nonexistent_path_is_io_error() {
match FileReplayAdapter::open("/no/such/file/at/all.rvcsi") {
Err(RvcsiError::Io(_)) => {}
other => panic!("expected Io error, got {other:?}"),
}
match read_all("/no/such/file/at/all.rvcsi") {
Err(RvcsiError::Io(_)) => {}
other => panic!("expected Io error, got {other:?}"),
}
}
#[test]
fn counters_are_consistent() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let header = header();
let frames = sample_frames();
let mut rec = FileRecorder::create(tmp.path(), &header).unwrap();
for (i, f) in frames.iter().enumerate() {
rec.write_frame(f).unwrap();
assert_eq!(rec.frames_written(), (i + 1) as u64);
}
rec.finish().unwrap();
let mut adapter = FileReplayAdapter::open(tmp.path()).unwrap();
let mut n = 0u64;
while adapter.next_frame().unwrap().is_some() {
n += 1;
assert_eq!(adapter.health().frames_delivered, n);
}
assert_eq!(n, frames.len() as u64);
}
}
@@ -0,0 +1,113 @@
//! [`FileRecorder`] — writes a `.rvcsi` capture: a header line followed by one
//! JSON line per [`CsiFrame`].
use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::Path;
use rvcsi_core::{CsiFrame, Result};
use crate::format::CaptureHeader;
/// Append-only writer for a `.rvcsi` capture file.
///
/// Create one with [`FileRecorder::create`] (which writes the header line),
/// push frames with [`FileRecorder::write_frame`], and call
/// [`FileRecorder::finish`] (or just drop it after [`FileRecorder::flush`]) to
/// be sure everything reached disk.
pub struct FileRecorder {
writer: BufWriter<File>,
frames_written: u64,
}
impl FileRecorder {
/// Create `path` (truncating any existing file) and write `header` as the
/// first line.
pub fn create(path: impl AsRef<Path>, header: &CaptureHeader) -> Result<Self> {
let file = File::create(path.as_ref())?;
let mut writer = BufWriter::new(file);
write_json_line(&mut writer, header)?;
Ok(FileRecorder {
writer,
frames_written: 0,
})
}
/// Append one frame as a JSON line.
pub fn write_frame(&mut self, frame: &CsiFrame) -> Result<()> {
write_json_line(&mut self.writer, frame)?;
self.frames_written += 1;
Ok(())
}
/// Flush buffered bytes to the underlying file.
pub fn flush(&mut self) -> Result<()> {
self.writer.flush()?;
Ok(())
}
/// Number of frames written so far (the header line is not counted).
pub fn frames_written(&self) -> u64 {
self.frames_written
}
/// Flush and close the file, consuming the recorder.
pub fn finish(mut self) -> Result<()> {
self.flush()
}
}
/// Serialize `value` as a single JSON line (no embedded newlines — `serde_json`
/// compact form never produces them) followed by `\n`.
fn write_json_line<W: Write, T: serde::Serialize>(writer: &mut W, value: &T) -> Result<()> {
serde_json::to_writer(&mut *writer, value)?;
writer.write_all(b"\n")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use rvcsi_core::{AdapterKind, AdapterProfile, FrameId, SessionId, SourceId};
use std::io::Read;
fn frame(id: u64, ts: u64) -> CsiFrame {
CsiFrame::from_iq(
FrameId(id),
SessionId(1),
SourceId::from("rec-test"),
AdapterKind::File,
ts,
6,
20,
vec![1.0, 2.0, 3.0],
vec![0.5, 0.5, 0.5],
)
}
#[test]
fn writes_header_then_frames_and_counts() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let header = CaptureHeader::new(
SessionId(1),
SourceId::from("rec-test"),
AdapterProfile::offline(AdapterKind::File),
)
.with_created_unix_ns(0);
let mut rec = FileRecorder::create(tmp.path(), &header).unwrap();
assert_eq!(rec.frames_written(), 0);
rec.write_frame(&frame(0, 100)).unwrap();
rec.write_frame(&frame(1, 200)).unwrap();
assert_eq!(rec.frames_written(), 2);
rec.finish().unwrap();
let mut contents = String::new();
File::open(tmp.path()).unwrap().read_to_string(&mut contents).unwrap();
let lines: Vec<&str> = contents.lines().collect();
assert_eq!(lines.len(), 3);
let parsed_header: CaptureHeader = serde_json::from_str(lines[0]).unwrap();
assert_eq!(parsed_header, header);
let f0: CsiFrame = serde_json::from_str(lines[1]).unwrap();
assert_eq!(f0, frame(0, 100));
}
}
+304
View File
@@ -0,0 +1,304 @@
//! [`FileReplayAdapter`] — a [`CsiSource`] that replays a `.rvcsi` capture
//! file, frame by frame, exactly as it was recorded.
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
use rvcsi_core::{
AdapterProfile, CsiFrame, CsiSource, Result, RvcsiError, SessionId, SourceHealth, SourceId,
};
use crate::format::{CaptureHeader, CAPTURE_VERSION};
/// Deterministic replay source backed by a `.rvcsi` capture file.
///
/// The header is parsed eagerly on [`FileReplayAdapter::open`]; frames are
/// parsed lazily, one line at a time, on each [`CsiSource::next_frame`] call.
/// Timestamps, ordering and per-frame [`rvcsi_core::ValidationStatus`] are
/// preserved verbatim — replay does not re-validate or re-order anything, it
/// only deserializes what was stored.
///
/// `replay_speed` is carried for the daemon/CLI to pace playback with; the
/// adapter itself never sleeps.
#[derive(Debug)]
pub struct FileReplayAdapter {
header: CaptureHeader,
profile: AdapterProfile,
source_id: SourceId,
reader: BufReader<File>,
/// 1-based line number of the line a subsequent `next_frame` will read.
next_line: usize,
frames_delivered: u64,
at_eof: bool,
replay_speed: f32,
last_status: Option<String>,
}
impl FileReplayAdapter {
/// Open `path` for replay at real-time speed (`replay_speed == 1.0`).
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
Self::open_with_speed(path, 1.0)
}
/// Open `path` for replay, carrying `replay_speed` for downstream pacing.
pub fn open_with_speed(path: impl AsRef<Path>, replay_speed: f32) -> Result<Self> {
let file = File::open(path.as_ref())?;
let mut reader = BufReader::new(file);
let mut first = String::new();
let n = reader.read_line(&mut first)?;
if n == 0 {
return Err(RvcsiError::parse(0, "empty capture file: missing header line"));
}
let header: CaptureHeader = serde_json::from_str(first.trim_end_matches(['\n', '\r']))
.map_err(|e| RvcsiError::parse(0, format!("invalid .rvcsi header line: {e}")))?;
if header.rvcsi_capture_version != CAPTURE_VERSION {
return Err(RvcsiError::parse(
0,
format!(
"unsupported .rvcsi capture version {} (this build supports {})",
header.rvcsi_capture_version, CAPTURE_VERSION
),
));
}
let profile = header.adapter_profile.clone();
let source_id = header.source_id.clone();
Ok(FileReplayAdapter {
header,
profile,
source_id,
reader,
next_line: 2,
frames_delivered: 0,
at_eof: false,
replay_speed,
last_status: None,
})
}
/// The capture header parsed from the file.
pub fn header(&self) -> &CaptureHeader {
&self.header
}
/// Playback speed multiplier carried for the daemon/CLI (the adapter itself
/// does not sleep).
pub fn replay_speed(&self) -> f32 {
self.replay_speed
}
/// Whether the underlying file has been fully consumed.
pub fn is_at_eof(&self) -> bool {
self.at_eof
}
}
impl CsiSource for FileReplayAdapter {
fn profile(&self) -> &AdapterProfile {
&self.profile
}
fn session_id(&self) -> SessionId {
self.header.session_id
}
fn source_id(&self) -> &SourceId {
&self.source_id
}
fn next_frame(&mut self) -> core::result::Result<Option<CsiFrame>, RvcsiError> {
if self.at_eof {
return Ok(None);
}
loop {
let mut line = String::new();
let read = self.reader.read_line(&mut line)?;
if read == 0 {
self.at_eof = true;
return Ok(None);
}
let line_no = self.next_line;
self.next_line += 1;
let trimmed = line.trim_end_matches(['\n', '\r']);
if trimmed.is_empty() {
// Tolerate blank lines (e.g. a trailing newline at EOF).
continue;
}
let frame: CsiFrame = serde_json::from_str(trimmed).map_err(|e| {
self.last_status = Some(format!("parse error at line {line_no}"));
RvcsiError::parse(line_no, format!("invalid frame line {line_no}: {e}"))
})?;
self.frames_delivered += 1;
return Ok(Some(frame));
}
}
fn health(&self) -> SourceHealth {
SourceHealth {
connected: !self.at_eof,
frames_delivered: self.frames_delivered,
frames_rejected: 0,
status: self.last_status.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::recorder::FileRecorder;
use rvcsi_core::{AdapterKind, FrameId, ValidationStatus};
use std::io::Write;
fn frame(id: u64, ts: u64) -> CsiFrame {
CsiFrame::from_iq(
FrameId(id),
SessionId(1),
SourceId::from("rep-test"),
AdapterKind::File,
ts,
6,
20,
vec![1.0, 2.0],
vec![0.0, 1.0],
)
}
fn write_capture(path: &Path, frames: &[CsiFrame]) -> CaptureHeader {
let header = CaptureHeader::new(
SessionId(1),
SourceId::from("rep-test"),
AdapterProfile::offline(AdapterKind::File),
)
.with_created_unix_ns(0);
let mut rec = FileRecorder::create(path, &header).unwrap();
for f in frames {
rec.write_frame(f).unwrap();
}
rec.finish().unwrap();
header
}
#[test]
fn open_speed_default_is_one() {
let tmp = tempfile::NamedTempFile::new().unwrap();
write_capture(tmp.path(), &[]);
let a = FileReplayAdapter::open(tmp.path()).unwrap();
assert_eq!(a.replay_speed(), 1.0);
let b = FileReplayAdapter::open_with_speed(tmp.path(), 4.0).unwrap();
assert_eq!(b.replay_speed(), 4.0);
}
#[test]
fn replays_frames_in_order() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let frames = vec![frame(0, 10), frame(1, 20), frame(2, 30)];
let header = write_capture(tmp.path(), &frames);
let mut a = FileReplayAdapter::open(tmp.path()).unwrap();
assert_eq!(a.header(), &header);
assert_eq!(a.session_id(), SessionId(1));
assert_eq!(a.source_id(), &SourceId::from("rep-test"));
let mut got = Vec::new();
while let Some(f) = a.next_frame().unwrap() {
got.push(f);
}
assert_eq!(got, frames);
assert!(a.is_at_eof());
assert!(!a.health().connected);
assert_eq!(a.health().frames_delivered, 3);
// Repeated calls after EOF stay at None.
assert!(a.next_frame().unwrap().is_none());
}
#[test]
fn header_only_file_yields_no_frames() {
let tmp = tempfile::NamedTempFile::new().unwrap();
write_capture(tmp.path(), &[]);
let mut a = FileReplayAdapter::open(tmp.path()).unwrap();
assert!(a.next_frame().unwrap().is_none());
assert_eq!(a.health().frames_delivered, 0);
}
#[test]
fn validation_status_preserved() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let mut f = frame(0, 1);
f.validation = ValidationStatus::Degraded;
f.quality_score = 0.42;
f.quality_reasons = vec!["missing rssi".to_string()];
write_capture(tmp.path(), &[f.clone()]);
let mut a = FileReplayAdapter::open(tmp.path()).unwrap();
let back = a.next_frame().unwrap().unwrap();
assert_eq!(back, f);
assert_eq!(back.validation, ValidationStatus::Degraded);
assert_eq!(back.quality_reasons, vec!["missing rssi".to_string()]);
}
#[test]
fn bad_header_is_parse_error_at_offset_zero() {
let tmp = tempfile::NamedTempFile::new().unwrap();
{
let mut f = File::create(tmp.path()).unwrap();
f.write_all(b"not json\n").unwrap();
}
let err = FileReplayAdapter::open(tmp.path()).unwrap_err();
match err {
RvcsiError::Parse { offset, .. } => assert_eq!(offset, 0),
other => panic!("expected Parse, got {other:?}"),
}
}
#[test]
fn garbage_frame_line_is_parse_error_with_line_number() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let header = CaptureHeader::new(
SessionId(1),
SourceId::from("rep-test"),
AdapterProfile::offline(AdapterKind::File),
)
.with_created_unix_ns(0);
{
let mut f = File::create(tmp.path()).unwrap();
serde_json::to_writer(&mut f, &header).unwrap();
f.write_all(b"\n").unwrap();
// line 2: a good frame
serde_json::to_writer(&mut f, &frame(0, 1)).unwrap();
f.write_all(b"\n").unwrap();
// line 3: garbage
f.write_all(b"{not a frame}\n").unwrap();
}
let mut a = FileReplayAdapter::open(tmp.path()).unwrap();
assert!(a.next_frame().unwrap().is_some()); // line 2 ok
let err = a.next_frame().unwrap_err(); // line 3
match err {
RvcsiError::Parse { offset, .. } => assert_eq!(offset, 3),
other => panic!("expected Parse at line 3, got {other:?}"),
}
}
#[test]
fn nonexistent_path_is_io_error() {
let err = FileReplayAdapter::open("/no/such/rvcsi/file.rvcsi").unwrap_err();
assert!(matches!(err, RvcsiError::Io(_)), "expected Io, got {err:?}");
}
#[test]
fn wrong_version_rejected() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let mut header = CaptureHeader::new(
SessionId(1),
SourceId::from("x"),
AdapterProfile::offline(AdapterKind::File),
);
header.rvcsi_capture_version = 999;
{
let mut f = File::create(tmp.path()).unwrap();
serde_json::to_writer(&mut f, &header).unwrap();
f.write_all(b"\n").unwrap();
}
let err = FileReplayAdapter::open(tmp.path()).unwrap_err();
assert!(matches!(err, RvcsiError::Parse { offset: 0, .. }));
}
}