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        quality_signal: None,
470        attribution_group: None,
471        attribution_id: Some(request.response_ref.clone()),
472        baseline_ref: None,
473        price_version: None,
474        customer_approval: None,
475        settlement_status: None,
476        is_first_inject: None,
477        cache_read_per_m_usd: Some(quote.cost.cache_read_per_m),
478        cache_write_per_m_usd: Some(quote.cost.cache_write_per_m),
479    };
480    let _ = savings_ledger::store::append(&path, event);
481}
482
483/// Remove a session's optimizer (cleanup on session end).
484pub fn remove_session(session_id: &str) {
485    let mut reg = registry()
486        .lock()
487        .unwrap_or_else(std::sync::PoisonError::into_inner);
488    reg.remove(session_id);
489}
490
491/// Global statistics across all sessions.
492pub fn global_stats() -> OptimizerStats {
493    let reg = registry()
494        .lock()
495        .unwrap_or_else(std::sync::PoisonError::into_inner);
496    let mut total = OptimizerStats::default();
497    for opt in reg.values() {
498        let guard = opt
499            .lock()
500            .unwrap_or_else(std::sync::PoisonError::into_inner);
501        total.cache_hits += guard.stats.cache_hits;
502        total.cache_misses += guard.stats.cache_misses;
503        total.dedup_detections += guard.stats.dedup_detections;
504        total.total_tokens_saved += guard.stats.total_tokens_saved;
505    }
506    total
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    fn default_config() -> ResponseOptimizerConfig {
514        ResponseOptimizerConfig {
515            enabled: true,
516            ..Default::default()
517        }
518    }
519
520    // ─── Cache tests ─────────────────────────────────────────────────────
521
522    #[test]
523    fn cache_stores_and_retrieves() {
524        let mut cache = ResponseCache::new(8, Duration::from_mins(1));
525        cache.put(42, "hello world".to_string(), 5);
526        assert_eq!(cache.get(42), Some("hello world"));
527    }
528
529    #[test]
530    fn cache_miss_returns_none() {
531        let mut cache = ResponseCache::new(8, Duration::from_mins(1));
532        assert_eq!(cache.get(99), None);
533    }
534
535    #[test]
536    fn cache_respects_capacity() {
537        let mut cache = ResponseCache::new(3, Duration::from_mins(1));
538        cache.put(1, "a".into(), 1);
539        cache.put(2, "b".into(), 1);
540        cache.put(3, "c".into(), 1);
541        cache.put(4, "d".into(), 1);
542        // Oldest (key=1) evicted.
543        assert_eq!(cache.get(1), None);
544        assert_eq!(cache.get(2), Some("b"));
545        assert_eq!(cache.get(4), Some("d"));
546        assert_eq!(cache.len(), 3);
547    }
548
549    #[test]
550    fn cache_updates_existing_key() {
551        let mut cache = ResponseCache::new(8, Duration::from_mins(1));
552        cache.put(1, "old".into(), 5);
553        cache.put(1, "new".into(), 5);
554        assert_eq!(cache.get(1), Some("new"));
555        assert_eq!(cache.len(), 1);
556    }
557
558    // ─── Dedup tests ─────────────────────────────────────────────────────
559
560    #[test]
561    fn dedup_detects_repeated_fingerprint() {
562        let mut dedup = DedupTracker::new(8);
563        assert!(!dedup.record(100), "first occurrence");
564        assert!(!dedup.record(200), "different fingerprint");
565        assert!(dedup.record(100), "repeated");
566    }
567
568    #[test]
569    fn dedup_window_evicts_old_entries() {
570        let mut dedup = DedupTracker::new(3);
571        dedup.record(1);
572        dedup.record(2);
573        dedup.record(3);
574        // Window full [1,2,3]. Adding 4 evicts 1 → [2,3,4].
575        dedup.record(4);
576        assert!(!dedup.record(1), "1 was evicted from window");
577        // Recording 1 evicted 2 → window is now [3,4,1].
578        assert!(dedup.record(3), "3 still in window");
579        assert!(!dedup.record(2), "2 was evicted when 1 was added");
580    }
581
582    // ─── Cache key computation ───────────────────────────────────────────
583
584    #[test]
585    fn cache_key_is_deterministic() {
586        let k1 = compute_cache_key("gpt-4o", Some("sys"), &["hello", "world"]);
587        let k2 = compute_cache_key("gpt-4o", Some("sys"), &["hello", "world"]);
588        assert_eq!(k1, k2);
589    }
590
591    #[test]
592    fn cache_key_differs_for_different_inputs() {
593        let k1 = compute_cache_key("gpt-4o", Some("sys"), &["hello"]);
594        let k2 = compute_cache_key("gpt-4o", Some("sys"), &["world"]);
595        assert_ne!(k1, k2);
596
597        let k3 = compute_cache_key("gpt-4o", None, &["hello"]);
598        let k4 = compute_cache_key("claude-sonnet-4", None, &["hello"]);
599        assert_ne!(k3, k4);
600    }
601
602    #[test]
603    fn cache_key_order_matters() {
604        let k1 = compute_cache_key("m", None, &["a", "b"]);
605        let k2 = compute_cache_key("m", None, &["b", "a"]);
606        assert_ne!(k1, k2, "message order must affect key");
607    }
608
609    // ─── Response fingerprinting ─────────────────────────────────────────
610
611    #[test]
612    fn fingerprint_uses_prefix() {
613        let short = "hello";
614        let long = format!("{}{}", "x".repeat(200), "DIFFERENT_TAIL");
615        let long2 = format!("{}{}", "x".repeat(200), "OTHER_TAIL");
616        // Same 200-char prefix → same fingerprint.
617        assert_eq!(fingerprint_response(&long), fingerprint_response(&long2));
618        // Different prefix → different fingerprint.
619        assert_ne!(fingerprint_response(short), fingerprint_response(&long));
620    }
621
622    // ─── SessionOptimizer integration ────────────────────────────────────
623
624    #[test]
625    fn session_optimizer_cache_flow() {
626        let mut opt = SessionOptimizer::new(default_config());
627        let key = compute_cache_key("gpt-4o", None, &["what is rust?"]);
628
629        // Miss on first query.
630        assert!(opt.try_cache_hit(key).is_none());
631        assert_eq!(opt.stats.cache_misses, 1);
632
633        // Record the response.
634        let decision = opt.record_response(key, "Rust is a systems programming language.", 12);
635        assert!(!decision.cache_hit);
636        assert!(!decision.is_duplicate);
637
638        // Hit on identical query.
639        let hit = opt.try_cache_hit(key);
640        assert_eq!(hit, Some("Rust is a systems programming language."));
641        assert_eq!(opt.stats.cache_hits, 1);
642    }
643
644    #[test]
645    fn session_optimizer_dedup_flow() {
646        let mut opt = SessionOptimizer::new(default_config());
647        let key1 = 100;
648        let key2 = 200;
649
650        // Same response to different queries → dedup flags it.
651        let response = "Rust is a systems programming language.";
652        let d1 = opt.record_response(key1, response, 12);
653        assert!(!d1.is_duplicate);
654
655        let d2 = opt.record_response(key2, response, 12);
656        assert!(d2.is_duplicate);
657        assert_eq!(d2.source, OptimizationSource::Dedup);
658        assert_eq!(opt.stats.dedup_detections, 1);
659    }
660
661    #[test]
662    fn disabled_optimizer_is_noop() {
663        let config = ResponseOptimizerConfig {
664            enabled: true,
665            cache_enabled: false,
666            dedup_enabled: false,
667            ..Default::default()
668        };
669        let mut opt = SessionOptimizer::new(config);
670        let key = 42;
671
672        assert!(opt.try_cache_hit(key).is_none());
673        let d = opt.record_response(key, "response", 10);
674        assert!(!d.is_duplicate);
675        // Cache should be empty since disabled.
676        assert!(opt.cache.is_empty());
677    }
678
679    #[test]
680    fn global_registry_creates_and_retrieves() {
681        let config = default_config();
682        let opt1 = get_or_create("session-test-1", &config);
683        let opt2 = get_or_create("session-test-1", &config);
684        // Same session → same instance.
685        assert!(Arc::ptr_eq(&opt1, &opt2));
686
687        let opt3 = get_or_create("session-test-2", &config);
688        assert!(!Arc::ptr_eq(&opt1, &opt3));
689
690        // Cleanup.
691        remove_session("session-test-1");
692        remove_session("session-test-2");
693    }
694
695    // ─── Determinism ─────────────────────────────────────────────────────
696
697    #[test]
698    fn optimizer_decisions_are_deterministic() {
699        let mut opt = SessionOptimizer::new(default_config());
700        let key = compute_cache_key("m", None, &["q"]);
701        opt.record_response(key, "answer", 5);
702
703        // Same cache state + same key → deterministic hit.
704        let h1 = opt.try_cache_hit(key).map(str::to_string);
705        let h2 = opt.try_cache_hit(key).map(str::to_string);
706        assert_eq!(h1, h2);
707    }
708
709    #[test]
710    fn ocla_registry_path_measures_response_tokens() {
711        let _isolated = crate::core::data_dir::isolated_data_dir();
712        let registry = crate::core::ocla::registry::OclaRegistry::with_builtins();
713        let request = ResponseOptimizationRequest {
714            context: crate::core::ocla::types::OclaRequestContext {
715                request_id: "response-optimizer-test".into(),
716                session_id: "response-optimizer-test".into(),
717                agent_id: "agent-test".into(),
718                content_ref: "response:test".into(),
719                tenant_id: None,
720                trace_id: "tr-unit".into(),
721            },
722            response_ref: "blake3:response-optimizer-test".into(),
723            original_tokens: 1_000,
724            target_tokens: 400,
725        };
726
727        let result = registry
728            .response_optimizer
729            .optimize_response(request)
730            .expect("registry response optimizer must succeed");
731        assert_eq!(result.delivered_tokens, 400);
732
733        let event = savings_ledger::all_events()
734            .into_iter()
735            .find(|event| event.tool == "proxy_response_optimizer")
736            .expect("response optimization must create a ledger event");
737        assert_eq!(event.response_original_tokens, Some(1_000));
738        assert_eq!(event.response_delivered_tokens, Some(400));
739    }
740}