Skip to main content

lean_ctx/proxy/
response_optimizer.rs

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