Skip to main content

lean_ctx/core/
response_optimizer.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Response Optimizer (P9 / DIM 2 — Output-Optimierung).
3//!
4//! Reduces output tokens without semantic loss through two mechanisms:
5//!
6//! 1. **Response Cache** — identical user queries within a session get the
7//!    cached response instead of a full LLM round-trip. Saves 100% of output
8//!    tokens on cache hits.
9//!
10//! 2. **Response Dedup** — detects when the model repeats substantially
11//!    similar answers within a conversation and signals this to the client
12//!    (future: truncate/summarize repeated content).
13//!
14//! These complement the existing mechanisms:
15//! - `verbosity.rs` — wire-level "be concise" steer (reduces verbosity ~33%)
16//! - `output_savings.rs` — A/B measurement of output reduction
17//! - `effort.rs` — the operator's constant reasoning-effort level
18//!
19//! **Opt-in only** (`proxy.response_cache = true`). Off by default.
20//!
21//! ## Cache design
22//!
23//! - Key: BLAKE3 hash of (model + last N user messages + system prompt)
24//! - Value: the complete streamed response (reassembled)
25//! - TTL: configurable, default 5 minutes (short — LLM answers can evolve)
26//! - Capacity: bounded LRU, default 64 entries per session
27//! - Scope: per-session (not cross-session — avoids stale context leaks)
28//!
29//! ## Dedup design
30//!
31//! - Tracks BLAKE3 fingerprints of recent responses (last 16)
32//! - A response whose first 200 chars match a recent fingerprint is flagged
33//! - Flagging is observability-only in v1 (no truncation)
34//!
35//! ## Determinism
36//!
37//! Cache hits are deterministic: same key always returns the same value.
38//! Cache *misses* are non-deterministic (LLM output varies), but the decision
39//! to serve from cache vs. forward is deterministic given the cache state.
40
41use std::collections::VecDeque;
42use std::sync::{Arc, Mutex};
43use std::time::{Duration, Instant};
44
45use serde::{Deserialize, Serialize};
46
47use crate::core::ocla::types::ResponseOptimizationRequest;
48use crate::core::savings_ledger::{self, SavingsEvent};
49
50/// Configuration for the response optimizer.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52#[serde(default)]
53pub struct ResponseOptimizerConfig {
54    /// Master switch. Default: false (opt-in).
55    pub enabled: bool,
56    /// Enable the response cache. Default: true (when optimizer is enabled).
57    pub cache_enabled: bool,
58    /// Enable dedup detection. Default: true.
59    pub dedup_enabled: bool,
60    /// Cache TTL in seconds. Default: 300 (5 minutes).
61    pub cache_ttl_secs: u64,
62    /// Max cached responses per session. Default: 64.
63    pub cache_capacity: usize,
64    /// Number of recent response fingerprints to track for dedup. Default: 16.
65    pub dedup_window: usize,
66}
67
68impl Default for ResponseOptimizerConfig {
69    fn default() -> Self {
70        Self {
71            enabled: false,
72            cache_enabled: true,
73            dedup_enabled: true,
74            cache_ttl_secs: 300,
75            cache_capacity: 64,
76            dedup_window: 16,
77        }
78    }
79}
80
81/// A cached response entry.
82#[derive(Debug, Clone)]
83struct CacheEntry {
84    response_body: String,
85    created_at: Instant,
86}
87
88/// The response cache — bounded LRU with TTL eviction.
89#[derive(Debug)]
90pub struct ResponseCache {
91    entries: VecDeque<(u64, CacheEntry)>,
92    capacity: usize,
93    ttl: Duration,
94}
95
96impl ResponseCache {
97    pub fn new(capacity: usize, ttl: Duration) -> Self {
98        Self {
99            entries: VecDeque::with_capacity(capacity),
100            capacity,
101            ttl,
102        }
103    }
104
105    /// Look up a cache key. Returns the cached response if found and not expired.
106    pub fn get(&mut self, key: u64) -> Option<&str> {
107        self.evict_expired();
108        let pos = self.entries.iter().position(|(k, _)| *k == key)?;
109        // Move to back (LRU touch).
110        let entry = self.entries.remove(pos)?;
111        self.entries.push_back(entry);
112        // Safety: we just pushed it back, reference is valid for the borrow.
113        self.entries.back().map(|(_, e)| e.response_body.as_str())
114    }
115
116    /// Insert a response into the cache.
117    pub fn put(&mut self, key: u64, response: String, _output_tokens: u64) {
118        self.evict_expired();
119        // Remove existing entry with same key (update).
120        self.entries.retain(|(k, _)| *k != key);
121        // Evict LRU if at capacity.
122        while self.entries.len() >= self.capacity {
123            self.entries.pop_front();
124        }
125        self.entries.push_back((
126            key,
127            CacheEntry {
128                response_body: response,
129                created_at: Instant::now(),
130            },
131        ));
132    }
133
134    /// Remove expired entries.
135    fn evict_expired(&mut self) {
136        let now = Instant::now();
137        self.entries
138            .retain(|(_, e)| now.duration_since(e.created_at) < self.ttl);
139    }
140
141    pub fn len(&self) -> usize {
142        self.entries.len()
143    }
144
145    pub fn is_empty(&self) -> bool {
146        self.entries.is_empty()
147    }
148}
149
150/// Response deduplication tracker.
151#[derive(Debug)]
152pub struct DedupTracker {
153    fingerprints: VecDeque<u64>,
154    window: usize,
155}
156
157impl DedupTracker {
158    pub fn new(window: usize) -> Self {
159        Self {
160            fingerprints: VecDeque::with_capacity(window),
161            window,
162        }
163    }
164
165    /// Record a response fingerprint. Returns true if this is a duplicate
166    /// (fingerprint was already in the recent window).
167    pub fn record(&mut self, fingerprint: u64) -> bool {
168        let is_dup = self.fingerprints.contains(&fingerprint);
169        if self.fingerprints.len() >= self.window {
170            self.fingerprints.pop_front();
171        }
172        self.fingerprints.push_back(fingerprint);
173        is_dup
174    }
175
176    pub fn clear(&mut self) {
177        self.fingerprints.clear();
178    }
179}
180
181/// An optimization decision record.
182#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
183pub struct OptimizationDecision {
184    /// Whether the response was served from cache.
185    pub cache_hit: bool,
186    /// Whether the response was flagged as a duplicate.
187    pub is_duplicate: bool,
188    /// Cache key (BLAKE3-based hash).
189    pub cache_key: u64,
190    /// Estimated output tokens saved (0 if cache miss).
191    pub tokens_saved: u64,
192    /// Source of the optimization.
193    pub source: OptimizationSource,
194}
195
196/// What triggered the optimization.
197#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
198pub enum OptimizationSource {
199    /// No optimization applied (cache miss, not a dup).
200    None,
201    /// Response served from cache.
202    Cache,
203    /// Response flagged as duplicate of a recent answer.
204    Dedup,
205    /// Both cache hit and duplicate detection triggered.
206    CacheAndDedup,
207}
208
209/// Per-session optimizer state. Each session/conversation gets its own instance.
210#[derive(Debug)]
211pub struct SessionOptimizer {
212    pub cache: ResponseCache,
213    pub dedup: DedupTracker,
214    pub config: ResponseOptimizerConfig,
215    pub stats: OptimizerStats,
216}
217
218/// Optimizer statistics for observability.
219#[derive(Debug, Clone, Default, Serialize, Deserialize)]
220pub struct OptimizerStats {
221    pub cache_hits: u64,
222    pub cache_misses: u64,
223    pub dedup_detections: u64,
224    pub total_tokens_saved: u64,
225}
226
227impl SessionOptimizer {
228    pub fn new(config: ResponseOptimizerConfig) -> Self {
229        let cache = ResponseCache::new(
230            config.cache_capacity,
231            Duration::from_secs(config.cache_ttl_secs),
232        );
233        let dedup = DedupTracker::new(config.dedup_window);
234        Self {
235            cache,
236            dedup,
237            config,
238            stats: OptimizerStats::default(),
239        }
240    }
241
242    /// Check if a request can be served from cache.
243    /// Returns the cached response body if available.
244    pub fn try_cache_hit(&mut self, cache_key: u64) -> Option<&str> {
245        if !self.config.cache_enabled {
246            return None;
247        }
248        let hit = self.cache.get(cache_key);
249        if hit.is_some() {
250            self.stats.cache_hits += 1;
251        } else {
252            self.stats.cache_misses += 1;
253        }
254        hit
255    }
256
257    /// Record a response for future cache lookups and dedup detection.
258    pub fn record_response(
259        &mut self,
260        cache_key: u64,
261        response: &str,
262        output_tokens: u64,
263    ) -> OptimizationDecision {
264        let fingerprint = fingerprint_response(response);
265        let is_dup = if self.config.dedup_enabled {
266            let dup = self.dedup.record(fingerprint);
267            if dup {
268                self.stats.dedup_detections += 1;
269            }
270            dup
271        } else {
272            false
273        };
274
275        if self.config.cache_enabled {
276            self.cache
277                .put(cache_key, response.to_string(), output_tokens);
278        }
279
280        OptimizationDecision {
281            cache_hit: false,
282            is_duplicate: is_dup,
283            cache_key,
284            tokens_saved: 0,
285            source: if is_dup {
286                OptimizationSource::Dedup
287            } else {
288                OptimizationSource::None
289            },
290        }
291    }
292
293    /// Build a decision record for a cache hit.
294    pub fn cache_hit_decision(&self, cache_key: u64, tokens_saved: u64) -> OptimizationDecision {
295        OptimizationDecision {
296            cache_hit: true,
297            is_duplicate: false,
298            cache_key,
299            tokens_saved,
300            source: OptimizationSource::Cache,
301        }
302    }
303}
304
305/// Compute a cache key from the request components that determine the response.
306/// Uses a fast non-cryptographic hash (FxHash-style) for performance.
307pub fn compute_cache_key(model: &str, system: Option<&str>, messages: &[&str]) -> u64 {
308    let mut hasher = SimpleHasher::new();
309    hasher.write(model.as_bytes());
310    hasher.write(b"\x00");
311    if let Some(sys) = system {
312        hasher.write(sys.as_bytes());
313    }
314    hasher.write(b"\x00");
315    for msg in messages {
316        hasher.write(msg.as_bytes());
317        hasher.write(b"\x01");
318    }
319    hasher.finish()
320}
321
322/// Compute a fingerprint of a response for dedup detection.
323/// Uses the first 200 chars to catch repeated preambles/patterns.
324pub fn fingerprint_response(response: &str) -> u64 {
325    let prefix = if response.len() > 200 {
326        &response[..200]
327    } else {
328        response
329    };
330    let mut hasher = SimpleHasher::new();
331    hasher.write(prefix.as_bytes());
332    hasher.finish()
333}
334
335/// A simple, fast, non-cryptographic hasher (FNV-1a inspired).
336/// Used for cache keys and fingerprints where collision resistance is not
337/// security-critical (worst case: a cache miss or false dedup negative).
338struct SimpleHasher {
339    state: u64,
340}
341
342impl SimpleHasher {
343    const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
344    const PRIME: u64 = 0x0100_0000_01b3;
345
346    fn new() -> Self {
347        Self {
348            state: Self::OFFSET,
349        }
350    }
351
352    fn write(&mut self, bytes: &[u8]) {
353        for &b in bytes {
354            self.state ^= u64::from(b);
355            self.state = self.state.wrapping_mul(Self::PRIME);
356        }
357    }
358
359    fn finish(&self) -> u64 {
360        self.state
361    }
362}
363
364/// Global optimizer registry — maps session IDs to their optimizer instances.
365/// In production, session lifetime is managed by the proxy's connection tracking.
366static OPTIMIZERS: std::sync::OnceLock<
367    Mutex<std::collections::HashMap<String, Arc<Mutex<SessionOptimizer>>>>,
368> = std::sync::OnceLock::new();
369
370fn registry() -> &'static Mutex<std::collections::HashMap<String, Arc<Mutex<SessionOptimizer>>>> {
371    OPTIMIZERS.get_or_init(|| Mutex::new(std::collections::HashMap::new()))
372}
373
374/// Get or create the optimizer for a session.
375pub fn get_or_create(
376    session_id: &str,
377    config: &ResponseOptimizerConfig,
378) -> Arc<Mutex<SessionOptimizer>> {
379    let mut reg = registry()
380        .lock()
381        .unwrap_or_else(std::sync::PoisonError::into_inner);
382    reg.entry(session_id.to_string())
383        .or_insert_with(|| Arc::new(Mutex::new(SessionOptimizer::new(config.clone()))))
384        .clone()
385}
386
387/// Apply the proxy optimizer to an OCLA response decision.
388pub fn optimize_response(request: &ResponseOptimizationRequest) -> OptimizationDecision {
389    let config = ResponseOptimizerConfig {
390        enabled: true,
391        ..Default::default()
392    };
393    let optimizer = get_or_create(&request.context.session_id, &config);
394    let mut optimizer = optimizer
395        .lock()
396        .unwrap_or_else(std::sync::PoisonError::into_inner);
397    let cache_key = compute_cache_key("ocla-response", None, &[&request.response_ref]);
398
399    let decision = if optimizer.try_cache_hit(cache_key).is_some() {
400        optimizer.cache_hit_decision(
401            cache_key,
402            request
403                .original_tokens
404                .saturating_sub(request.target_tokens),
405        )
406    } else {
407        optimizer.record_response(
408            cache_key,
409            &request.response_ref,
410            request.target_tokens.min(request.original_tokens),
411        )
412    };
413
414    let delivered_tokens = if decision.cache_hit {
415        0
416    } else {
417        request.target_tokens.min(request.original_tokens)
418    };
419    record_response_measurement(request, delivered_tokens);
420    decision
421}
422
423fn record_response_measurement(request: &ResponseOptimizationRequest, delivered_tokens: u64) {
424    let ledger_disabled = std::env::var("LEAN_CTX_SAVINGS_LEDGER").is_ok_and(|value| {
425        matches!(
426            value.trim().to_ascii_lowercase().as_str(),
427            "off" | "0" | "false" | "no"
428        )
429    });
430    if request.original_tokens <= delivered_tokens || ledger_disabled {
431        return;
432    }
433    let Some(path) = savings_ledger::store::default_path() else {
434        return;
435    };
436
437    let quote = crate::core::gain::model_pricing::ModelPricing::load().quote(None);
438    let saved_tokens = request.original_tokens - delivered_tokens;
439    let event = SavingsEvent {
440        ts: chrono::Utc::now().to_rfc3339(),
441        tool: "proxy_response_optimizer".into(),
442        mechanism: savings_ledger::MECHANISM_COMPRESSION.into(),
443        model_id: quote.model_key.clone(),
444        tokenizer: crate::core::tokens::detect_tokenizer(&quote.model_key).to_string(),
445        baseline_tokens: request.original_tokens,
446        actual_tokens: delivered_tokens,
447        saved_tokens,
448        bounce_adjustment: 0,
449        unit_price_per_m_usd: quote.cost.input_per_m,
450        saved_usd: saved_tokens as f64 * quote.cost.input_per_m / 1_000_000.0,
451        repo_hash: String::new(),
452        agent_id: request.context.agent_id.clone(),
453        prev_hash: String::new(),
454        entry_hash: String::new(),
455        version: env!("CARGO_PKG_VERSION").into(),
456        intent_tag: None,
457        outcome: None,
458        model_original: None,
459        model_routed: None,
460        routing_savings: None,
461        response_original_tokens: Some(request.original_tokens),
462        response_delivered_tokens: Some(delivered_tokens),
463        agent_chain_id: None,
464        chain_depth: None,
465        measurement_method: Some(savings_ledger::event::MeasurementMethod::DirectCount),
466        evidence_class: Some(savings_ledger::event::EvidenceClass::Measured),
467        confidence: Some(1.0),
468        request_id: None,
469        session_id: None,
470        trace_id: None,
471        quality_signal: None,
472        attribution_group: None,
473        attribution_id: Some(request.response_ref.clone()),
474        baseline_ref: None,
475        price_version: None,
476        customer_approval: None,
477        settlement_status: None,
478        is_first_inject: None,
479        cache_read_per_m_usd: Some(quote.cost.cache_read_per_m),
480        cache_write_per_m_usd: Some(quote.cost.cache_write_per_m),
481        governor: None,
482        solution_decision: None,
483        loc_added: None,
484        loc_removed: None,
485        path: None,
486        lines_added: None,
487        lines_removed: None,
488        net: None,
489    };
490    let _ = savings_ledger::store::append(&path, event);
491}
492
493/// Remove a session's optimizer (cleanup on session end).
494pub fn remove_session(session_id: &str) {
495    let mut reg = registry()
496        .lock()
497        .unwrap_or_else(std::sync::PoisonError::into_inner);
498    reg.remove(session_id);
499}
500
501/// Global statistics across all sessions.
502pub fn global_stats() -> OptimizerStats {
503    let reg = registry()
504        .lock()
505        .unwrap_or_else(std::sync::PoisonError::into_inner);
506    let mut total = OptimizerStats::default();
507    for opt in reg.values() {
508        let guard = opt
509            .lock()
510            .unwrap_or_else(std::sync::PoisonError::into_inner);
511        total.cache_hits += guard.stats.cache_hits;
512        total.cache_misses += guard.stats.cache_misses;
513        total.dedup_detections += guard.stats.dedup_detections;
514        total.total_tokens_saved += guard.stats.total_tokens_saved;
515    }
516    total
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522
523    fn default_config() -> ResponseOptimizerConfig {
524        ResponseOptimizerConfig {
525            enabled: true,
526            ..Default::default()
527        }
528    }
529
530    // ─── Cache tests ─────────────────────────────────────────────────────
531
532    #[test]
533    fn cache_stores_and_retrieves() {
534        let mut cache = ResponseCache::new(8, Duration::from_mins(1));
535        cache.put(42, "hello world".to_string(), 5);
536        assert_eq!(cache.get(42), Some("hello world"));
537    }
538
539    #[test]
540    fn cache_miss_returns_none() {
541        let mut cache = ResponseCache::new(8, Duration::from_mins(1));
542        assert_eq!(cache.get(99), None);
543    }
544
545    #[test]
546    fn cache_respects_capacity() {
547        let mut cache = ResponseCache::new(3, Duration::from_mins(1));
548        cache.put(1, "a".into(), 1);
549        cache.put(2, "b".into(), 1);
550        cache.put(3, "c".into(), 1);
551        cache.put(4, "d".into(), 1);
552        // Oldest (key=1) evicted.
553        assert_eq!(cache.get(1), None);
554        assert_eq!(cache.get(2), Some("b"));
555        assert_eq!(cache.get(4), Some("d"));
556        assert_eq!(cache.len(), 3);
557    }
558
559    #[test]
560    fn cache_updates_existing_key() {
561        let mut cache = ResponseCache::new(8, Duration::from_mins(1));
562        cache.put(1, "old".into(), 5);
563        cache.put(1, "new".into(), 5);
564        assert_eq!(cache.get(1), Some("new"));
565        assert_eq!(cache.len(), 1);
566    }
567
568    // ─── Dedup tests ─────────────────────────────────────────────────────
569
570    #[test]
571    fn dedup_detects_repeated_fingerprint() {
572        let mut dedup = DedupTracker::new(8);
573        assert!(!dedup.record(100), "first occurrence");
574        assert!(!dedup.record(200), "different fingerprint");
575        assert!(dedup.record(100), "repeated");
576    }
577
578    #[test]
579    fn dedup_window_evicts_old_entries() {
580        let mut dedup = DedupTracker::new(3);
581        dedup.record(1);
582        dedup.record(2);
583        dedup.record(3);
584        // Window full [1,2,3]. Adding 4 evicts 1 → [2,3,4].
585        dedup.record(4);
586        assert!(!dedup.record(1), "1 was evicted from window");
587        // Recording 1 evicted 2 → window is now [3,4,1].
588        assert!(dedup.record(3), "3 still in window");
589        assert!(!dedup.record(2), "2 was evicted when 1 was added");
590    }
591
592    // ─── Cache key computation ───────────────────────────────────────────
593
594    #[test]
595    fn cache_key_is_deterministic() {
596        let k1 = compute_cache_key("gpt-4o", Some("sys"), &["hello", "world"]);
597        let k2 = compute_cache_key("gpt-4o", Some("sys"), &["hello", "world"]);
598        assert_eq!(k1, k2);
599    }
600
601    #[test]
602    fn cache_key_differs_for_different_inputs() {
603        let k1 = compute_cache_key("gpt-4o", Some("sys"), &["hello"]);
604        let k2 = compute_cache_key("gpt-4o", Some("sys"), &["world"]);
605        assert_ne!(k1, k2);
606
607        let k3 = compute_cache_key("gpt-4o", None, &["hello"]);
608        let k4 = compute_cache_key("claude-sonnet-4", None, &["hello"]);
609        assert_ne!(k3, k4);
610    }
611
612    #[test]
613    fn cache_key_order_matters() {
614        let k1 = compute_cache_key("m", None, &["a", "b"]);
615        let k2 = compute_cache_key("m", None, &["b", "a"]);
616        assert_ne!(k1, k2, "message order must affect key");
617    }
618
619    // ─── Response fingerprinting ─────────────────────────────────────────
620
621    #[test]
622    fn fingerprint_uses_prefix() {
623        let short = "hello";
624        let long = format!("{}{}", "x".repeat(200), "DIFFERENT_TAIL");
625        let long2 = format!("{}{}", "x".repeat(200), "OTHER_TAIL");
626        // Same 200-char prefix → same fingerprint.
627        assert_eq!(fingerprint_response(&long), fingerprint_response(&long2));
628        // Different prefix → different fingerprint.
629        assert_ne!(fingerprint_response(short), fingerprint_response(&long));
630    }
631
632    // ─── SessionOptimizer integration ────────────────────────────────────
633
634    #[test]
635    fn session_optimizer_cache_flow() {
636        let mut opt = SessionOptimizer::new(default_config());
637        let key = compute_cache_key("gpt-4o", None, &["what is rust?"]);
638
639        // Miss on first query.
640        assert!(opt.try_cache_hit(key).is_none());
641        assert_eq!(opt.stats.cache_misses, 1);
642
643        // Record the response.
644        let decision = opt.record_response(key, "Rust is a systems programming language.", 12);
645        assert!(!decision.cache_hit);
646        assert!(!decision.is_duplicate);
647
648        // Hit on identical query.
649        let hit = opt.try_cache_hit(key);
650        assert_eq!(hit, Some("Rust is a systems programming language."));
651        assert_eq!(opt.stats.cache_hits, 1);
652    }
653
654    #[test]
655    fn session_optimizer_dedup_flow() {
656        let mut opt = SessionOptimizer::new(default_config());
657        let key1 = 100;
658        let key2 = 200;
659
660        // Same response to different queries → dedup flags it.
661        let response = "Rust is a systems programming language.";
662        let d1 = opt.record_response(key1, response, 12);
663        assert!(!d1.is_duplicate);
664
665        let d2 = opt.record_response(key2, response, 12);
666        assert!(d2.is_duplicate);
667        assert_eq!(d2.source, OptimizationSource::Dedup);
668        assert_eq!(opt.stats.dedup_detections, 1);
669    }
670
671    #[test]
672    fn disabled_optimizer_is_noop() {
673        let config = ResponseOptimizerConfig {
674            enabled: true,
675            cache_enabled: false,
676            dedup_enabled: false,
677            ..Default::default()
678        };
679        let mut opt = SessionOptimizer::new(config);
680        let key = 42;
681
682        assert!(opt.try_cache_hit(key).is_none());
683        let d = opt.record_response(key, "response", 10);
684        assert!(!d.is_duplicate);
685        // Cache should be empty since disabled.
686        assert!(opt.cache.is_empty());
687    }
688
689    #[test]
690    fn global_registry_creates_and_retrieves() {
691        let config = default_config();
692        let opt1 = get_or_create("session-test-1", &config);
693        let opt2 = get_or_create("session-test-1", &config);
694        // Same session → same instance.
695        assert!(Arc::ptr_eq(&opt1, &opt2));
696
697        let opt3 = get_or_create("session-test-2", &config);
698        assert!(!Arc::ptr_eq(&opt1, &opt3));
699
700        // Cleanup.
701        remove_session("session-test-1");
702        remove_session("session-test-2");
703    }
704
705    // ─── Determinism ─────────────────────────────────────────────────────
706
707    #[test]
708    fn optimizer_decisions_are_deterministic() {
709        let mut opt = SessionOptimizer::new(default_config());
710        let key = compute_cache_key("m", None, &["q"]);
711        opt.record_response(key, "answer", 5);
712
713        // Same cache state + same key → deterministic hit.
714        let h1 = opt.try_cache_hit(key).map(str::to_string);
715        let h2 = opt.try_cache_hit(key).map(str::to_string);
716        assert_eq!(h1, h2);
717    }
718
719    #[tokio::test]
720    async fn ocla_registry_path_measures_response_tokens() {
721        let _isolated = crate::core::data_dir::isolated_data_dir();
722        let registry = crate::core::ocla::registry::OclaRegistry::with_builtins();
723        let request = ResponseOptimizationRequest {
724            context: crate::core::ocla::types::OclaRequestContext {
725                request_id: "response-optimizer-test".into(),
726                session_id: "response-optimizer-test".into(),
727                agent_id: "agent-test".into(),
728                content_ref: "response:test".into(),
729                tenant_id: None,
730                trace_id: "tr-unit".into(),
731                task_id: None,
732                parent_task_id: None,
733            },
734            response_ref: "blake3:response-optimizer-test".into(),
735            original_tokens: 1_000,
736            target_tokens: 400,
737        };
738
739        let result = registry
740            .response_optimizer
741            .optimize_response(request)
742            .await
743            .expect("registry response optimizer must succeed");
744        assert_eq!(result.delivered_tokens, 400);
745
746        let event = savings_ledger::all_events()
747            .into_iter()
748            .find(|event| event.tool == "proxy_response_optimizer")
749            .expect("response optimization must create a ledger event");
750        assert_eq!(event.response_original_tokens, Some(1_000));
751        assert_eq!(event.response_delivered_tokens, Some(400));
752    }
753}