Skip to main content

lean_ctx_proxy/
holdout.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Deterministic output-savings holdout (#895 Track B).
3//!
4//! To honestly measure how much our **output-shaping** (cache-safe effort
5//! control #834 + the optional verbosity steer) reduces *output* tokens, we A/B
6//! it against a control arm. The cohort is a pure function of conversation
7//! identity (system prompt + first user message), so:
8//! * the SAME conversation is always in the SAME arm — every turn — which keeps
9//!   the decision (and therefore the request body) stable across turns, and
10//! * a fraction `f` of conversations land in the control arm, which is metered
11//!   but NOT output-shaped, giving a clean paired comparison of average output
12//!   tokens per arm.
13//!
14//! `output_holdout = 0` (default) puts everyone in `Treatment` → no behaviour
15//! change. The hash is content-addressed (blake3), not random, so it is stable
16//! across processes and machines.
17//!
18//! The same machinery runs a second, independent experiment for the proxy's
19//! *input* compression (#1905, [`compression_arm`]), whose control arm is sent
20//! uncompressed.
21
22use serde_json::Value;
23
24/// Number of cohort buckets; a conversation maps to one bucket in `0..BUCKETS`.
25const BUCKETS: u64 = 10_000;
26
27/// Separator between key fields, chosen so it cannot occur in normal prose.
28const FIELD_SEP: char = '\u{1}';
29
30/// Which experiment arm a conversation is assigned to.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Arm {
33    /// Output-shaping skipped (the measurement baseline); still metered.
34    Control,
35    /// Output-shaping applied (effort control + verbosity steer).
36    Treatment,
37}
38
39impl Arm {
40    #[must_use]
41    pub fn as_str(self) -> &'static str {
42        match self {
43            Arm::Control => "control",
44            Arm::Treatment => "treatment",
45        }
46    }
47}
48
49/// Deterministic bucket `0..BUCKETS` from conversation key material.
50#[must_use]
51pub fn bucket(key: &str) -> u64 {
52    let hash = blake3::hash(key.as_bytes());
53    let b = hash.as_bytes();
54    let n = u64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]]);
55    n % BUCKETS
56}
57
58/// Assign an [`Arm`] for `key` given the control fraction `holdout` in `[0,1]`.
59/// `holdout <= 0` ⇒ everyone is [`Arm::Treatment`] (no control arm).
60#[must_use]
61pub fn assign(key: &str, holdout: f64) -> Arm {
62    let h = holdout.clamp(0.0, 1.0);
63    if h <= 0.0 {
64        return Arm::Treatment;
65    }
66    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
67    let threshold = (h * BUCKETS as f64).round() as u64;
68    if bucket(key) < threshold {
69        Arm::Control
70    } else {
71        Arm::Treatment
72    }
73}
74
75/// Arm for the input-compression holdout (#1905). The control arm forwards the
76/// request without any of the proxy's input compression, giving a real
77/// uncompressed baseline instead of the simulated one Shadow Mode reports.
78///
79/// The key is salted so this cohort is independent of the output-savings one:
80/// with both holdouts active, being in one control arm says nothing about the
81/// other, and each comparison stays clean.
82#[must_use]
83pub fn compression_arm(key: &str, holdout: f64) -> Arm {
84    assign(&format!("compression{FIELD_SEP}{key}"), holdout)
85}
86
87/// Conversation key for any supported request shape, for holdouts decided
88/// before the provider is known (#1905). Gemini bodies carry `contents`,
89/// Responses-API bodies `input`, Anthropic bodies a top-level `system`; the rest
90/// are Chat Completions.
91#[must_use]
92pub fn conversation_key(doc: &Value) -> String {
93    if doc.get("contents").is_some() {
94        google_key(doc)
95    } else if doc.get("input").is_some() && doc.get("messages").is_none() {
96        openai_responses_key(doc)
97    } else if doc.get("system").is_some() {
98        anthropic_key(doc)
99    } else {
100        openai_chat_key(doc)
101    }
102}
103
104/// Input-compression holdout arm (#1905) for a parsed request body, or `None`
105/// when `[proxy] compression_holdout` is off. The forward path calls this once,
106/// on the caller's pristine body before any stage rewrites it, so every turn
107/// of a conversation lands in the same arm.
108#[must_use]
109pub fn compression_holdout_arm(doc: &Value) -> Option<Arm> {
110    let fraction = lean_ctx::core::config::Config::load()
111        .proxy
112        .compression_holdout_fraction();
113    (fraction > 0.0).then(|| compression_arm(&conversation_key(doc), fraction))
114}
115
116#[cfg(feature = "http-server")]
117thread_local! {
118    static COMPRESSION_CONTROL: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
119}
120
121/// Marks the requests compressed on this thread as input-compression control
122/// (#1905) until the guard drops. The forward path decides the arm once, on the
123/// pristine body, and every compression stage it calls synchronously reads it
124/// through [`in_compression_control`] — one decision, so no stage can disagree.
125#[cfg(feature = "http-server")]
126pub(crate) struct CompressionControlScope(bool);
127
128#[cfg(feature = "http-server")]
129pub(crate) fn enter_compression_control(control: bool) -> CompressionControlScope {
130    CompressionControlScope(COMPRESSION_CONTROL.replace(control))
131}
132
133#[cfg(feature = "http-server")]
134impl Drop for CompressionControlScope {
135    fn drop(&mut self) {
136        COMPRESSION_CONTROL.set(self.0);
137    }
138}
139
140/// Whether the request being compressed on this thread is in the
141/// input-compression control arm, i.e. must be forwarded uncompressed.
142#[must_use]
143#[cfg(feature = "http-server")]
144pub(crate) fn in_compression_control() -> bool {
145    COMPRESSION_CONTROL.get()
146}
147
148/// The holdout arms one request belongs to; `None` where that holdout is off.
149#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
150pub struct Cohorts {
151    /// Output-shaping holdout (#895).
152    pub output: Option<Arm>,
153    /// Input-compression holdout (#1905).
154    pub compression: Option<Arm>,
155}
156
157/// Flatten a JSON content value (string / array of text blocks / `{text}` /
158/// `{content}`) into plain text for cohort keying. Order-preserving and pure.
159fn flatten_text(v: &Value) -> String {
160    match v {
161        Value::String(s) => s.clone(),
162        Value::Array(items) => items.iter().map(flatten_text).collect::<Vec<_>>().join(" "),
163        Value::Object(map) => {
164            if let Some(Value::String(t)) = map.get("text") {
165                t.clone()
166            } else if let Some(inner) = map.get("content") {
167                flatten_text(inner)
168            } else if let Some(parts) = map.get("parts") {
169                flatten_text(parts)
170            } else {
171                String::new()
172            }
173        }
174        _ => String::new(),
175    }
176}
177
178/// Text of the first message in `messages` whose `role` matches, flattening the
179/// `content_field` (e.g. `"content"` for OpenAI/Anthropic, `"parts"` for
180/// Gemini). Empty when absent.
181fn first_message_text(messages: Option<&Value>, role: &str, content_field: &str) -> String {
182    messages
183        .and_then(Value::as_array)
184        .and_then(|arr| {
185            arr.iter()
186                .find(|m| m.get("role").and_then(Value::as_str) == Some(role))
187        })
188        .and_then(|m| m.get(content_field))
189        .map(flatten_text)
190        .unwrap_or_default()
191}
192
193/// Cohort key for an Anthropic `/v1/messages` body: `system` + first user turn.
194#[must_use]
195pub fn anthropic_key(doc: &Value) -> String {
196    let system = doc.get("system").map(flatten_text).unwrap_or_default();
197    let first_user = first_message_text(doc.get("messages"), "user", "content");
198    format!("{system}{FIELD_SEP}{first_user}")
199}
200
201/// Cohort key for an OpenAI Chat Completions body: first `system`/`developer`
202/// message + first user message.
203#[must_use]
204pub fn openai_chat_key(doc: &Value) -> String {
205    let messages = doc.get("messages");
206    let mut system = first_message_text(messages, "system", "content");
207    if system.is_empty() {
208        system = first_message_text(messages, "developer", "content");
209    }
210    let first_user = first_message_text(messages, "user", "content");
211    format!("{system}{FIELD_SEP}{first_user}")
212}
213
214/// Cohort key for an OpenAI Responses body: `instructions` + first user `input`.
215#[must_use]
216pub fn openai_responses_key(doc: &Value) -> String {
217    let system = doc
218        .get("instructions")
219        .map(flatten_text)
220        .unwrap_or_default();
221    // `input` is either a plain string or an array of role/content items.
222    let first_user = match doc.get("input") {
223        Some(Value::String(s)) => s.clone(),
224        other => first_message_text(other, "user", "content"),
225    };
226    format!("{system}{FIELD_SEP}{first_user}")
227}
228
229/// Cohort key for a Gemini `generateContent` body: `systemInstruction` + first
230/// user `contents` turn.
231#[must_use]
232pub fn google_key(doc: &Value) -> String {
233    let system = doc
234        .get("systemInstruction")
235        .map(flatten_text)
236        .unwrap_or_default();
237    let first_user = first_message_text(doc.get("contents"), "user", "parts");
238    format!("{system}{FIELD_SEP}{first_user}")
239}
240
241/// Deterministic holdout assignment based on task_id.
242/// Returns true if the task should be held out (assigned to baseline/control group).
243pub fn assign_holdout_by_task(task_id: &str, holdout_rate: f64) -> bool {
244    use std::hash::{Hash, Hasher};
245    let mut hasher = std::collections::hash_map::DefaultHasher::new();
246    task_id.hash(&mut hasher);
247    let hash = hasher.finish();
248    let normalized = (hash % 10000) as f64 / 10000.0;
249    normalized < holdout_rate
250}
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use serde_json::json;
255
256    #[test]
257    fn holdout_zero_is_all_treatment() {
258        assert_eq!(assign("any key", 0.0), Arm::Treatment);
259        assert_eq!(assign("any key", -1.0), Arm::Treatment);
260    }
261
262    #[test]
263    fn holdout_one_is_all_control() {
264        assert_eq!(assign("any key", 1.0), Arm::Control);
265        assert_eq!(assign("another", 2.0), Arm::Control);
266    }
267
268    #[test]
269    fn assignment_is_deterministic_and_stable() {
270        // Same key → same arm, every call (cross-turn stability).
271        let k = "system\u{1}first user message";
272        let a = assign(k, 0.5);
273        for _ in 0..20 {
274            assert_eq!(assign(k, 0.5), a);
275        }
276    }
277
278    #[test]
279    fn fraction_is_approximately_honoured() {
280        // ~30% of distinct conversations land in control.
281        let control = (0..5000)
282            .filter(|i| assign(&format!("conv-{i}"), 0.3) == Arm::Control)
283            .count();
284        let frac = control as f64 / 5000.0;
285        assert!((0.27..0.33).contains(&frac), "got {frac}");
286    }
287
288    #[test]
289    fn anthropic_key_uses_system_and_first_user() {
290        let doc = json!({
291            "system": "You are helpful.",
292            "messages": [
293                {"role": "user", "content": "Hello there"},
294                {"role": "assistant", "content": "Hi"},
295                {"role": "user", "content": "later turn"}
296            ]
297        });
298        assert_eq!(anthropic_key(&doc), "You are helpful.\u{1}Hello there");
299    }
300
301    #[test]
302    fn anthropic_key_flattens_block_arrays() {
303        let doc = json!({
304            "system": [{"type": "text", "text": "Sys A"}, {"type": "text", "text": "Sys B"}],
305            "messages": [{"role": "user", "content": [{"type": "text", "text": "U1"}]}]
306        });
307        assert_eq!(anthropic_key(&doc), "Sys A Sys B\u{1}U1");
308    }
309
310    #[test]
311    fn openai_chat_key_prefers_system_then_developer() {
312        let doc = json!({
313            "messages": [
314                {"role": "developer", "content": "Dev rules"},
315                {"role": "user", "content": "Q"}
316            ]
317        });
318        assert_eq!(openai_chat_key(&doc), "Dev rules\u{1}Q");
319    }
320
321    #[test]
322    fn google_key_uses_system_instruction_and_contents() {
323        let doc = json!({
324            "systemInstruction": {"parts": [{"text": "Be terse"}]},
325            "contents": [{"role": "user", "parts": [{"text": "First Q"}]}]
326        });
327        assert_eq!(google_key(&doc), "Be terse\u{1}First Q");
328    }
329
330    #[test]
331    fn responses_key_handles_string_and_array_input() {
332        let s = json!({"instructions": "Sys", "input": "just a string"});
333        assert_eq!(openai_responses_key(&s), "Sys\u{1}just a string");
334        let a = json!({
335            "instructions": "Sys",
336            "input": [{"role": "user", "content": "arr q"}]
337        });
338        assert_eq!(openai_responses_key(&a), "Sys\u{1}arr q");
339    }
340}