1use 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
21pub const DEFAULT_RING_CAPACITY: usize = 2048;
23pub const DEFAULT_SCOPE_CAP: usize = 512;
25
26pub trait TraceSink: Send + Sync + 'static {
29 fn record(&self, rec: TraceRecord);
30 fn flush(&self) {}
31 fn as_any(&self) -> &dyn std::any::Any;
34}
35
36#[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 pub candidate_expired: bool,
78}
79
80pub(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 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 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 pub fn lookup(&mut self, scope: u64, chain: &Chain, now_ms: u64, ttl_ms: u64) -> LcpResult {
176 let mut best: Option<(u64, MatchInfo)> = None; let mut candidate_expired = false;
178
179 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 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; 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 if now_ms.saturating_sub(rec.t_send_ms) > ttl_ms {
214 candidate_expired = true;
215 continue;
216 }
217 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 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 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#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize, TS)]
322#[ts(export)]
323pub struct SessionStepStat {
324 pub step: u32,
326 pub call_id: String,
328 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub hit_rate: Option<f64>,
332 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub verdict: Option<Verdict>,
335 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub error: Option<String>,
338}
339
340#[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
360pub 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>>, }
371
372use std::sync::Arc as Arc2;
374
375impl RingTraceStore {
376 #[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 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 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 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 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 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 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 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 fn append(&self, rec: TraceRecord) {
552 let mut inner = self.inner.lock().unwrap();
553 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 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 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
658fn 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
670fn 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 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 #[test]
790 fn lookup_does_not_stitch_blocks_across_records() {
791 let mut st = TraceStore::new(16, 512);
792 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 assert_eq!(r.matched.as_ref().unwrap().session.as_deref(), Some("s1"));
810 }
811
812 #[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 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 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 #[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 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 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 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}