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