Skip to main content

mimir_cli/
lib.rs

1//! Read-only inspection library backing the `mimir-cli` binary.
2//!
3//! Implements the rendering + verification surfaces of
4//! `docs/concepts/decoder-tool-contract.md`. Everything in this crate
5//! is read-only — no function here writes to a workspace or appends
6//! to a canonical log.
7//!
8//! Public surface (v1):
9//!
10//! - [`LispRenderer`] — `CanonicalRecord` → Lisp S-expression text
11//!   reconstructing the agent-visible fields of Sem / Epi / Pro / Inf
12//!   memory records. Backs the `decode` subcommand.
13//! - [`verify`] — integrity check on a `canonical.log` file covering
14//!   the framing, opcode, and symbol-reference corruption classes
15//!   from `decoder-tool-contract.md` § 6.
16//! - [`iso8601_from_millis`] — ms-since-epoch → `YYYY-MM-DDTHH:MM:SSZ`
17//!   string inverse of `mimir_core::parse`'s ISO-8601 loader, so
18//!   timestamps round-trip bit-perfect through render → re-parse.
19
20use std::path::Path;
21
22use thiserror::Error;
23
24use mimir_core::bind::SymbolTable;
25use mimir_core::canonical::{
26    decode_all, CanonicalRecord, DecodeError, EpiRecord, InfRecord, ProRecord, SemRecord,
27};
28use mimir_core::clock::ClockTime;
29use mimir_core::confidence::Confidence;
30use mimir_core::log::{CanonicalLog, LogBackend, LogError};
31use mimir_core::pipeline::Pipeline;
32use mimir_core::symbol::SymbolId;
33use mimir_core::value::Value;
34
35// -------------------------------------------------------------------
36// Render errors
37// -------------------------------------------------------------------
38
39/// Errors produced by [`LispRenderer::render_memory`]. None of these
40/// are fatal for the caller — the renderer can skip unrenderable
41/// records and continue.
42#[derive(Debug, Error, PartialEq, Eq)]
43pub enum RenderError {
44    /// A [`SymbolId`] referenced by a record is not allocated in the
45    /// supplied symbol table. For a well-formed committed log this
46    /// cannot happen (log replay populates the table); in a
47    /// corrupted log this surfaces the dangling reference.
48    #[error("unknown symbol id {id:?} in {context}")]
49    UnknownSymbol {
50        /// The offending ID.
51        id: SymbolId,
52        /// Human-readable slot name (for diagnostics).
53        context: &'static str,
54    },
55
56    /// The record's opcode is not a memory record (Sem / Epi / Pro /
57    /// Inf) — these shapes do not have a write-surface Lisp form.
58    /// The caller should `continue` past the record.
59    #[error("record is not a write-surface memory")]
60    NotAMemory,
61}
62
63// -------------------------------------------------------------------
64// Renderer
65// -------------------------------------------------------------------
66
67/// Renders memory records as Lisp S-expressions using a `SymbolTable`
68/// to resolve `SymbolId` → canonical name. Output conforms to
69/// `ir-write-surface.md` and re-parses to an equivalent
70/// `UnboundForm` (modulo librarian-assigned `memory_id` /
71/// `committed_at` / `observed_at` fields which the agent never
72/// provides).
73pub struct LispRenderer<'a> {
74    table: &'a SymbolTable,
75}
76
77impl<'a> LispRenderer<'a> {
78    /// Construct a renderer bound to a symbol table. The table must
79    /// resolve every `SymbolId` the renderer is asked to handle — use
80    /// `mimir_core::Store::open` to populate it from a canonical log.
81    #[must_use]
82    pub fn new(table: &'a SymbolTable) -> Self {
83        Self { table }
84    }
85
86    /// Render a memory record as a Lisp S-expression. Non-memory
87    /// records (`Checkpoint`, `SymbolAlloc`, edge records) return
88    /// [`RenderError::NotAMemory`] so the caller can skip them.
89    ///
90    /// # Errors
91    ///
92    /// - [`RenderError::NotAMemory`] for non-memory opcodes.
93    /// - [`RenderError::UnknownSymbol`] for symbol IDs not in the table.
94    pub fn render_memory(&self, record: &CanonicalRecord) -> Result<String, RenderError> {
95        match record {
96            CanonicalRecord::Sem(r) => self.render_sem(r),
97            CanonicalRecord::Epi(r) => self.render_epi(r),
98            CanonicalRecord::Pro(r) => self.render_pro(r),
99            CanonicalRecord::Inf(r) => self.render_inf(r),
100            _ => Err(RenderError::NotAMemory),
101        }
102    }
103
104    fn render_sem(&self, r: &SemRecord) -> Result<String, RenderError> {
105        Ok(format!(
106            "(sem @{subject} @{predicate} {object} :src @{source} :c {confidence} :v {valid_at})",
107            subject = self.name_of(r.s, "sem.s")?,
108            predicate = self.name_of(r.p, "sem.p")?,
109            object = self.render_value(&r.o, "sem.o")?,
110            source = self.name_of(r.source, "sem.source")?,
111            confidence = render_confidence(r.confidence),
112            valid_at = iso8601_from_millis(r.clocks.valid_at),
113        ))
114    }
115
116    fn render_epi(&self, r: &EpiRecord) -> Result<String, RenderError> {
117        let mut participants = String::from("(");
118        for (i, p) in r.participants.iter().enumerate() {
119            if i > 0 {
120                participants.push(' ');
121            }
122            participants.push('@');
123            participants.push_str(&self.name_of(*p, "epi.participant")?);
124        }
125        participants.push(')');
126        Ok(format!(
127            "(epi @{event_id} @{kind} {participants} @{location} :at {at_time} :obs {observed_at} :src @{source} :c {confidence})",
128            event_id = self.name_of(r.event_id, "epi.event_id")?,
129            kind = self.name_of(r.kind, "epi.kind")?,
130            location = self.name_of(r.location, "epi.location")?,
131            at_time = iso8601_from_millis(r.at_time),
132            observed_at = iso8601_from_millis(r.observed_at),
133            source = self.name_of(r.source, "epi.source")?,
134            confidence = render_confidence(r.confidence),
135        ))
136    }
137
138    fn render_pro(&self, r: &ProRecord) -> Result<String, RenderError> {
139        let mut out = format!(
140            "(pro @{rule_id} {trigger} {action}",
141            rule_id = self.name_of(r.rule_id, "pro.rule_id")?,
142            trigger = self.render_value(&r.trigger, "pro.trigger")?,
143            action = self.render_value(&r.action, "pro.action")?,
144        );
145        if let Some(pre) = &r.precondition {
146            out.push_str(" :pre ");
147            out.push_str(&self.render_value(pre, "pro.precondition")?);
148        }
149        out.push_str(" :scp @");
150        out.push_str(&self.name_of(r.scope, "pro.scope")?);
151        out.push_str(" :src @");
152        out.push_str(&self.name_of(r.source, "pro.source")?);
153        out.push_str(" :c ");
154        out.push_str(&render_confidence(r.confidence));
155        out.push(')');
156        Ok(out)
157    }
158
159    fn render_inf(&self, r: &InfRecord) -> Result<String, RenderError> {
160        let mut parents = String::from("(");
161        for (i, p) in r.derived_from.iter().enumerate() {
162            if i > 0 {
163                parents.push(' ');
164            }
165            parents.push('@');
166            parents.push_str(&self.name_of(*p, "inf.derived_from")?);
167        }
168        parents.push(')');
169        Ok(format!(
170            "(inf @{subject} @{predicate} {object} {parents} @{method} :c {confidence} :v {valid_at})",
171            subject = self.name_of(r.s, "inf.s")?,
172            predicate = self.name_of(r.p, "inf.p")?,
173            object = self.render_value(&r.o, "inf.o")?,
174            method = self.name_of(r.method, "inf.method")?,
175            confidence = render_confidence(r.confidence),
176            valid_at = iso8601_from_millis(r.clocks.valid_at),
177        ))
178    }
179
180    fn render_value(&self, value: &Value, context: &'static str) -> Result<String, RenderError> {
181        Ok(match value {
182            Value::Symbol(id) => format!("@{}", self.name_of(*id, context)?),
183            Value::Integer(n) => n.to_string(),
184            Value::Float(f) => render_float(*f),
185            Value::Boolean(b) => if *b { "true" } else { "false" }.to_string(),
186            Value::String(s) => render_string_literal(s),
187            Value::Timestamp(t) => iso8601_from_millis(*t),
188        })
189    }
190
191    fn name_of(&self, id: SymbolId, context: &'static str) -> Result<String, RenderError> {
192        self.table
193            .entry(id)
194            .map(|e| e.canonical_name.clone())
195            .ok_or(RenderError::UnknownSymbol { id, context })
196    }
197}
198
199fn render_confidence(c: Confidence) -> String {
200    // Five decimal digits exceed u16 resolution (65536 steps) so the
201    // parser re-quantizes losslessly.
202    format!("{:.5}", c.as_f32())
203}
204
205fn render_float(f: f64) -> String {
206    if !f.is_finite() {
207        // Parser rejects NaN / Inf; the renderer emits "nil" which
208        // bind will complain about, surfacing the corruption rather
209        // than silently losing it. For v1 corpora these should never
210        // appear.
211        return "nil".to_string();
212    }
213    // Use Rust's default Display, which round-trips f64 bit-for-bit.
214    // Append ".0" for integer values so the parser sees a float
215    // token, not an integer.
216    let s = format!("{f}");
217    if s.contains('.') || s.contains('e') || s.contains('E') {
218        s
219    } else {
220        format!("{s}.0")
221    }
222}
223
224fn render_string_literal(s: &str) -> String {
225    let mut out = String::with_capacity(s.len() + 2);
226    out.push('"');
227    for ch in s.chars() {
228        match ch {
229            '"' => out.push_str("\\\""),
230            '\\' => out.push_str("\\\\"),
231            '\n' => out.push_str("\\n"),
232            '\r' => out.push_str("\\r"),
233            '\t' => out.push_str("\\t"),
234            c => out.push(c),
235        }
236    }
237    out.push('"');
238    out
239}
240
241// -------------------------------------------------------------------
242// Timestamp rendering — inverse of mimir_core::parse's loader
243// -------------------------------------------------------------------
244
245/// ms-since-epoch → `YYYY-MM-DDTHH:MM:SSZ` UTC string. Matches the
246/// ISO-8601 format accepted by `mimir_core::parse`'s timestamp
247/// loader so the string round-trips bit-perfect.
248///
249/// Uses the Howard-Hinnant proleptic-Gregorian algorithm
250/// (integer-only; no chrono dependency) for the date component.
251#[must_use]
252#[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)]
253pub fn iso8601_from_millis(clock: ClockTime) -> String {
254    // ClockTime is u64 ms-since-epoch; Mimir never represents pre-
255    // epoch times (temporal-model.md § 9.1), so the i64 cast is safe
256    // for the full representable range.
257    let ms = clock.as_millis() as i64;
258    let days = ms.div_euclid(86_400_000);
259    let time_ms = ms.rem_euclid(86_400_000);
260    let (year, month, day) = civil_from_days(days);
261    let hour = time_ms / 3_600_000;
262    let minute = (time_ms % 3_600_000) / 60_000;
263    let second = (time_ms % 60_000) / 1_000;
264    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
265}
266
267/// Howard-Hinnant `civil_from_days`. Given days-since-Unix-epoch,
268/// returns `(year, month, day)` in the proleptic Gregorian calendar.
269/// Pure integer arithmetic; deterministic across architectures.
270#[allow(
271    clippy::cast_possible_truncation,
272    clippy::cast_possible_wrap,
273    clippy::cast_sign_loss,
274    clippy::similar_names
275)]
276fn civil_from_days(days: i64) -> (i32, u32, u32) {
277    let z = days + 719_468;
278    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
279    let doe = (z - era * 146_097) as u64;
280    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
281    let year_raw = yoe as i64 + era * 400;
282    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
283    let mp = (5 * doy + 2) / 153;
284    let d = doy - (153 * mp + 2) / 5 + 1;
285    let m = if mp < 10 { mp + 3 } else { mp - 9 };
286    let year = if m <= 2 { year_raw + 1 } else { year_raw };
287    (year as i32, m as u32, d as u32)
288}
289
290// -------------------------------------------------------------------
291// Verify
292// -------------------------------------------------------------------
293
294/// Classification of the trailing bytes past the last decodable
295/// record, if any.
296///
297/// Classification is by **decoder behavior**, not by checkpoint-commit
298/// boundary: `verify` walks the byte stream and inspects the error (if
299/// any) that stopped the walk. A partially-written record whose header
300/// fits but whose body is truncated is `OrphanTail`; any structural
301/// violation (unknown opcode, reserved sentinel, body underflow after
302/// a valid length) is `Corrupt`. Higher layers combine this with
303/// checkpoint state to decide recovery strategy.
304#[derive(Debug, PartialEq, Eq, Clone)]
305pub enum TailStatus {
306    /// The log decoded cleanly from start to end; no trailing bytes.
307    Clean,
308    /// Trailing bytes exist and the decoder stopped on a `Truncated`
309    /// error — consistent with a crashed-mid-write tail that
310    /// `write-protocol.md` § 10 expects to be truncated on next open.
311    OrphanTail {
312        /// Number of trailing bytes.
313        bytes: u64,
314    },
315    /// Trailing bytes exist and the decoder stopped on a non-
316    /// truncation error (unknown opcode, body underflow, reserved
317    /// sentinel, etc.) — genuine corruption, not the recoverable
318    /// append-was-interrupted pattern.
319    Corrupt {
320        /// Number of trailing bytes.
321        bytes: u64,
322        /// The `DecodeError` that stopped the walk.
323        first_decode_error: mimir_core::canonical::DecodeError,
324    },
325}
326
327impl TailStatus {
328    /// `true` if the tail is clean (no trailing bytes).
329    #[must_use]
330    pub const fn is_clean(&self) -> bool {
331        matches!(self, Self::Clean)
332    }
333
334    /// `true` if the tail indicates genuine corruption rather than a
335    /// recoverable truncated write.
336    #[must_use]
337    pub const fn is_corrupt(&self) -> bool {
338        matches!(self, Self::Corrupt { .. })
339    }
340
341    /// Number of trailing bytes (zero for `Clean`).
342    #[must_use]
343    pub const fn trailing_bytes(&self) -> u64 {
344        match self {
345            Self::Clean => 0,
346            Self::OrphanTail { bytes } | Self::Corrupt { bytes, .. } => *bytes,
347        }
348    }
349}
350
351/// Result of a `verify` pass.
352#[derive(Debug, PartialEq, Eq)]
353pub struct VerifyReport {
354    /// Number of records successfully decoded.
355    pub records_decoded: usize,
356    /// Number of `Checkpoint` boundaries found.
357    pub checkpoints: usize,
358    /// Number of memory records (Sem / Epi / Pro / Inf).
359    pub memory_records: usize,
360    /// Number of SYMBOL_* records.
361    pub symbol_events: usize,
362    /// Classification of the tail past the last decoded record —
363    /// clean, recoverable orphan-tail truncation, or genuine
364    /// corruption (with the underlying `DecodeError` preserved).
365    pub tail: TailStatus,
366    /// Dangling symbol references found in memory records (the
367    /// referenced `SymbolId` had no preceding `SymbolAlloc`).
368    pub dangling_symbols: usize,
369}
370
371impl VerifyReport {
372    /// Convenience accessor — bytes past the last decoded record
373    /// regardless of whether they're an orphan tail or corruption.
374    #[must_use]
375    pub const fn trailing_bytes(&self) -> u64 {
376        self.tail.trailing_bytes()
377    }
378}
379
380/// Errors produced by [`verify`] and [`load_table_from_log`].
381#[derive(Debug, Error)]
382pub enum VerifyError {
383    /// Underlying I/O failure.
384    #[error("verify I/O: {0}")]
385    Log(#[from] LogError),
386
387    /// Committed canonical bytes failed to decode. Distinct from the
388    /// trailing-tail-truncation case, which `verify` reports via
389    /// [`VerifyReport::trailing_bytes`]. This is genuine corruption in
390    /// the durable region of the log.
391    #[error("committed canonical bytes failed to decode: {source}")]
392    CorruptCommittedLog {
393        /// Underlying decoder error.
394        #[from]
395        source: mimir_core::canonical::DecodeError,
396    },
397
398    /// Replay of a `SYMBOL_*` record failed while rebuilding the
399    /// table — duplicate allocations with distinct names, aliases
400    /// pointing at unallocated symbols, etc.
401    #[error("symbol-replay conflict during load: {source}")]
402    SymbolReplay {
403        /// Underlying bind error.
404        #[from]
405        source: mimir_core::bind::BindError,
406    },
407
408    /// `last_checkpoint_end` returned a byte offset that does not fit
409    /// in `usize` on this target. Only possible on 32-bit hosts with
410    /// multi-gigabyte logs, but we surface it rather than silently
411    /// widening.
412    #[error("committed log offset {offset} exceeds usize on this target")]
413    CommittedEndOverflow {
414        /// The u64 offset.
415        offset: u64,
416    },
417}
418
419/// Read-only integrity check on a canonical log.
420///
421/// Checks:
422/// 1. Every record decodes cleanly until a trailing-bytes boundary.
423/// 2. Every memory record's `SymbolId` references resolve against
424///    the symbol table reconstructed by replaying the log's
425///    `SYMBOL_*` events.
426///
427/// The report distinguishes "clean log + trailing orphans" (a
428/// recoverable state per `write-protocol.md` § 10) from "dangling
429/// symbol reference in committed data" (a corruption signal).
430///
431/// # Errors
432///
433/// - [`VerifyError::Log`] on filesystem read failure.
434pub fn verify(log_path: &Path) -> Result<VerifyReport, VerifyError> {
435    let mut log = CanonicalLog::open(log_path)?;
436    let bytes = log.read_all()?;
437    let total_len = bytes.len() as u64;
438
439    // Walk records; stop on first decode error (matches Store recovery
440    // semantics). Track the final decoded-offset for trailing_bytes.
441    let mut pos: usize = 0;
442    let mut records_decoded = 0_usize;
443    let mut checkpoints = 0_usize;
444    let mut memory_records = 0_usize;
445    let mut symbol_events = 0_usize;
446
447    // Reconstruct a SymbolTable as we walk so we can validate symbol
448    // references in memory records.
449    let mut table = SymbolTable::new();
450
451    let mut first_stop_error: Option<mimir_core::canonical::DecodeError> = None;
452    while pos < bytes.len() {
453        let remaining = &bytes[pos..];
454        match mimir_core::canonical::decode_record(remaining) {
455            Ok((record, consumed)) => {
456                pos += consumed;
457                records_decoded += 1;
458                apply_for_verify(
459                    &record,
460                    &mut table,
461                    &mut checkpoints,
462                    &mut memory_records,
463                    &mut symbol_events,
464                );
465            }
466            Err(e) => {
467                first_stop_error = Some(e);
468                break;
469            }
470        }
471    }
472
473    // Second pass: dangling-symbol detection. decode_all covers all
474    // records we could read; we traverse memory records and check that
475    // every SymbolId has an entry.
476    let dangling_symbols = count_dangling_symbols(&bytes[..pos], &table);
477
478    let trailing = total_len - pos as u64;
479    // Classify the tail. `Truncated` and `LengthMismatch` correspond
480    // to the "append was interrupted mid-record" pattern (`write-
481    // protocol.md` § 10 — the writer crashed before fsync completed
482    // and the final record's bytes are short). Every other
483    // `DecodeError` variant indicates structurally-wrong bytes that
484    // no healthy write path could have produced.
485    let tail = match (first_stop_error, trailing) {
486        (None, 0) => TailStatus::Clean,
487        (None, bytes) => TailStatus::OrphanTail { bytes },
488        (Some(DecodeError::Truncated { .. } | DecodeError::LengthMismatch { .. }), bytes) => {
489            TailStatus::OrphanTail { bytes }
490        }
491        (Some(err), bytes) => TailStatus::Corrupt {
492            bytes,
493            first_decode_error: err,
494        },
495    };
496
497    Ok(VerifyReport {
498        records_decoded,
499        checkpoints,
500        memory_records,
501        symbol_events,
502        tail,
503        dangling_symbols,
504    })
505}
506
507fn apply_for_verify(
508    record: &CanonicalRecord,
509    table: &mut SymbolTable,
510    checkpoints: &mut usize,
511    memory_records: &mut usize,
512    symbol_events: &mut usize,
513) {
514    match record {
515        CanonicalRecord::SymbolAlloc(e) => {
516            *symbol_events += 1;
517            // Replay allocate; ignore conflicts (e.g. duplicate
518            // allocations) — verify reports them via dangling /
519            // trailing counters, not via mutation errors.
520            let _ = table.replay_allocate(e.symbol_id, e.name.clone(), e.symbol_kind);
521        }
522        CanonicalRecord::SymbolAlias(e) => {
523            *symbol_events += 1;
524            let _ = table.replay_alias(e.symbol_id, e.name.clone());
525        }
526        CanonicalRecord::SymbolRename(e) => {
527            *symbol_events += 1;
528            let _ = table.replay_rename(e.symbol_id, e.name.clone());
529        }
530        CanonicalRecord::SymbolRetire(e) => {
531            *symbol_events += 1;
532            let _ = table.replay_retire(e.symbol_id, e.name.clone());
533        }
534        CanonicalRecord::Checkpoint(_) => {
535            *checkpoints += 1;
536        }
537        CanonicalRecord::Sem(_)
538        | CanonicalRecord::Epi(_)
539        | CanonicalRecord::Pro(_)
540        | CanonicalRecord::Inf(_) => {
541            *memory_records += 1;
542        }
543        _ => {}
544    }
545}
546
547fn count_dangling_symbols(bytes: &[u8], table: &SymbolTable) -> usize {
548    let Ok(records) = decode_all(bytes) else {
549        return 0;
550    };
551    let mut dangling = 0_usize;
552    for record in records {
553        match record {
554            CanonicalRecord::Sem(r) => {
555                for id in [r.s, r.p, r.source, r.memory_id] {
556                    if table.entry(id).is_none() {
557                        dangling += 1;
558                    }
559                }
560                if let Value::Symbol(id) = r.o {
561                    if table.entry(id).is_none() {
562                        dangling += 1;
563                    }
564                }
565            }
566            CanonicalRecord::Epi(r) => {
567                for id in [r.event_id, r.kind, r.location, r.source, r.memory_id] {
568                    if table.entry(id).is_none() {
569                        dangling += 1;
570                    }
571                }
572                for p in &r.participants {
573                    if table.entry(*p).is_none() {
574                        dangling += 1;
575                    }
576                }
577            }
578            CanonicalRecord::Pro(r) => {
579                for id in [r.rule_id, r.scope, r.source, r.memory_id] {
580                    if table.entry(id).is_none() {
581                        dangling += 1;
582                    }
583                }
584            }
585            CanonicalRecord::Inf(r) => {
586                for id in [r.s, r.p, r.method, r.memory_id] {
587                    if table.entry(id).is_none() {
588                        dangling += 1;
589                    }
590                }
591                for p in &r.derived_from {
592                    if table.entry(*p).is_none() {
593                        dangling += 1;
594                    }
595                }
596            }
597            _ => {}
598        }
599    }
600    dangling
601}
602
603// -------------------------------------------------------------------
604// Helpers for the binary — decode a log + return its reconstructed
605// pipeline table. Thin wrapper around Store::open.
606// -------------------------------------------------------------------
607
608/// Open a canonical log read-only and return the reconstructed
609/// [`SymbolTable`] (via a temporary [`Pipeline`])
610/// so the renderer and verify helpers can share a table.
611///
612/// # Errors
613///
614/// - [`VerifyError::Log`] on filesystem read failure.
615pub fn load_table_from_log(log_path: &Path) -> Result<SymbolTable, VerifyError> {
616    let mut log = CanonicalLog::open(log_path)?;
617    let bytes = log.read_all()?;
618    let committed_end = log.last_checkpoint_end()?;
619    // Only replay committed data to avoid decoding corrupted tails.
620    let committed_end =
621        usize::try_from(committed_end).map_err(|_| VerifyError::CommittedEndOverflow {
622            offset: committed_end,
623        })?;
624    let records = decode_all(&bytes[..committed_end])?;
625    let mut pipeline = Pipeline::new();
626    for record in records {
627        match record {
628            CanonicalRecord::SymbolAlloc(e) => {
629                pipeline.replay_allocate(e.symbol_id, e.name, e.symbol_kind)?;
630            }
631            CanonicalRecord::SymbolAlias(e) => {
632                pipeline.replay_alias(e.symbol_id, e.name)?;
633            }
634            CanonicalRecord::SymbolRename(e) => {
635                pipeline.replay_rename(e.symbol_id, e.name)?;
636            }
637            CanonicalRecord::SymbolRetire(e) => {
638                pipeline.replay_retire(e.symbol_id, e.name)?;
639            }
640            _ => {}
641        }
642    }
643    Ok(pipeline.table().clone())
644}
645
646// -------------------------------------------------------------------
647// Tests
648// -------------------------------------------------------------------
649
650#[cfg(test)]
651#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
652mod tests {
653    use super::*;
654
655    #[test]
656    fn iso8601_renders_unix_epoch_zero() {
657        assert_eq!(
658            iso8601_from_millis(ClockTime::try_from_millis(0).expect("non-sentinel")),
659            "1970-01-01T00:00:00Z"
660        );
661    }
662
663    #[test]
664    fn iso8601_renders_y2k() {
665        // 2000-01-01T00:00:00Z = 946,684,800,000 ms.
666        assert_eq!(
667            iso8601_from_millis(ClockTime::try_from_millis(946_684_800_000).expect("non-sentinel")),
668            "2000-01-01T00:00:00Z"
669        );
670    }
671
672    #[test]
673    fn iso8601_renders_known_timestamp() {
674        // 2024-01-15T00:00:00Z = 1_705_276_800_000 ms.
675        assert_eq!(
676            iso8601_from_millis(
677                ClockTime::try_from_millis(1_705_276_800_000).expect("non-sentinel")
678            ),
679            "2024-01-15T00:00:00Z"
680        );
681    }
682
683    #[test]
684    fn render_float_adds_fractional_for_integers() {
685        assert_eq!(render_float(3.0), "3.0");
686        assert_eq!(render_float(0.0), "0.0");
687    }
688
689    #[test]
690    fn render_float_preserves_fractional() {
691        assert_eq!(render_float(0.5), "0.5");
692    }
693
694    #[test]
695    fn render_string_literal_escapes_special_chars() {
696        assert_eq!(render_string_literal("hi"), r#""hi""#);
697        assert_eq!(render_string_literal("a\"b"), r#""a\"b""#);
698        assert_eq!(render_string_literal("x\nn"), r#""x\nn""#);
699    }
700
701    #[test]
702    fn render_confidence_gives_stable_decimal() {
703        let c = Confidence::try_from_f32(0.8).unwrap();
704        // 0.8 * 65535 ≈ 52428 → as f32 ≈ 0.79999...; five decimals.
705        let s = render_confidence(c);
706        assert!(s.starts_with("0.7999") || s.starts_with("0.8000"));
707        assert_eq!(s.chars().filter(|c| *c == '.').count(), 1);
708    }
709}