Skip to main content

ruvector_temporal_tensor/
metrics.rs

1//! Witness logging and decision audit for the temporal tensor store.
2//!
3//! Provides an append-only [`WitnessLog`] that records every auditable decision
4//! (tier changes, evictions, checksum failures, etc.) and aggregate
5//! [`StoreMetrics`] for dashboards and alerting.
6//!
7//! All types are zero-dependency and allocation-minimal so they can live on the
8//! hot path without measurable overhead.
9//!
10//! # Usage
11//!
12//! ```ignore
13//! use ruvector_temporal_tensor::metrics::{WitnessLog, WitnessEvent, StoreMetrics};
14//!
15//! let mut log = WitnessLog::new(1024);
16//! log.record(42, WitnessEvent::Eviction {
17//!     key: BlockKey(7),
18//!     score: 0.1,
19//!     bytes_freed: 4096,
20//! });
21//! assert_eq!(log.count_evictions(), 1);
22//! ```
23
24use crate::store::{BlockKey, ReconstructPolicy, Tier};
25
26// ---------------------------------------------------------------------------
27// Witness record types
28// ---------------------------------------------------------------------------
29
30/// A witness record for an auditable decision.
31///
32/// Each record pairs a monotonic timestamp (tick counter) with the event that
33/// occurred at that instant. Records are append-only and immutable once stored.
34#[derive(Clone, Debug)]
35pub struct WitnessRecord {
36    /// Monotonic tick at which the event was witnessed.
37    pub timestamp: u64,
38    /// The event that was witnessed.
39    pub event: WitnessEvent,
40}
41
42/// Types of witnessed events.
43///
44/// Every variant captures the minimum context required to reconstruct the
45/// decision after the fact (key, scores, tiers, byte counts).
46#[derive(Clone, Debug)]
47pub enum WitnessEvent {
48    /// A block was accessed (read or write).
49    Access {
50        key: BlockKey,
51        score: f32,
52        tier: Tier,
53    },
54    /// A block changed tiers.
55    TierChange {
56        key: BlockKey,
57        from_tier: Tier,
58        to_tier: Tier,
59        score: f32,
60        reason: TierChangeReason,
61    },
62    /// A block was evicted (compressed to zero).
63    Eviction {
64        key: BlockKey,
65        score: f32,
66        bytes_freed: usize,
67    },
68    /// A maintenance tick was processed.
69    Maintenance {
70        upgrades: u32,
71        downgrades: u32,
72        evictions: u32,
73        bytes_freed: usize,
74        budget_remaining_bytes: u32,
75        budget_remaining_ops: u32,
76    },
77    /// A delta chain was compacted.
78    Compaction { key: BlockKey, chain_len_before: u8 },
79    /// A checksum mismatch was detected.
80    ChecksumFailure {
81        key: BlockKey,
82        expected: u32,
83        actual: u32,
84    },
85    /// A block was reconstructed from deltas or factors.
86    Reconstruction {
87        key: BlockKey,
88        policy: ReconstructPolicy,
89        success: bool,
90    },
91}
92
93/// Reason a block changed tiers.
94#[derive(Clone, Debug, PartialEq, Eq)]
95pub enum TierChangeReason {
96    /// Score rose above the upgrade threshold.
97    ScoreUpgrade,
98    /// Score fell below the downgrade threshold.
99    ScoreDowngrade,
100    /// Byte-cap pressure forced a downgrade.
101    ByteCapPressure,
102    /// An operator or API caller forced a tier change.
103    ManualOverride,
104}
105
106// ---------------------------------------------------------------------------
107// Aggregate metrics
108// ---------------------------------------------------------------------------
109
110/// Aggregate metrics for the temporal tensor store.
111///
112/// All counters are monotonically increasing over the lifetime of the store.
113/// Gauge-style fields (e.g. `tier0_blocks`) reflect the current state.
114#[derive(Clone, Debug, Default)]
115pub struct StoreMetrics {
116    /// Total number of live blocks across all tiers.
117    pub total_blocks: u64,
118    /// Number of blocks in tier 0 (raw / uncompressed).
119    pub tier0_blocks: u64,
120    /// Number of blocks in tier 1 (hot, 8-bit).
121    pub tier1_blocks: u64,
122    /// Number of blocks in tier 2 (warm, 7/5-bit).
123    pub tier2_blocks: u64,
124    /// Number of blocks in tier 3 (cold, 3-bit).
125    pub tier3_blocks: u64,
126    /// Total stored bytes in tier 1.
127    pub tier1_bytes: u64,
128    /// Total stored bytes in tier 2.
129    pub tier2_bytes: u64,
130    /// Total stored bytes in tier 3.
131    pub tier3_bytes: u64,
132    /// Cumulative read count.
133    pub total_reads: u64,
134    /// Cumulative write count.
135    pub total_writes: u64,
136    /// Cumulative eviction count.
137    pub total_evictions: u64,
138    /// Cumulative upgrade count.
139    pub total_upgrades: u64,
140    /// Cumulative downgrade count.
141    pub total_downgrades: u64,
142    /// Cumulative reconstruction count.
143    pub total_reconstructions: u64,
144    /// Cumulative checksum failure count.
145    pub total_checksum_failures: u64,
146    /// Cumulative compaction count.
147    pub total_compactions: u64,
148    /// Tier flips per block per minute over the last minute.
149    pub tier_flips_last_minute: f32,
150    /// Average score of tier 1 blocks.
151    pub avg_score_tier1: f32,
152    /// Average score of tier 2 blocks.
153    pub avg_score_tier2: f32,
154    /// Average score of tier 3 blocks.
155    pub avg_score_tier3: f32,
156}
157
158impl StoreMetrics {
159    /// Create a new zeroed metrics struct.
160    pub fn new() -> Self {
161        Self::default()
162    }
163
164    /// Compression ratio: raw f32 bytes / stored bytes.
165    ///
166    /// Raw bytes are estimated as `total_blocks * average_tensor_len * 4`, but
167    /// since we lack per-block tensor lengths here, we approximate with the
168    /// tier-0 identity: each tier-0 block is already f32, so the stored bytes
169    /// for tier 0 equal the raw bytes. The ratio is therefore:
170    ///
171    /// `(tier0_raw + tier1_raw + tier2_raw + tier3_raw) / total_stored_bytes`
172    ///
173    /// Because we don't track raw bytes per tier at this level, we report
174    /// `total_stored_bytes / total_stored_bytes` as a baseline and let callers
175    /// that have richer context compute the true ratio. For a simple heuristic,
176    /// we use the known compression ratios: tier1 ~4x, tier2 ~5.5x, tier3 ~10.67x.
177    pub fn compression_ratio(&self) -> f32 {
178        let stored = self.total_stored_bytes();
179        if stored == 0 {
180            return 0.0;
181        }
182        let raw_estimate = (self.tier1_bytes as f64 * 4.0)
183            + (self.tier2_bytes as f64 * 5.5)
184            + (self.tier3_bytes as f64 * 10.67);
185        raw_estimate as f32 / stored as f32
186    }
187
188    /// Total stored bytes across all compressed tiers (1, 2, 3).
189    ///
190    /// Tier 0 blocks are raw f32 and not tracked separately; callers can
191    /// compute tier-0 bytes as `tier0_blocks * tensor_len * 4` if needed.
192    pub fn total_stored_bytes(&self) -> u64 {
193        self.tier1_bytes + self.tier2_bytes + self.tier3_bytes
194    }
195
196    /// Generate a human-readable multi-line status report.
197    pub fn format_report(&self) -> String {
198        let mut s = String::with_capacity(512);
199        s.push_str("=== Temporal Tensor Store Report ===\n");
200        s.push_str(&format_line("Total blocks", self.total_blocks));
201        s.push_str(&format_line("  Tier0 (raw)", self.tier0_blocks));
202        s.push_str(&format_line("  Tier1 (hot)", self.tier1_blocks));
203        s.push_str(&format_line("  Tier2 (warm)", self.tier2_blocks));
204        s.push_str(&format_line("  Tier3 (cold)", self.tier3_blocks));
205        s.push_str("--- Storage ---\n");
206        s.push_str(&format_line("Tier1 bytes", self.tier1_bytes));
207        s.push_str(&format_line("Tier2 bytes", self.tier2_bytes));
208        s.push_str(&format_line("Tier3 bytes", self.tier3_bytes));
209        s.push_str(&format_line("Total stored", self.total_stored_bytes()));
210        s.push_str(&format!(
211            "Compression ratio: {:.2}x\n",
212            self.compression_ratio()
213        ));
214        s.push_str("--- Operations ---\n");
215        s.push_str(&format_line("Reads", self.total_reads));
216        s.push_str(&format_line("Writes", self.total_writes));
217        s.push_str(&format_line("Evictions", self.total_evictions));
218        s.push_str(&format_line("Upgrades", self.total_upgrades));
219        s.push_str(&format_line("Downgrades", self.total_downgrades));
220        s.push_str(&format_line("Reconstructions", self.total_reconstructions));
221        s.push_str(&format_line("Compactions", self.total_compactions));
222        s.push_str(&format_line(
223            "Checksum failures",
224            self.total_checksum_failures,
225        ));
226        s.push_str(&format!(
227            "Tier flip rate: {:.4}/block/min\n",
228            self.tier_flips_last_minute
229        ));
230        s
231    }
232
233    /// Generate a JSON representation (no serde dependency).
234    pub fn format_json(&self) -> String {
235        format!(
236            concat!(
237                "{{",
238                "\"total_blocks\":{},",
239                "\"tier0_blocks\":{},",
240                "\"tier1_blocks\":{},",
241                "\"tier2_blocks\":{},",
242                "\"tier3_blocks\":{},",
243                "\"tier1_bytes\":{},",
244                "\"tier2_bytes\":{},",
245                "\"tier3_bytes\":{},",
246                "\"total_reads\":{},",
247                "\"total_writes\":{},",
248                "\"total_evictions\":{},",
249                "\"total_upgrades\":{},",
250                "\"total_downgrades\":{},",
251                "\"total_reconstructions\":{},",
252                "\"total_checksum_failures\":{},",
253                "\"total_compactions\":{},",
254                "\"compression_ratio\":{:.4},",
255                "\"tier_flips_last_minute\":{:.4},",
256                "\"avg_score_tier1\":{:.4},",
257                "\"avg_score_tier2\":{:.4},",
258                "\"avg_score_tier3\":{:.4}",
259                "}}"
260            ),
261            self.total_blocks,
262            self.tier0_blocks,
263            self.tier1_blocks,
264            self.tier2_blocks,
265            self.tier3_blocks,
266            self.tier1_bytes,
267            self.tier2_bytes,
268            self.tier3_bytes,
269            self.total_reads,
270            self.total_writes,
271            self.total_evictions,
272            self.total_upgrades,
273            self.total_downgrades,
274            self.total_reconstructions,
275            self.total_checksum_failures,
276            self.total_compactions,
277            self.compression_ratio(),
278            self.tier_flips_last_minute,
279            self.avg_score_tier1,
280            self.avg_score_tier2,
281            self.avg_score_tier3,
282        )
283    }
284
285    /// Automated health assessment.
286    pub fn health_check(&self) -> StoreHealthStatus {
287        // Critical: checksum failures
288        if self.total_checksum_failures > 0 {
289            return StoreHealthStatus::Critical(format!(
290                "{} checksum failures detected",
291                self.total_checksum_failures
292            ));
293        }
294        // Warning: high tier flip rate
295        if self.tier_flips_last_minute > 0.5 {
296            return StoreHealthStatus::Warning(format!(
297                "High tier flip rate: {:.3}/block/min",
298                self.tier_flips_last_minute
299            ));
300        }
301        // Warning: mostly evictions
302        if self.total_evictions > 0 && self.total_blocks > 0 {
303            let eviction_ratio =
304                self.total_evictions as f32 / (self.total_reads + self.total_writes).max(1) as f32;
305            if eviction_ratio > 0.3 {
306                return StoreHealthStatus::Warning(format!(
307                    "High eviction ratio: {:.1}%",
308                    eviction_ratio * 100.0
309                ));
310            }
311        }
312        StoreHealthStatus::Healthy
313    }
314}
315
316/// Health status of the store.
317#[derive(Clone, Debug, PartialEq)]
318pub enum StoreHealthStatus {
319    /// Everything is operating normally.
320    Healthy,
321    /// Non-critical issue detected.
322    Warning(String),
323    /// Critical issue requiring attention.
324    Critical(String),
325}
326
327// ---------------------------------------------------------------------------
328// Witness log (ring buffer)
329// ---------------------------------------------------------------------------
330
331/// Append-only witness log with configurable capacity.
332///
333/// When the log reaches capacity, the oldest records are dropped to make room
334/// for new ones, giving ring-buffer semantics. This bounds memory usage while
335/// preserving the most recent history for audit trails and flip-rate
336/// calculations.
337pub struct WitnessLog {
338    records: Vec<WitnessRecord>,
339    capacity: usize,
340}
341
342impl WitnessLog {
343    /// Create a new witness log with the given maximum capacity.
344    ///
345    /// A capacity of zero is treated as one (at least one record can be stored).
346    pub fn new(capacity: usize) -> Self {
347        let capacity = capacity.max(1);
348        Self {
349            records: Vec::with_capacity(capacity.min(1024)),
350            capacity,
351        }
352    }
353
354    /// Record a witness event at the given timestamp.
355    ///
356    /// If the log is at capacity, the oldest record is removed first.
357    pub fn record(&mut self, timestamp: u64, event: WitnessEvent) {
358        if self.records.len() >= self.capacity {
359            self.records.remove(0);
360        }
361        self.records.push(WitnessRecord { timestamp, event });
362    }
363
364    /// Number of recorded events currently in the log.
365    pub fn len(&self) -> usize {
366        self.records.len()
367    }
368
369    /// Whether the log contains no records.
370    pub fn is_empty(&self) -> bool {
371        self.records.is_empty()
372    }
373
374    /// Get the most recent `n` records.
375    ///
376    /// Returns fewer than `n` if the log does not contain that many records.
377    pub fn recent(&self, n: usize) -> &[WitnessRecord] {
378        let start = self.records.len().saturating_sub(n);
379        &self.records[start..]
380    }
381
382    /// Get all records currently in the log.
383    pub fn all(&self) -> &[WitnessRecord] {
384        &self.records
385    }
386
387    /// Clear all records from the log.
388    pub fn clear(&mut self) {
389        self.records.clear();
390    }
391
392    /// Count the number of [`WitnessEvent::TierChange`] records.
393    pub fn count_tier_changes(&self) -> usize {
394        self.records
395            .iter()
396            .filter(|r| matches!(r.event, WitnessEvent::TierChange { .. }))
397            .count()
398    }
399
400    /// Count the number of [`WitnessEvent::Eviction`] records.
401    pub fn count_evictions(&self) -> usize {
402        self.records
403            .iter()
404            .filter(|r| matches!(r.event, WitnessEvent::Eviction { .. }))
405            .count()
406    }
407
408    /// Count the number of [`WitnessEvent::ChecksumFailure`] records.
409    pub fn count_checksum_failures(&self) -> usize {
410        self.records
411            .iter()
412            .filter(|r| matches!(r.event, WitnessEvent::ChecksumFailure { .. }))
413            .count()
414    }
415
416    /// Compute tier flip rate: tier changes per block per minute.
417    ///
418    /// `window_ticks` is the size of the time window to consider (only records
419    /// whose timestamp is >= `max_timestamp - window_ticks` are counted).
420    /// `num_blocks` is the current total block count (used as the denominator).
421    ///
422    /// Returns `0.0` when `num_blocks` is zero or when no tier changes fall
423    /// within the window.
424    pub fn tier_flip_rate(&self, window_ticks: u64, num_blocks: u64) -> f32 {
425        if num_blocks == 0 || self.records.is_empty() {
426            return 0.0;
427        }
428
429        let max_ts = self.records.iter().map(|r| r.timestamp).max().unwrap_or(0);
430        let min_ts = max_ts.saturating_sub(window_ticks);
431
432        let flips = self
433            .records
434            .iter()
435            .filter(|r| r.timestamp >= min_ts)
436            .filter(|r| matches!(r.event, WitnessEvent::TierChange { .. }))
437            .count() as f32;
438
439        flips / num_blocks as f32
440    }
441}
442
443// ---------------------------------------------------------------------------
444// Point-in-time snapshot
445// ---------------------------------------------------------------------------
446
447/// A point-in-time snapshot of store state for serialization and export.
448///
449/// Captures the metrics, tier distribution (block counts), and byte distribution
450/// at a single instant.
451#[derive(Clone, Debug)]
452pub struct StoreSnapshot {
453    /// Monotonic tick at which the snapshot was taken.
454    pub timestamp: u64,
455    /// Aggregate metrics at snapshot time.
456    pub metrics: StoreMetrics,
457    /// Block count per tier: `[tier0, tier1, tier2, tier3]`.
458    pub tier_distribution: [u64; 4],
459    /// Byte count per tier: `[tier0, tier1, tier2, tier3]`.
460    pub byte_distribution: [u64; 4],
461}
462
463impl StoreSnapshot {
464    /// Serialize to a simple `key=value` text format.
465    ///
466    /// Each line is `key=value\n`. Numeric values are printed in decimal.
467    /// This format is intentionally trivial to parse so that external tools
468    /// (dashboards, log aggregators) can ingest it without pulling in a JSON
469    /// library.
470    pub fn to_bytes(&self) -> Vec<u8> {
471        let mut buf = Vec::with_capacity(512);
472
473        push_kv(&mut buf, "timestamp", self.timestamp);
474        push_kv(&mut buf, "total_blocks", self.metrics.total_blocks);
475        push_kv(&mut buf, "tier0_blocks", self.metrics.tier0_blocks);
476        push_kv(&mut buf, "tier1_blocks", self.metrics.tier1_blocks);
477        push_kv(&mut buf, "tier2_blocks", self.metrics.tier2_blocks);
478        push_kv(&mut buf, "tier3_blocks", self.metrics.tier3_blocks);
479        push_kv(&mut buf, "tier1_bytes", self.metrics.tier1_bytes);
480        push_kv(&mut buf, "tier2_bytes", self.metrics.tier2_bytes);
481        push_kv(&mut buf, "tier3_bytes", self.metrics.tier3_bytes);
482        push_kv(&mut buf, "total_reads", self.metrics.total_reads);
483        push_kv(&mut buf, "total_writes", self.metrics.total_writes);
484        push_kv(&mut buf, "total_evictions", self.metrics.total_evictions);
485        push_kv(&mut buf, "total_upgrades", self.metrics.total_upgrades);
486        push_kv(&mut buf, "total_downgrades", self.metrics.total_downgrades);
487        push_kv(
488            &mut buf,
489            "total_reconstructions",
490            self.metrics.total_reconstructions,
491        );
492        push_kv(
493            &mut buf,
494            "total_checksum_failures",
495            self.metrics.total_checksum_failures,
496        );
497        push_kv(
498            &mut buf,
499            "total_compactions",
500            self.metrics.total_compactions,
501        );
502        push_kv_f32(
503            &mut buf,
504            "tier_flips_last_minute",
505            self.metrics.tier_flips_last_minute,
506        );
507        push_kv_f32(&mut buf, "avg_score_tier1", self.metrics.avg_score_tier1);
508        push_kv_f32(&mut buf, "avg_score_tier2", self.metrics.avg_score_tier2);
509        push_kv_f32(&mut buf, "avg_score_tier3", self.metrics.avg_score_tier3);
510        push_kv_f32(
511            &mut buf,
512            "compression_ratio",
513            self.metrics.compression_ratio(),
514        );
515        push_kv(
516            &mut buf,
517            "total_stored_bytes",
518            self.metrics.total_stored_bytes(),
519        );
520
521        // Distributions
522        for (i, &count) in self.tier_distribution.iter().enumerate() {
523            push_kv_indexed(&mut buf, "tier_dist", i, count);
524        }
525        for (i, &bytes) in self.byte_distribution.iter().enumerate() {
526            push_kv_indexed(&mut buf, "byte_dist", i, bytes);
527        }
528
529        buf
530    }
531}
532
533// ---------------------------------------------------------------------------
534// Time-series metrics ring buffer
535// ---------------------------------------------------------------------------
536
537/// Ring buffer of [`StoreMetrics`] snapshots for trend analysis.
538pub struct MetricsSeries {
539    snapshots: Vec<(u64, StoreMetrics)>,
540    capacity: usize,
541}
542
543/// Trend analysis computed from a [`MetricsSeries`].
544#[derive(Clone, Debug)]
545pub struct MetricsTrend {
546    /// Evictions per snapshot (rate of change).
547    pub eviction_rate: f32,
548    /// Whether compression ratio is improving over recent snapshots.
549    pub compression_improving: bool,
550    /// Whether tier distribution is stable (low variance).
551    pub tier_distribution_stable: bool,
552}
553
554impl MetricsSeries {
555    /// Create a new series with the given capacity.
556    pub fn new(capacity: usize) -> Self {
557        Self {
558            snapshots: Vec::with_capacity(capacity.min(256)),
559            capacity: capacity.max(1),
560        }
561    }
562
563    /// Record a metrics snapshot at the given timestamp.
564    pub fn record(&mut self, timestamp: u64, metrics: StoreMetrics) {
565        if self.snapshots.len() >= self.capacity {
566            self.snapshots.remove(0);
567        }
568        self.snapshots.push((timestamp, metrics));
569    }
570
571    /// Number of snapshots stored.
572    pub fn len(&self) -> usize {
573        self.snapshots.len()
574    }
575
576    /// Whether the series is empty.
577    pub fn is_empty(&self) -> bool {
578        self.snapshots.is_empty()
579    }
580
581    /// Get the most recent snapshot.
582    pub fn latest(&self) -> Option<&(u64, StoreMetrics)> {
583        self.snapshots.last()
584    }
585
586    /// Compute trend analysis over the stored snapshots.
587    pub fn trend(&self) -> MetricsTrend {
588        if self.snapshots.len() < 2 {
589            return MetricsTrend {
590                eviction_rate: 0.0,
591                compression_improving: false,
592                tier_distribution_stable: true,
593            };
594        }
595
596        let n = self.snapshots.len();
597        let first = &self.snapshots[0].1;
598        let last = &self.snapshots[n - 1].1;
599
600        // Eviction rate: evictions delta / number of snapshots
601        let eviction_delta = last.total_evictions.saturating_sub(first.total_evictions);
602        let eviction_rate = eviction_delta as f32 / n as f32;
603
604        // Compression trend: compare first half average to second half average
605        let mid = n / 2;
606        let first_half_ratio: f32 = self.snapshots[..mid]
607            .iter()
608            .map(|(_, m)| m.compression_ratio())
609            .sum::<f32>()
610            / mid as f32;
611        let second_half_ratio: f32 = self.snapshots[mid..]
612            .iter()
613            .map(|(_, m)| m.compression_ratio())
614            .sum::<f32>()
615            / (n - mid) as f32;
616        let compression_improving = second_half_ratio > first_half_ratio;
617
618        // Tier stability: check if tier1_blocks variance is low
619        let avg_tier1: f64 = self
620            .snapshots
621            .iter()
622            .map(|(_, m)| m.tier1_blocks as f64)
623            .sum::<f64>()
624            / n as f64;
625        let var_tier1: f64 = self
626            .snapshots
627            .iter()
628            .map(|(_, m)| {
629                let d = m.tier1_blocks as f64 - avg_tier1;
630                d * d
631            })
632            .sum::<f64>()
633            / n as f64;
634        let tier_distribution_stable = var_tier1.sqrt() < avg_tier1.max(1.0) * 0.3;
635
636        MetricsTrend {
637            eviction_rate,
638            compression_improving,
639            tier_distribution_stable,
640        }
641    }
642}
643
644// ---------------------------------------------------------------------------
645// Serialization helpers (no alloc formatting -- we avoid `format!` to stay
646// lightweight; instead we write digits manually).
647// ---------------------------------------------------------------------------
648
649/// Format a key-value line for the text report.
650fn format_line(key: &str, value: u64) -> String {
651    format!("{}: {}\n", key, value)
652}
653
654/// Push `key=value\n` for a u64 value.
655fn push_kv(buf: &mut Vec<u8>, key: &str, value: u64) {
656    buf.extend_from_slice(key.as_bytes());
657    buf.push(b'=');
658    push_u64(buf, value);
659    buf.push(b'\n');
660}
661
662/// Push `key=value\n` for an f32 value (6 decimal places).
663fn push_kv_f32(buf: &mut Vec<u8>, key: &str, value: f32) {
664    buf.extend_from_slice(key.as_bytes());
665    buf.push(b'=');
666    push_f32(buf, value);
667    buf.push(b'\n');
668}
669
670/// Push `key[index]=value\n`.
671fn push_kv_indexed(buf: &mut Vec<u8>, key: &str, index: usize, value: u64) {
672    buf.extend_from_slice(key.as_bytes());
673    buf.push(b'[');
674    push_u64(buf, index as u64);
675    buf.push(b']');
676    buf.push(b'=');
677    push_u64(buf, value);
678    buf.push(b'\n');
679}
680
681/// Write a `u64` as decimal ASCII digits.
682fn push_u64(buf: &mut Vec<u8>, mut v: u64) {
683    if v == 0 {
684        buf.push(b'0');
685        return;
686    }
687    let start = buf.len();
688    while v > 0 {
689        buf.push(b'0' + (v % 10) as u8);
690        v /= 10;
691    }
692    buf[start..].reverse();
693}
694
695/// Write an `f32` as decimal with 6 fractional digits.
696fn push_f32(buf: &mut Vec<u8>, v: f32) {
697    if v < 0.0 {
698        buf.push(b'-');
699        push_f32(buf, -v);
700        return;
701    }
702    let int_part = v as u64;
703    push_u64(buf, int_part);
704    buf.push(b'.');
705    let frac = ((v - int_part as f32) * 1_000_000.0).round() as u64;
706    // Pad to 6 digits.
707    let s = frac;
708    let digits = if s == 0 {
709        1
710    } else {
711        ((s as f64).log10().floor() as usize) + 1
712    };
713    for _ in 0..(6usize.saturating_sub(digits)) {
714        buf.push(b'0');
715    }
716    push_u64(buf, s);
717}
718
719// ---------------------------------------------------------------------------
720// Tests
721// ---------------------------------------------------------------------------
722
723#[cfg(test)]
724mod tests {
725    use super::*;
726    use crate::store::{BlockKey, Tier};
727
728    // -----------------------------------------------------------------------
729    // Helpers
730    // -----------------------------------------------------------------------
731
732    fn bk(id: u64) -> BlockKey {
733        BlockKey {
734            tensor_id: id as u128,
735            block_index: 0,
736        }
737    }
738
739    fn make_access(key: u64, score: f32, tier: Tier) -> WitnessEvent {
740        WitnessEvent::Access {
741            key: bk(key),
742            score,
743            tier,
744        }
745    }
746
747    fn make_tier_change(key: u64, from: Tier, to: Tier) -> WitnessEvent {
748        WitnessEvent::TierChange {
749            key: bk(key),
750            from_tier: from,
751            to_tier: to,
752            score: 100.0,
753            reason: TierChangeReason::ScoreUpgrade,
754        }
755    }
756
757    fn make_eviction(key: u64) -> WitnessEvent {
758        WitnessEvent::Eviction {
759            key: bk(key),
760            score: 0.5,
761            bytes_freed: 1024,
762        }
763    }
764
765    fn make_checksum_failure(key: u64) -> WitnessEvent {
766        WitnessEvent::ChecksumFailure {
767            key: bk(key),
768            expected: 0xDEAD,
769            actual: 0xBEEF,
770        }
771    }
772
773    // -----------------------------------------------------------------------
774    // WitnessLog: capacity enforcement (ring buffer)
775    // -----------------------------------------------------------------------
776
777    #[test]
778    fn test_capacity_enforcement() {
779        let mut log = WitnessLog::new(3);
780        log.record(1, make_access(1, 1.0, Tier::Tier1));
781        log.record(2, make_access(2, 2.0, Tier::Tier2));
782        log.record(3, make_access(3, 3.0, Tier::Tier3));
783        assert_eq!(log.len(), 3);
784
785        // Fourth record should evict the oldest (timestamp=1).
786        log.record(4, make_access(4, 4.0, Tier::Tier1));
787        assert_eq!(log.len(), 3);
788        assert_eq!(log.all()[0].timestamp, 2);
789        assert_eq!(log.all()[2].timestamp, 4);
790    }
791
792    #[test]
793    fn test_capacity_zero_treated_as_one() {
794        let mut log = WitnessLog::new(0);
795        log.record(1, make_access(1, 1.0, Tier::Tier1));
796        assert_eq!(log.len(), 1);
797        log.record(2, make_access(2, 2.0, Tier::Tier2));
798        assert_eq!(log.len(), 1);
799        assert_eq!(log.all()[0].timestamp, 2);
800    }
801
802    // -----------------------------------------------------------------------
803    // WitnessLog: recording and retrieval
804    // -----------------------------------------------------------------------
805
806    #[test]
807    fn test_record_and_retrieve_all() {
808        let mut log = WitnessLog::new(100);
809        log.record(10, make_access(1, 1.0, Tier::Tier1));
810        log.record(20, make_eviction(2));
811        log.record(30, make_tier_change(3, Tier::Tier3, Tier::Tier2));
812
813        let all = log.all();
814        assert_eq!(all.len(), 3);
815        assert_eq!(all[0].timestamp, 10);
816        assert_eq!(all[1].timestamp, 20);
817        assert_eq!(all[2].timestamp, 30);
818    }
819
820    #[test]
821    fn test_recent_returns_tail() {
822        let mut log = WitnessLog::new(100);
823        for i in 0..10 {
824            log.record(i, make_access(i, i as f32, Tier::Tier1));
825        }
826
827        let recent = log.recent(3);
828        assert_eq!(recent.len(), 3);
829        assert_eq!(recent[0].timestamp, 7);
830        assert_eq!(recent[1].timestamp, 8);
831        assert_eq!(recent[2].timestamp, 9);
832    }
833
834    #[test]
835    fn test_recent_more_than_available() {
836        let mut log = WitnessLog::new(100);
837        log.record(1, make_access(1, 1.0, Tier::Tier1));
838        let recent = log.recent(50);
839        assert_eq!(recent.len(), 1);
840    }
841
842    #[test]
843    fn test_clear() {
844        let mut log = WitnessLog::new(100);
845        log.record(1, make_access(1, 1.0, Tier::Tier1));
846        log.record(2, make_eviction(2));
847        assert_eq!(log.len(), 2);
848
849        log.clear();
850        assert_eq!(log.len(), 0);
851        assert!(log.is_empty());
852    }
853
854    // -----------------------------------------------------------------------
855    // WitnessLog: counting by event type
856    // -----------------------------------------------------------------------
857
858    #[test]
859    fn test_count_tier_changes() {
860        let mut log = WitnessLog::new(100);
861        log.record(1, make_tier_change(1, Tier::Tier3, Tier::Tier2));
862        log.record(2, make_access(2, 1.0, Tier::Tier1));
863        log.record(3, make_tier_change(3, Tier::Tier2, Tier::Tier1));
864        log.record(4, make_eviction(4));
865
866        assert_eq!(log.count_tier_changes(), 2);
867    }
868
869    #[test]
870    fn test_count_evictions() {
871        let mut log = WitnessLog::new(100);
872        log.record(1, make_eviction(1));
873        log.record(2, make_eviction(2));
874        log.record(3, make_access(3, 1.0, Tier::Tier1));
875        log.record(4, make_eviction(3));
876
877        assert_eq!(log.count_evictions(), 3);
878    }
879
880    #[test]
881    fn test_count_checksum_failures() {
882        let mut log = WitnessLog::new(100);
883        log.record(1, make_checksum_failure(1));
884        log.record(2, make_access(2, 1.0, Tier::Tier1));
885        log.record(3, make_checksum_failure(3));
886
887        assert_eq!(log.count_checksum_failures(), 2);
888    }
889
890    // -----------------------------------------------------------------------
891    // WitnessLog: tier flip rate
892    // -----------------------------------------------------------------------
893
894    #[test]
895    fn test_tier_flip_rate_basic() {
896        let mut log = WitnessLog::new(100);
897        // 4 tier changes in the window, 10 blocks.
898        for i in 0..4 {
899            log.record(100 + i, make_tier_change(i, Tier::Tier3, Tier::Tier2));
900        }
901        // Some non-tier-change events.
902        log.record(101, make_access(5, 1.0, Tier::Tier1));
903
904        let rate = log.tier_flip_rate(200, 10);
905        // 4 tier changes in window / 10 blocks = 0.4
906        assert!((rate - 0.4).abs() < 1e-6, "rate={rate}");
907    }
908
909    #[test]
910    fn test_tier_flip_rate_windowed() {
911        let mut log = WitnessLog::new(100);
912        // Old tier changes (outside window).
913        log.record(10, make_tier_change(1, Tier::Tier3, Tier::Tier2));
914        log.record(20, make_tier_change(2, Tier::Tier3, Tier::Tier1));
915        // Recent tier changes (inside window of 50 ticks from max=200).
916        log.record(160, make_tier_change(3, Tier::Tier2, Tier::Tier1));
917        log.record(200, make_tier_change(4, Tier::Tier1, Tier::Tier2));
918
919        let rate = log.tier_flip_rate(50, 5);
920        // Window: [200-50, 200] = [150, 200]. Records at 160 and 200 qualify.
921        // 2 flips / 5 blocks = 0.4
922        assert!((rate - 0.4).abs() < 1e-6, "rate={rate}");
923    }
924
925    #[test]
926    fn test_tier_flip_rate_zero_blocks() {
927        let mut log = WitnessLog::new(100);
928        log.record(1, make_tier_change(1, Tier::Tier3, Tier::Tier2));
929        assert_eq!(log.tier_flip_rate(100, 0), 0.0);
930    }
931
932    #[test]
933    fn test_tier_flip_rate_empty_log() {
934        let log = WitnessLog::new(100);
935        assert_eq!(log.tier_flip_rate(100, 10), 0.0);
936    }
937
938    // -----------------------------------------------------------------------
939    // StoreMetrics: compression ratio
940    // -----------------------------------------------------------------------
941
942    #[test]
943    fn test_compression_ratio_zero_bytes() {
944        let m = StoreMetrics::new();
945        assert_eq!(m.compression_ratio(), 0.0);
946    }
947
948    #[test]
949    fn test_compression_ratio_nonzero() {
950        let m = StoreMetrics {
951            tier1_bytes: 1000,
952            tier2_bytes: 500,
953            tier3_bytes: 200,
954            ..Default::default()
955        };
956        // raw_estimate = 1000*4.0 + 500*5.5 + 200*10.67 = 4000 + 2750 + 2134 = 8884
957        // stored = 1000 + 500 + 200 = 1700
958        // ratio = 8884 / 1700 ~= 5.226
959        let ratio = m.compression_ratio();
960        assert!(ratio > 5.0 && ratio < 5.5, "ratio={ratio}");
961    }
962
963    #[test]
964    fn test_total_stored_bytes() {
965        let m = StoreMetrics {
966            tier1_bytes: 100,
967            tier2_bytes: 200,
968            tier3_bytes: 300,
969            ..Default::default()
970        };
971        assert_eq!(m.total_stored_bytes(), 600);
972    }
973
974    // -----------------------------------------------------------------------
975    // StoreSnapshot: serialization
976    // -----------------------------------------------------------------------
977
978    #[test]
979    fn test_snapshot_to_bytes_contains_keys() {
980        let snap = StoreSnapshot {
981            timestamp: 42,
982            metrics: StoreMetrics {
983                total_blocks: 10,
984                tier0_blocks: 2,
985                tier1_blocks: 3,
986                tier2_blocks: 3,
987                tier3_blocks: 2,
988                tier1_bytes: 1000,
989                tier2_bytes: 500,
990                tier3_bytes: 200,
991                total_reads: 100,
992                total_writes: 50,
993                ..Default::default()
994            },
995            tier_distribution: [2, 3, 3, 2],
996            byte_distribution: [8000, 1000, 500, 200],
997        };
998
999        let bytes = snap.to_bytes();
1000        let text = core::str::from_utf8(&bytes).expect("valid utf-8");
1001
1002        assert!(text.contains("timestamp=42\n"), "missing timestamp");
1003        assert!(text.contains("total_blocks=10\n"), "missing total_blocks");
1004        assert!(text.contains("tier1_bytes=1000\n"), "missing tier1_bytes");
1005        assert!(text.contains("total_reads=100\n"), "missing total_reads");
1006        assert!(text.contains("total_writes=50\n"), "missing total_writes");
1007        assert!(text.contains("tier_dist[0]=2\n"), "missing tier_dist[0]");
1008        assert!(text.contains("tier_dist[3]=2\n"), "missing tier_dist[3]");
1009        assert!(text.contains("byte_dist[1]=1000\n"), "missing byte_dist[1]");
1010        assert!(
1011            text.contains("compression_ratio="),
1012            "missing compression_ratio"
1013        );
1014        assert!(
1015            text.contains("total_stored_bytes=1700\n"),
1016            "missing total_stored_bytes"
1017        );
1018    }
1019
1020    #[test]
1021    fn test_snapshot_empty_metrics() {
1022        let snap = StoreSnapshot {
1023            timestamp: 0,
1024            metrics: StoreMetrics::default(),
1025            tier_distribution: [0; 4],
1026            byte_distribution: [0; 4],
1027        };
1028
1029        let bytes = snap.to_bytes();
1030        let text = core::str::from_utf8(&bytes).expect("valid utf-8");
1031
1032        assert!(text.contains("timestamp=0\n"));
1033        assert!(text.contains("total_blocks=0\n"));
1034        assert!(text.contains("total_stored_bytes=0\n"));
1035    }
1036
1037    // -----------------------------------------------------------------------
1038    // Empty log edge cases
1039    // -----------------------------------------------------------------------
1040
1041    #[test]
1042    fn test_empty_log_len() {
1043        let log = WitnessLog::new(10);
1044        assert_eq!(log.len(), 0);
1045        assert!(log.is_empty());
1046    }
1047
1048    #[test]
1049    fn test_empty_log_recent() {
1050        let log = WitnessLog::new(10);
1051        assert!(log.recent(5).is_empty());
1052    }
1053
1054    #[test]
1055    fn test_empty_log_counts() {
1056        let log = WitnessLog::new(10);
1057        assert_eq!(log.count_tier_changes(), 0);
1058        assert_eq!(log.count_evictions(), 0);
1059        assert_eq!(log.count_checksum_failures(), 0);
1060    }
1061
1062    #[test]
1063    fn test_empty_log_clear_is_noop() {
1064        let mut log = WitnessLog::new(10);
1065        log.clear();
1066        assert!(log.is_empty());
1067    }
1068
1069    // -----------------------------------------------------------------------
1070    // Serialization helpers
1071    // -----------------------------------------------------------------------
1072
1073    #[test]
1074    fn test_push_u64_zero() {
1075        let mut buf = Vec::new();
1076        push_u64(&mut buf, 0);
1077        assert_eq!(&buf, b"0");
1078    }
1079
1080    #[test]
1081    fn test_push_u64_large() {
1082        let mut buf = Vec::new();
1083        push_u64(&mut buf, 123456789);
1084        assert_eq!(&buf, b"123456789");
1085    }
1086
1087    #[test]
1088    fn test_push_f32_positive() {
1089        let mut buf = Vec::new();
1090        push_f32(&mut buf, 3.14);
1091        let s = core::str::from_utf8(&buf).unwrap();
1092        // Should start with "3." and have fractional digits close to 140000.
1093        assert!(s.starts_with("3."), "got: {s}");
1094        let frac: u64 = s.split('.').nth(1).unwrap().parse().unwrap();
1095        // Allow rounding: 3.14 -> frac ~= 140000 (within 100 of 140000).
1096        assert!(
1097            (frac as i64 - 140000).unsigned_abs() < 200,
1098            "frac={frac}, expected ~140000"
1099        );
1100    }
1101
1102    #[test]
1103    fn test_push_f32_negative() {
1104        let mut buf = Vec::new();
1105        push_f32(&mut buf, -1.5);
1106        let s = core::str::from_utf8(&buf).unwrap();
1107        assert!(s.starts_with("-1."), "got: {s}");
1108    }
1109
1110    // -----------------------------------------------------------------------
1111    // StoreMetrics: format_report
1112    // -----------------------------------------------------------------------
1113
1114    #[test]
1115    fn test_format_report_contains_sections() {
1116        let m = StoreMetrics {
1117            total_blocks: 100,
1118            tier1_blocks: 50,
1119            tier2_blocks: 30,
1120            tier3_blocks: 20,
1121            tier1_bytes: 5000,
1122            tier2_bytes: 3000,
1123            tier3_bytes: 1000,
1124            total_reads: 1000,
1125            total_writes: 500,
1126            ..Default::default()
1127        };
1128        let report = m.format_report();
1129        assert!(report.contains("Temporal Tensor Store Report"));
1130        assert!(report.contains("Total blocks: 100"));
1131        assert!(report.contains("Reads: 1000"));
1132        assert!(report.contains("Compression ratio:"));
1133    }
1134
1135    #[test]
1136    fn test_format_json_valid_structure() {
1137        let m = StoreMetrics {
1138            total_blocks: 10,
1139            tier1_bytes: 100,
1140            ..Default::default()
1141        };
1142        let json = m.format_json();
1143        assert!(json.starts_with('{'));
1144        assert!(json.ends_with('}'));
1145        assert!(json.contains("\"total_blocks\":10"));
1146        assert!(json.contains("\"tier1_bytes\":100"));
1147    }
1148
1149    // -----------------------------------------------------------------------
1150    // StoreMetrics: health_check
1151    // -----------------------------------------------------------------------
1152
1153    #[test]
1154    fn test_health_check_healthy() {
1155        let m = StoreMetrics {
1156            total_blocks: 100,
1157            total_reads: 1000,
1158            total_writes: 500,
1159            ..Default::default()
1160        };
1161        assert_eq!(m.health_check(), StoreHealthStatus::Healthy);
1162    }
1163
1164    #[test]
1165    fn test_health_check_critical_checksum() {
1166        let m = StoreMetrics {
1167            total_checksum_failures: 5,
1168            ..Default::default()
1169        };
1170        match m.health_check() {
1171            StoreHealthStatus::Critical(msg) => assert!(msg.contains("checksum")),
1172            other => panic!("expected Critical, got {:?}", other),
1173        }
1174    }
1175
1176    #[test]
1177    fn test_health_check_warning_flip_rate() {
1178        let m = StoreMetrics {
1179            tier_flips_last_minute: 0.8,
1180            ..Default::default()
1181        };
1182        match m.health_check() {
1183            StoreHealthStatus::Warning(msg) => assert!(msg.contains("flip rate")),
1184            other => panic!("expected Warning, got {:?}", other),
1185        }
1186    }
1187
1188    // -----------------------------------------------------------------------
1189    // MetricsSeries
1190    // -----------------------------------------------------------------------
1191
1192    #[test]
1193    fn test_metrics_series_record_and_latest() {
1194        let mut series = MetricsSeries::new(10);
1195        assert!(series.is_empty());
1196        series.record(
1197            1,
1198            StoreMetrics {
1199                total_blocks: 10,
1200                ..Default::default()
1201            },
1202        );
1203        series.record(
1204            2,
1205            StoreMetrics {
1206                total_blocks: 20,
1207                ..Default::default()
1208            },
1209        );
1210        assert_eq!(series.len(), 2);
1211        assert_eq!(series.latest().unwrap().1.total_blocks, 20);
1212    }
1213
1214    #[test]
1215    fn test_metrics_series_capacity() {
1216        let mut series = MetricsSeries::new(3);
1217        for i in 0..5 {
1218            series.record(
1219                i as u64,
1220                StoreMetrics {
1221                    total_blocks: i,
1222                    ..Default::default()
1223                },
1224            );
1225        }
1226        assert_eq!(series.len(), 3);
1227        assert_eq!(series.latest().unwrap().1.total_blocks, 4);
1228    }
1229
1230    #[test]
1231    fn test_metrics_trend_empty() {
1232        let series = MetricsSeries::new(10);
1233        let trend = series.trend();
1234        assert_eq!(trend.eviction_rate, 0.0);
1235        assert!(trend.tier_distribution_stable);
1236    }
1237
1238    #[test]
1239    fn test_metrics_trend_with_data() {
1240        let mut series = MetricsSeries::new(10);
1241        for i in 0..6u64 {
1242            series.record(
1243                i,
1244                StoreMetrics {
1245                    total_blocks: 100,
1246                    tier1_blocks: 50,
1247                    total_evictions: i * 2,
1248                    tier1_bytes: 5000 + i * 100,
1249                    tier2_bytes: 3000,
1250                    tier3_bytes: 1000,
1251                    ..Default::default()
1252                },
1253            );
1254        }
1255        let trend = series.trend();
1256        assert!(trend.eviction_rate > 0.0);
1257    }
1258}