Skip to main content

aimux_core/trace/
store.rs

1//! Trace storage (RFC-0015 §5.3/§6): `TraceSink` trait + bounded ring
2//! `RingTraceStore` with per-session index and query API.
3//!
4//! Zero implicit global state — everything is explicit injection. Memory is
5//! hard-bounded (FIFO ring + per-scope cap), TTL expiry is lazy, and
6//! (slot, gen) generational invalidation keeps eviction cheap. Plaintext
7//! bodies never enter the store — fingerprints only.
8
9use std::collections::{BTreeMap, HashMap, VecDeque};
10use std::io::Write;
11use std::sync::Mutex;
12
13use serde::{Deserialize, Serialize};
14use ts_rs::TS;
15
16use super::fingerprint::Chain;
17use super::hash;
18use super::record::TraceRecord;
19use super::verdict::{Verdict, VerdictKind};
20
21/// Default global ring capacity (≈6-7 MB with default 4 KiB blocks).
22pub const DEFAULT_RING_CAPACITY: usize = 2048;
23/// Default per-scope cap.
24pub const DEFAULT_SCOPE_CAP: usize = 512;
25
26/// Collection entry point (RFC-0015 §5.3). Thread-safe; the layer calls
27/// `record` once per call. Optional `flush` for persistent sinks.
28pub trait TraceSink: Send + Sync + 'static {
29    fn record(&self, rec: TraceRecord);
30    fn flush(&self) {}
31    /// Downcast support (the layer uses this to reach the LCP index of a
32    /// `RingTraceStore` sink).
33    fn as_any(&self) -> &dyn std::any::Any;
34}
35
36// ─────────────────────────────────────────────────────────────────────────────
37// Internal ring (prototype `TraceStore` port)
38// ─────────────────────────────────────────────────────────────────────────────
39
40#[derive(Debug, Clone)]
41pub(crate) struct StoredRecord {
42    scope: u64,
43    session: Option<String>,
44    len_bytes: u64,
45    t_send_ms: u64,
46    block_hashes: Vec<u128>,
47}
48
49#[derive(Debug, Clone, Default)]
50struct RingSlot {
51    generation: u32,
52    record: Option<StoredRecord>,
53}
54
55#[derive(Debug, Clone, Copy)]
56struct BlockRef {
57    slot: u32,
58    generation: u32,
59    block: u32,
60}
61
62#[derive(Debug, Clone, Default)]
63pub(crate) struct MatchInfo {
64    pub t_send_ms: u64,
65    pub session: Option<String>,
66    #[allow(dead_code)]
67    pub len_bytes: u64,
68}
69
70#[derive(Debug, Clone, Default)]
71pub(crate) struct LcpResult {
72    pub lcp_bytes: u64,
73    pub matched_blocks: u32,
74    pub matched: Option<MatchInfo>,
75    /// Block-0 had a candidate that failed the TTL check (timing violation)
76    /// — versus no candidate at all (granularity floor).
77    pub candidate_expired: bool,
78}
79
80/// Internal index: bounded ring + reverse (scope, block-hash-low64) index
81/// with (slot, gen) lazy invalidation.
82pub(crate) struct TraceStore {
83    cap: usize,
84    per_scope_cap: usize,
85    ring: Vec<RingSlot>,
86    next: usize,
87    index: HashMap<(u64, u64), Vec<BlockRef>>,
88    scope_count: HashMap<u64, u64>,
89    tombstone_hits: u64,
90}
91
92impl TraceStore {
93    pub fn new(cap: usize, per_scope_cap: usize) -> Self {
94        assert!(cap > 0);
95        TraceStore {
96            cap,
97            per_scope_cap,
98            ring: (0..cap).map(|_| RingSlot::default()).collect(),
99            next: 0,
100            index: HashMap::new(),
101            scope_count: HashMap::new(),
102            tombstone_hits: 0,
103        }
104    }
105
106    pub fn records_in_scope(&self, scope: u64) -> u64 {
107        self.scope_count.get(&scope).copied().unwrap_or(0)
108    }
109
110    /// Insert a record (maintaining the reverse index). Per-scope overflow
111    /// lazily evicts the oldest record of the same scope.
112    pub fn insert(&mut self, rec: StoredRecord) {
113        if self.per_scope_cap > 0 {
114            let n = self.scope_count.get(&rec.scope).copied().unwrap_or(0);
115            if n >= self.per_scope_cap as u64 {
116                self.evict_oldest_in_scope(rec.scope);
117            }
118        }
119        let slot = self.next % self.cap;
120        self.next += 1;
121        // The slot may hold an older record (global ring wrap): drop its
122        // scope count so `records_in_scope` stays an accurate live count.
123        if let Some(old) = &self.ring[slot].record
124            && let Some(n) = self.scope_count.get_mut(&old.scope)
125        {
126            *n = n.saturating_sub(1);
127        }
128        let generation = self.ring[slot].generation.wrapping_add(1);
129        self.ring[slot] = RingSlot {
130            generation,
131            record: Some(rec.clone()),
132        };
133        *self.scope_count.entry(rec.scope).or_insert(0) += 1;
134        for (block, h) in rec.block_hashes.iter().enumerate() {
135            self.index
136                .entry((rec.scope, hash::low64(*h)))
137                .or_default()
138                .push(BlockRef {
139                    slot: slot as u32,
140                    generation,
141                    block: block as u32,
142                });
143        }
144    }
145
146    fn evict_oldest_in_scope(&mut self, scope: u64) {
147        let mut oldest: Option<(usize, u64)> = None;
148        for (i, s) in self.ring.iter().enumerate() {
149            if let Some(r) = &s.record
150                && r.scope == scope
151                && oldest.map(|(_, t)| r.t_send_ms < t).unwrap_or(true)
152            {
153                oldest = Some((i, r.t_send_ms));
154            }
155        }
156        if let Some((slot, _)) = oldest {
157            self.ring[slot].generation = self.ring[slot].generation.wrapping_add(1);
158            self.ring[slot].record = None;
159            if let Some(n) = self.scope_count.get_mut(&scope) {
160                *n = n.saturating_sub(1);
161            }
162        }
163    }
164
165    /// Longest common prefix against same-scope history (block granularity).
166    ///
167    /// C4-1: the matched prefix must come from a SINGLE record. The previous
168    /// implementation picked the best candidate independently per block, which
169    /// could stitch blocks from different records into a "historical prefix"
170    /// that no single request ever sent. Now block-0 candidates are collected
171    /// and each candidate's full common prefix with the request chain is
172    /// computed; the longest live (TTL-valid) prefix wins. Any block failing
173    /// 128-bit verification, or an expired candidate (now − t_send > ttl_ms),
174    /// is excluded (monotonicity).
175    pub fn lookup(&mut self, scope: u64, chain: &Chain, now_ms: u64, ttl_ms: u64) -> LcpResult {
176        let mut best: Option<(u64, MatchInfo)> = None; // (shared_blocks, info)
177        let mut candidate_expired = false;
178
179        // The LCP is a prefix: only a record whose block 0 matches can
180        // contribute. An empty request chain matches nothing.
181        let Some(first) = chain.block_hashes.first() else {
182            self.maybe_rebuild_index();
183            return LcpResult {
184                lcp_bytes: 0,
185                matched_blocks: 0,
186                matched: None,
187                candidate_expired: false,
188            };
189        };
190        let key = (scope, hash::low64(*first));
191        if let Some(refs) = self.index.get(&key) {
192            for r in refs {
193                // Position must be block 0 (LCP is positional) + full 128-bit
194                // verification rejects 64-bit index collisions.
195                if r.block != 0 {
196                    continue;
197                }
198                let slot = &self.ring[r.slot as usize];
199                if slot.generation != r.generation {
200                    self.tombstone_hits += 1; // lazy-invalidation tombstone
201                    continue;
202                }
203                let Some(rec) = &slot.record else { continue };
204                if rec.scope != scope {
205                    continue;
206                }
207                if rec.block_hashes.first() != Some(first) {
208                    continue;
209                }
210                // TTL window (monotonicity): expired candidates do not extend
211                // the prefix. A block-0 candidate that is expired marks the
212                // timing-violation flag (versus no candidate at all).
213                if now_ms.saturating_sub(rec.t_send_ms) > ttl_ms {
214                    candidate_expired = true;
215                    continue;
216                }
217                // Whole-prefix match against THIS record only (C4-1): no
218                // stitching across records.
219                let shared = common_prefix_blocks(&rec.block_hashes, &chain.block_hashes) as u64;
220                let info = MatchInfo {
221                    t_send_ms: rec.t_send_ms,
222                    session: rec.session.clone(),
223                    len_bytes: rec.len_bytes,
224                };
225                // Longest real prefix wins; tie-break by latest t_send_ms
226                // (mirrors the prior per-block "best" preference).
227                let better = best.as_ref().is_none_or(|(b, m)| {
228                    shared > *b || (shared == *b && rec.t_send_ms > m.t_send_ms)
229                });
230                if better {
231                    best = Some((shared, info));
232                }
233            }
234        }
235        self.maybe_rebuild_index();
236        match best {
237            Some((blocks, matched)) => LcpResult {
238                lcp_bytes: blocks * chain.block_size as u64,
239                matched_blocks: blocks as u32,
240                matched: Some(matched),
241                candidate_expired: false,
242            },
243            None => LcpResult {
244                lcp_bytes: 0,
245                matched_blocks: 0,
246                matched: None,
247                candidate_expired,
248            },
249        }
250    }
251
252    fn maybe_rebuild_index(&mut self) {
253        // Tombstone ratio > 25% of total refs → full index rebuild.
254        let live: usize = self.index.values().map(Vec::len).sum();
255        if live > 0 && self.tombstone_hits * 4 > live as u64 {
256            let mut new_index: HashMap<(u64, u64), Vec<BlockRef>> = HashMap::new();
257            for (slot, s) in self.ring.iter().enumerate() {
258                if let Some(rec) = &s.record {
259                    for (block, h) in rec.block_hashes.iter().enumerate() {
260                        new_index
261                            .entry((rec.scope, hash::low64(*h)))
262                            .or_default()
263                            .push(BlockRef {
264                                slot: slot as u32,
265                                generation: s.generation,
266                                block: block as u32,
267                            });
268                    }
269                }
270            }
271            self.index = new_index;
272            self.tombstone_hits = 0;
273        }
274    }
275}
276
277// ─────────────────────────────────────────────────────────────────────────────
278// RingTraceStore — public bounded sink with query API
279// ─────────────────────────────────────────────────────────────────────────────
280
281/// Query filter (RFC-0015 §5.3).
282#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
283#[ts(export)]
284pub struct TraceFilter {
285    pub provider: Option<String>,
286    pub model: Option<String>,
287    pub session_id: Option<String>,
288    pub since_unix_ms: Option<i64>,
289}
290
291/// Aggregated statistics (RFC-0015 §5.2 — the two hit rates are reported
292/// side by side, never merged into a single number).
293#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
294#[ts(export)]
295pub struct TraceStats {
296    pub provider: String,
297    pub model: String,
298    pub requests: u64,
299    pub input_tokens_total: u64,
300    pub claimed_cache_read_total: u64,
301    pub claimed_cache_write_total: u64,
302    pub reported_hit_rate: Option<f64>,
303    pub client_upper_bound_hit_rate: Option<f64>,
304    pub verdict_counts: BTreeMap<String, u64>,
305    pub ttft_p50_ms: Option<u64>,
306    pub ttft_p95_ms: Option<u64>,
307    pub errors: u64,
308}
309
310/// Session chain view (append-only order + prefix stability, RFC-0015 §5.3).
311#[derive(Debug, Clone, Serialize, Deserialize, TS)]
312#[ts(export)]
313pub struct SessionChainView {
314    pub session_id: String,
315    pub record_ids: Vec<String>,
316    pub prefix_stability: f64,
317    pub breaks: Vec<PrefixBreak>,
318}
319
320/// One step of a session's cache-hit trajectory (RFC-0024 §4.3).
321#[derive(Debug, Clone, Serialize, Deserialize, TS)]
322#[ts(export)]
323pub struct SessionStepStat {
324    /// Step within the session (0-based, session order).
325    pub step: u32,
326    /// The call's id (association key into Recording / replay, RFC-0023).
327    pub call_id: String,
328    /// Reported hit rate for this step (`cache_read / input_total`); `None`
329    /// when the call carried no input/cache-read usage.
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub hit_rate: Option<f64>,
332    /// Audit verdict (present only when an auditor is attached).
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    pub verdict: Option<Verdict>,
335    /// Error string for failed calls (failures are part of the session).
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub error: Option<String>,
338}
339
340/// A prefix break between two consecutive records of a session.
341#[derive(Debug, Clone, Serialize, Deserialize, TS)]
342#[ts(export)]
343pub struct PrefixBreak {
344    pub at_record_id: String,
345    pub prev_record_id: String,
346    pub lcp_bytes: u64,
347    pub expected_break: bool,
348    pub kind: BreakKind,
349}
350
351#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
352#[ts(export)]
353pub enum BreakKind {
354    SystemChanged,
355    ToolsChanged,
356    ConversationReset,
357    Unknown,
358}
359
360/// The built-in bounded sink: FIFO ring (global) + per-scope cap + per-session
361/// index. Thread-safe; queries are consistent snapshots.
362pub struct RingTraceStore {
363    inner: Mutex<Inner>,
364}
365
366struct Inner {
367    store: TraceStore,
368    records: VecDeque<Arc2<TraceRecord>>,
369    by_session: HashMap<String, Vec<String>>, // session_id → call_ids (ordered)
370}
371
372// Arc2: std::sync::Arc (name avoids confusion with the variant).
373use std::sync::Arc as Arc2;
374
375impl RingTraceStore {
376    /// A store with default bounds (2048 records, 512 per scope).
377    #[must_use]
378    pub fn new() -> Self {
379        Self::with_capacity(DEFAULT_RING_CAPACITY, DEFAULT_SCOPE_CAP)
380    }
381
382    #[must_use]
383    pub fn with_capacity(cap: usize, per_scope_cap: usize) -> Self {
384        assert!(cap > 0 && per_scope_cap > 0);
385        Self {
386            inner: Mutex::new(Inner {
387                store: TraceStore::new(cap, per_scope_cap),
388                records: VecDeque::with_capacity(cap),
389                by_session: HashMap::new(),
390            }),
391        }
392    }
393
394    pub fn clear(&self) {
395        let mut inner = self.inner.lock().unwrap();
396        inner.store = TraceStore::new(inner.store.cap, inner.store.per_scope_cap);
397        inner.records.clear();
398        inner.by_session.clear();
399    }
400
401    pub fn len(&self) -> usize {
402        self.inner.lock().unwrap().records.len()
403    }
404
405    pub fn is_empty(&self) -> bool {
406        self.len() == 0
407    }
408
409    /// Aggregate statistics, grouped by (provider, model), filtered.
410    pub fn aggregate(&self, f: &TraceFilter) -> Vec<TraceStats> {
411        let inner = self.inner.lock().unwrap();
412        let mut groups: BTreeMap<(String, String), TraceStats> = BTreeMap::new();
413        let mut ttfts: BTreeMap<(String, String), Vec<u64>> = BTreeMap::new();
414        for rec in &inner.records {
415            if !matches_filter(rec, f) {
416                continue;
417            }
418            let key = (rec.provider.clone(), rec.model.clone());
419            let g = groups.entry(key.clone()).or_insert_with(|| TraceStats {
420                provider: rec.provider.clone(),
421                model: rec.model.clone(),
422                ..Default::default()
423            });
424            g.requests += 1;
425            g.input_tokens_total += rec.usage.input_total.unwrap_or(0);
426            g.claimed_cache_read_total += rec.usage.cache_read.unwrap_or(0);
427            g.claimed_cache_write_total += rec.usage.cache_write.unwrap_or(0);
428            if let Some(kind) = rec.verdict.as_ref().map(|v| kind_name(v.kind)) {
429                *g.verdict_counts.entry(kind.to_string()).or_insert(0) += 1;
430            }
431            if rec.error.is_some() {
432                g.errors += 1;
433            }
434            if let Some(t) = rec.ttft_ms {
435                ttfts.entry(key).or_default().push(t);
436            }
437        }
438        // Hit rates + TTFT percentiles per group.
439        for (key, g) in groups.iter_mut() {
440            let total = g.input_tokens_total;
441            if total > 0 {
442                g.reported_hit_rate = Some(g.claimed_cache_read_total as f64 / total as f64);
443                // Client upper bound: per-record LCP token upper bound only —
444                // records without LCP evidence (first request / no match)
445                // contribute 0 (RFC-0015 §5.2; never the full request length).
446                let est: u64 = inner
447                    .records
448                    .iter()
449                    .filter(|r| r.provider == key.0 && r.model == key.1 && matches_filter(r, f))
450                    .map(|r| r.lcp_token_upper.unwrap_or(0))
451                    .sum();
452                if est > 0 {
453                    g.client_upper_bound_hit_rate = Some(est as f64 / total as f64);
454                }
455            }
456            if let Some(mut ts) = ttfts.remove(key) {
457                ts.sort_unstable();
458                g.ttft_p50_ms = percentile(&ts, 0.50);
459                g.ttft_p95_ms = percentile(&ts, 0.95);
460            }
461        }
462        groups.into_values().collect()
463    }
464
465    /// Session chain view: ordered record ids + prefix stability + breaks.
466    pub fn session_chain(&self, session_id: &str) -> Option<SessionChainView> {
467        let inner = self.inner.lock().unwrap();
468        let ids = inner.by_session.get(session_id)?;
469        if ids.is_empty() {
470            return None;
471        }
472        let records: Vec<&TraceRecord> = ids
473            .iter()
474            .filter_map(|id| inner.records.iter().find(|r| &r.call_id == id))
475            .map(|r| &**r)
476            .collect();
477        let mut lcp_sum = 0u64;
478        let mut prev_len_sum = 0u64;
479        let mut breaks = Vec::new();
480        for w in records.windows(2) {
481            let (a, b) = (w[0], w[1]);
482            // Byte LCP between consecutive bodies — from stored fingerprints
483            // (hex chains) via prefix-block comparison.
484            let (lcp, shared) = chain_lcp(a, b);
485            prev_len_sum += a.fingerprint.len_bytes;
486            lcp_sum += lcp;
487            if shared < a.fingerprint.block_hashes.len() as u64 {
488                breaks.push(PrefixBreak {
489                    at_record_id: b.call_id.clone(),
490                    prev_record_id: a.call_id.clone(),
491                    lcp_bytes: lcp,
492                    expected_break: false,
493                    kind: BreakKind::Unknown,
494                });
495            }
496        }
497        let prefix_stability = if prev_len_sum > 0 {
498            lcp_sum as f64 / prev_len_sum as f64
499        } else {
500            1.0
501        };
502        Some(SessionChainView {
503            session_id: session_id.to_string(),
504            record_ids: ids.clone(),
505            prefix_stability: prefix_stability.min(1.0),
506            breaks,
507        })
508    }
509
510    /// Per-step cache-hit trajectory of a session (RFC-0024 §4.3
511    /// `session_cache_trajectory`): one [`SessionStepStat`] per call, in
512    /// session order, with the reported hit rate and audit verdict (when an
513    /// auditor is attached). Empty for unknown sessions.
514    pub fn session_cache_trajectory(&self, session_id: &str) -> Vec<SessionStepStat> {
515        let inner = self.inner.lock().unwrap();
516        let ids = match inner.by_session.get(session_id) {
517            Some(ids) => ids,
518            None => return Vec::new(),
519        };
520        ids.iter()
521            .enumerate()
522            .filter_map(|(step, id)| {
523                let rec = inner.records.iter().find(|r| &r.call_id == id)?;
524                Some(SessionStepStat {
525                    step: step as u32,
526                    call_id: rec.call_id.clone(),
527                    hit_rate: rec.reported_hit_rate(),
528                    verdict: rec.verdict.clone(),
529                    error: rec.error.clone(),
530                })
531            })
532            .collect()
533    }
534
535    /// Export all records as JSONL (one `TraceRecord` per line).
536    ///
537    /// # Errors
538    ///
539    /// Returns an I/O error (JSON serialization failures included) when
540    /// serializing a record or writing a line fails.
541    pub fn export_jsonl(&self, w: &mut impl Write) -> std::io::Result<()> {
542        let inner = self.inner.lock().unwrap();
543        for rec in &inner.records {
544            let line = serde_json::to_string(&**rec).map_err(std::io::Error::other)?;
545            writeln!(w, "{line}")?;
546        }
547        Ok(())
548    }
549
550    /// Insert a record: ring append + reverse index + per-session index.
551    fn append(&self, rec: TraceRecord) {
552        let mut inner = self.inner.lock().unwrap();
553        // Feed the internal LCP index.
554        inner.store.insert(StoredRecord {
555            scope: rec.scope_key,
556            session: rec.session_id.clone(),
557            len_bytes: rec.fingerprint.len_bytes,
558            t_send_ms: rec.monotonic_sent_ms,
559            block_hashes: rec
560                .fingerprint
561                .block_hashes
562                .iter()
563                .map(|h| u128::from_str_radix(h, 16).unwrap_or(0))
564                .collect(),
565        });
566        // Bounded FIFO ring.
567        if inner.records.len() >= inner.store.cap
568            && let Some(evicted) = inner.records.pop_front()
569            && let Some(sid) = &evicted.session_id
570            && let Some(ids) = inner.by_session.get_mut(sid)
571        {
572            ids.retain(|id| id != &evicted.call_id);
573            if ids.is_empty() {
574                inner.by_session.remove(sid);
575            }
576        }
577        if let Some(sid) = &rec.session_id {
578            inner
579                .by_session
580                .entry(sid.clone())
581                .or_default()
582                .push(rec.call_id.clone());
583        }
584        inner.records.push_back(Arc2::new(rec));
585    }
586
587    /// Internal LCP lookup (used by `TraceLayer`).
588    pub(crate) fn lookup(&self, scope: u64, chain: &Chain, now_ms: u64, ttl_ms: u64) -> LcpResult {
589        self.inner
590            .lock()
591            .unwrap()
592            .store
593            .lookup(scope, chain, now_ms, ttl_ms)
594    }
595
596    pub(crate) fn records_in_scope(&self, scope: u64) -> u64 {
597        self.inner.lock().unwrap().store.records_in_scope(scope)
598    }
599}
600
601impl Default for RingTraceStore {
602    fn default() -> Self {
603        Self::new()
604    }
605}
606
607impl TraceSink for RingTraceStore {
608    fn record(&self, rec: TraceRecord) {
609        self.append(rec);
610    }
611
612    fn as_any(&self) -> &dyn std::any::Any {
613        self
614    }
615}
616
617fn matches_filter(rec: &TraceRecord, f: &TraceFilter) -> bool {
618    if let Some(p) = &f.provider
619        && &rec.provider != p
620    {
621        return false;
622    }
623    if let Some(m) = &f.model
624        && &rec.model != m
625    {
626        return false;
627    }
628    if let Some(s) = &f.session_id
629        && rec.session_id.as_deref() != Some(s.as_str())
630    {
631        return false;
632    }
633    if let Some(since) = f.since_unix_ms
634        && rec.sent_at_unix_ms < since
635    {
636        return false;
637    }
638    true
639}
640
641fn kind_name(kind: VerdictKind) -> &'static str {
642    match kind {
643        VerdictKind::Trusted => "Trusted",
644        VerdictKind::SuspectOverclaim => "SuspectOverclaim",
645        VerdictKind::SuspectUnderclaim => "SuspectUnderclaim",
646        VerdictKind::Unknown => "Unknown",
647    }
648}
649
650fn percentile(sorted: &[u64], p: f64) -> Option<u64> {
651    if sorted.is_empty() {
652        return None;
653    }
654    let idx = ((sorted.len() as f64 - 1.0) * p).round() as usize;
655    Some(sorted[idx.min(sorted.len() - 1)])
656}
657
658/// Number of leading blocks two chains share (positional, 128-bit exact).
659/// Used by the LCP lookup to confine a prefix match to a single record (C4-1).
660fn common_prefix_blocks(a: &[u128], b: &[u128]) -> usize {
661    let n = a.len().min(b.len());
662    for i in 0..n {
663        if a[i] != b[i] {
664            return i;
665        }
666    }
667    n
668}
669
670/// Block-level LCP between two records' stored fingerprint chains.
671fn chain_lcp(a: &TraceRecord, b: &TraceRecord) -> (u64, u64) {
672    let bs = a.fingerprint.block_size.max(1);
673    let n = a
674        .fingerprint
675        .block_hashes
676        .len()
677        .min(b.fingerprint.block_hashes.len());
678    let mut shared = 0u64;
679    for i in 0..n {
680        if a.fingerprint.block_hashes[i] == b.fingerprint.block_hashes[i] {
681            shared += 1;
682        } else {
683            break;
684        }
685    }
686    (shared * bs, shared)
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692    use crate::trace::fingerprint::{BlockChainFingerprint, Fingerprint};
693    use crate::trace::record::{TraceRecord, UsageSnapshot};
694
695    fn chain_for(data: &[u8], salt: u64) -> Chain {
696        BlockChainFingerprint::new(512, salt).compute(data)
697    }
698
699    fn insert(st: &mut TraceStore, scope: u64, t: u64, chain: &Chain) {
700        st.insert(StoredRecord {
701            scope,
702            session: Some("s1".into()),
703            len_bytes: chain.len_bytes,
704            t_send_ms: t,
705            block_hashes: chain.block_hashes.clone(),
706        });
707    }
708
709    #[test]
710    fn lookup_matches_live_records_within_ttl() {
711        let mut st = TraceStore::new(16, 512);
712        let body = vec![b'a'; 1500];
713        let chain = chain_for(&body, 1);
714        insert(&mut st, 1, 0, &chain);
715        let r = st.lookup(1, &chain, 100, u64::MAX);
716        assert!(r.matched_blocks > 0);
717        assert!(r.matched.is_some());
718    }
719
720    #[test]
721    fn lookup_expires_by_ttl_idle() {
722        let mut st = TraceStore::new(16, 512);
723        let body = vec![b'b'; 1500];
724        let chain = chain_for(&body, 2);
725        insert(&mut st, 1, 0, &chain);
726        let ttl = 3_600_000u64;
727        let ok = st.lookup(1, &chain, ttl - 1, ttl);
728        assert!(ok.matched.is_some(), "within TTL must match");
729        let expired = st.lookup(1, &chain, ttl + 1, ttl);
730        assert_eq!(expired.matched_blocks, 0, "over TTL must abstain");
731        assert!(expired.candidate_expired, "expired candidate flagged");
732    }
733
734    #[test]
735    fn lookup_granularity_miss_is_not_timing_violation() {
736        let mut st = TraceStore::new(16, 512);
737        let short = vec![b'c'; 100];
738        let long = vec![b'c'; 200];
739        let short_chain = chain_for(&short, 3);
740        let long_chain = chain_for(&long, 3);
741        insert(&mut st, 1, 0, &short_chain);
742        let r = st.lookup(1, &long_chain, 1, u64::MAX);
743        assert_eq!(r.matched_blocks, 0);
744        assert!(!r.candidate_expired, "no candidate → granularity floor");
745    }
746
747    #[test]
748    fn lru_ring_evicts_oldest_globally() {
749        let mut st = TraceStore::new(2, 512);
750        let a = chain_for(b"aaaa", 4);
751        let b = chain_for(b"bbbb", 4);
752        let c = chain_for(b"cccc", 4);
753        insert(&mut st, 1, 0, &a);
754        insert(&mut st, 1, 1, &b);
755        insert(&mut st, 1, 2, &c);
756        let ra = st.lookup(1, &a, 3, u64::MAX);
757        assert_eq!(ra.matched_blocks, 0, "a evicted");
758        let rc = st.lookup(1, &c, 3, u64::MAX);
759        assert!(rc.matched.is_some(), "c live");
760    }
761
762    // ── C4-1: same-record prefix constraint ────────────────────────────────
763
764    /// Helper that inserts a record with crafted block hashes (bypasses the
765    /// chained `compute` so a cross-record stitch can be constructed).
766    fn insert_hashes(st: &mut TraceStore, scope: u64, t: u64, session: &str, hashes: &[u128]) {
767        st.insert(StoredRecord {
768            scope,
769            session: Some(session.into()),
770            len_bytes: (hashes.len() * 512) as u64,
771            t_send_ms: t,
772            block_hashes: hashes.to_vec(),
773        });
774    }
775
776    fn chain_with(hashes: &[u128]) -> Chain {
777        Chain {
778            body_hash: 0,
779            len_bytes: (hashes.len() * 512) as u64,
780            block_size: 512,
781            block_hashes: hashes.to_vec(),
782        }
783    }
784
785    /// Records `[A,X]` and `[Y,B]` exist; a request `[A,B]` must NOT report
786    /// LCP=2 (block 0 from record 1, block 1 from record 2 — a fabricated
787    /// prefix no single request ever sent). The match must stay within one
788    /// record, so LCP=1 (record 1's real prefix).
789    #[test]
790    fn lookup_does_not_stitch_blocks_across_records() {
791        let mut st = TraceStore::new(16, 512);
792        // Distinct low-64 bits so no reverse-index bucket collides.
793        let a: u128 = 0x0000_0000_0000_0000_0000_0000_0000_0001;
794        let b: u128 = 0x0000_0000_0000_0000_0000_0000_0000_0002;
795        let x: u128 = 0x0000_0000_0000_0000_0000_0000_0000_0003;
796        let y: u128 = 0x0000_0000_0000_0000_0000_0000_0000_0004;
797        insert_hashes(&mut st, 1, 0, "s1", &[a, x]);
798        insert_hashes(&mut st, 1, 1, "s2", &[y, b]);
799
800        let req = chain_with(&[a, b]);
801        let r = st.lookup(1, &req, 100, u64::MAX);
802        assert_eq!(
803            r.matched_blocks, 1,
804            "C4-1: prefix must come from one record (expected 1, got {})",
805            r.matched_blocks
806        );
807        assert!(r.matched.is_some(), "block-0 match still reported");
808        // The winning record is record 1 ([A,X]) — its session.
809        assert_eq!(r.matched.as_ref().unwrap().session.as_deref(), Some("s1"));
810    }
811
812    /// When two records share the real prefix [A,B] but diverge later, the
813    /// longest real prefix wins (not the latest block-0 candidate's tail).
814    #[test]
815    fn lookup_picks_longest_single_record_prefix() {
816        let mut st = TraceStore::new(16, 512);
817        let a: u128 = 1;
818        let b: u128 = 2;
819        let c: u128 = 3;
820        let x: u128 = 4;
821        // R1 = [A,B,C] (t=0, older, longer real prefix), R2 = [A,X] (t=9, newer).
822        insert_hashes(&mut st, 1, 0, "long", &[a, b, c]);
823        insert_hashes(&mut st, 1, 9, "short", &[a, x]);
824
825        let req = chain_with(&[a, b, c]);
826        let r = st.lookup(1, &req, 100, u64::MAX);
827        // R1 shares all 3 blocks; R2 shares only 1. Longest real prefix = 3.
828        assert_eq!(r.matched_blocks, 3, "longest single-record prefix wins");
829        assert_eq!(
830            r.matched.as_ref().unwrap().session.as_deref(),
831            Some("long"),
832            "must pick the record that owns the full prefix"
833        );
834    }
835
836    /// A block-0 candidate that is expired sets the timing-violation flag even
837    /// when no live candidate extends the prefix.
838    #[test]
839    fn lookup_expired_block0_candidate_flags_timing() {
840        let mut st = TraceStore::new(16, 512);
841        let a: u128 = 1;
842        let x: u128 = 2;
843        insert_hashes(&mut st, 1, 0, "s1", &[a, x]);
844
845        let req = chain_with(&[a, x]);
846        let ttl = 1_000u64;
847        let live = st.lookup(1, &req, ttl - 1, ttl);
848        assert!(live.matched.is_some(), "within TTL matches");
849        assert!(!live.candidate_expired);
850
851        let expired = st.lookup(1, &req, ttl + 1, ttl);
852        assert_eq!(expired.matched_blocks, 0, "over TTL abstains");
853        assert!(
854            expired.candidate_expired,
855            "expired block-0 candidate must flag timing violation"
856        );
857    }
858
859    // ── RFC-0024 P4: session_cache_trajectory ──────────────────────────────
860
861    fn trajectory_record(
862        call_id: &str,
863        session_id: &str,
864        input_total: u64,
865        cache_read: u64,
866        error: Option<String>,
867    ) -> TraceRecord {
868        TraceRecord {
869            provider: "openai".into(),
870            model: "gpt-4o".into(),
871            request_id: None,
872            session_id: Some(session_id.into()),
873            call_id: call_id.into(),
874            sent_at_unix_ms: 1,
875            monotonic_sent_ms: 0,
876            lcp_token_upper: None,
877            ttft_ms: None,
878            fingerprint: Fingerprint {
879                body_hash: "0".repeat(32),
880                len_bytes: 0,
881                block_size: 0,
882                block_hashes: vec![],
883                token_estimate: 0,
884            },
885            usage: UsageSnapshot {
886                input_total: Some(input_total),
887                cache_read: Some(cache_read),
888                ..Default::default()
889            },
890            response_cache_headers: None,
891            request_cache_hints: None,
892            verdict: None,
893            error,
894            scope_key: 0,
895        }
896    }
897
898    #[test]
899    fn session_trajectory_returns_per_step_hit_rates_in_order() {
900        let store = RingTraceStore::with_capacity(16, 256);
901        // 命中率:0.8 → 0 → 0.5(同一 session);另一 session 不受影响。
902        store.record(trajectory_record("c1", "s1", 10, 8, None));
903        store.record(trajectory_record("c2", "s1", 5, 0, None));
904        store.record(trajectory_record("c3", "s1", 4, 2, Some("boom".into())));
905        store.record(trajectory_record("c-x", "s2", 9, 9, None));
906
907        let traj = store.session_cache_trajectory("s1");
908        assert_eq!(traj.len(), 3);
909        assert_eq!(traj[0].step, 0);
910        assert_eq!(traj[0].call_id, "c1");
911        assert_eq!(traj[0].hit_rate, Some(0.8));
912        assert_eq!(traj[0].error, None);
913        assert_eq!(traj[1].step, 1);
914        assert_eq!(traj[1].hit_rate, Some(0.0));
915        assert_eq!(traj[2].step, 2);
916        assert_eq!(traj[2].hit_rate, Some(0.5));
917        assert_eq!(traj[2].error.as_deref(), Some("boom"));
918
919        // 未知 session 与其它 session 互不干扰。
920        assert!(store.session_cache_trajectory("nope").is_empty());
921        assert_eq!(store.session_cache_trajectory("s2").len(), 1);
922        assert_eq!(store.session_cache_trajectory("s2")[0].call_id, "c-x");
923    }
924}