firstpass_core/rollout.rs
1//! Percentage rollout: deciding which slice of matched traffic actually enforces (ADR 0009 D1).
2//!
3//! The whole mechanism is one pure function, and the reason it is a *function of a stable key*
4//! rather than a random draw is the point of the module. Drawing per request would:
5//!
6//! * flip a multi-turn conversation between enforced and observed mid-thread, so a user sees
7//! the model change for no reason they can perceive; and, worse,
8//! * make the served population unstable. The distribution-free bound Firstpass publishes is
9//! computed over *the population that was served* — if membership is re-drawn every request,
10//! that population is not a stable sample of anything and the bound describes a cohort that
11//! never existed.
12//!
13//! So assignment is `hash(salt || key_kind || key_value)`: deterministic for a fixed salt,
14//! identical across restarts and across replicas sharing config, needing no coordination, and
15//! leaking nothing — the same salted-hash discipline [`crate::features::repo_fingerprint`] uses.
16
17use serde::Deserialize;
18
19/// Buckets per rollout space. 10 000 gives two decimal places of percent, which is finer than
20/// any operator needs and keeps the modulo bias negligible against a 32-bit draw.
21pub const BUCKETS: u32 = 10_000;
22
23/// Which stable identity a rollout holds constant.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
25#[serde(rename_all = "lowercase")]
26pub enum RolloutKey {
27 /// The `x-firstpass-session` header — a whole agent run moves together.
28 ///
29 /// The default, because a conversation is the unit a user experiences, so it is the unit
30 /// that should be held constant. Falls back to [`RolloutKey::Request`] when absent.
31 #[default]
32 Session,
33 /// The request's own identity. For stateless single-shot traffic with no session.
34 Request,
35 /// The tenant — for hosted deployments ramping tenant by tenant.
36 Tenant,
37}
38
39impl RolloutKey {
40 /// Domain-separator tag mixed into the hash, so the same value under two different key kinds
41 /// lands in unrelated buckets (a tenant id that happens to equal a session id must not
42 /// inherit its assignment).
43 #[must_use]
44 pub const fn tag(self) -> &'static str {
45 match self {
46 Self::Session => "session",
47 Self::Request => "request",
48 Self::Tenant => "tenant",
49 }
50 }
51}
52
53/// Rollout settings for a route. Absent means "all matched traffic", i.e. today's behaviour.
54#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
55#[serde(deny_unknown_fields)]
56pub struct Rollout {
57 /// Percent of matched traffic that actually enforces, in `[0, 100]`. The remainder takes the
58 /// ordinary observe path. Rollout only ever *subtracts* from what `mode` already allows.
59 pub percent: f64,
60 /// Which identity is held constant.
61 #[serde(default)]
62 pub key: RolloutKey,
63}
64
65impl Rollout {
66 /// Whether this rollout is configured coherently.
67 ///
68 /// # Errors
69 /// If `percent` is not finite or falls outside `[0, 100]`.
70 pub fn validate(&self) -> Result<(), String> {
71 if !self.percent.is_finite() || !(0.0..=100.0).contains(&self.percent) {
72 return Err(format!(
73 "route.rollout.percent must be finite and within [0, 100], got {}",
74 self.percent
75 ));
76 }
77 Ok(())
78 }
79}
80
81/// Shadow scoring settings for a route (ADR 0009 D2).
82///
83/// Observe mode records what traffic looked like; it never runs the ladder, so it cannot answer
84/// the one question it exists for — *what would Firstpass have served, and what would it have
85/// cost?* Shadow answers that by scoring a sample of observed requests off the hot path.
86///
87/// It makes real model calls, so there is deliberately no default-on sample rate and a hard daily
88/// ceiling is required. A measurement that quietly runs up a bill is worse than no measurement.
89#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
90#[serde(deny_unknown_fields)]
91pub struct Shadow {
92 /// Fraction of observed requests scored counterfactually, in `[0, 1]`.
93 pub sample_rate: f64,
94 /// Hard ceiling on shadow spend per UTC day, in USD. Shadow stops when exhausted, and the
95 /// fact that it stopped is recorded — silently degrading a measurement is its own bug.
96 pub max_usd_per_day: f64,
97}
98
99impl Shadow {
100 /// Whether this shadow config is coherent.
101 ///
102 /// # Errors
103 /// If `sample_rate` is outside `[0, 1]` or `max_usd_per_day` is negative or non-finite.
104 pub fn validate(&self) -> Result<(), String> {
105 if !self.sample_rate.is_finite() || !(0.0..=1.0).contains(&self.sample_rate) {
106 return Err(format!(
107 "route.shadow.sample_rate must be finite and within [0, 1], got {}",
108 self.sample_rate
109 ));
110 }
111 if !self.max_usd_per_day.is_finite() || self.max_usd_per_day < 0.0 {
112 return Err(format!(
113 "route.shadow.max_usd_per_day must be finite and >= 0, got {}",
114 self.max_usd_per_day
115 ));
116 }
117 Ok(())
118 }
119
120 /// Whether this request is in the shadow sample.
121 ///
122 /// Uses the same salted bucketing as rollout, under its own domain tag, so shadow sampling is
123 /// deterministic and — critically — *independent* of the rollout arm. If the two shared a
124 /// hash, the shadow sample would be a biased subset of one arm and the projection it produces
125 /// would not describe the traffic an operator is about to enforce on.
126 #[must_use]
127 pub fn sampled(&self, salt: &str, key_value: &str) -> bool {
128 if self.sample_rate <= 0.0 {
129 return false;
130 }
131 if self.sample_rate >= 1.0 {
132 return true;
133 }
134 use sha2::{Digest, Sha256};
135 let mut h = Sha256::new();
136 h.update(salt.as_bytes());
137 h.update([0u8]);
138 h.update(b"shadow"); // distinct tag: shadow sampling must not correlate with the arm
139 h.update([0u8]);
140 h.update(key_value.as_bytes());
141 let d = h.finalize();
142 let bucket = u32::from_be_bytes([d[0], d[1], d[2], d[3]]) % BUCKETS;
143 #[expect(
144 clippy::cast_possible_truncation,
145 clippy::cast_sign_loss,
146 reason = "sample_rate is validated finite in [0,1]"
147 )]
148 let cutoff = (self.sample_rate * f64::from(BUCKETS)) as u32;
149 bucket < cutoff
150 }
151}
152
153/// The bucket `key_value` falls in, within `0..`[`BUCKETS`].
154///
155/// Deterministic for a fixed `salt`. The salt is the per-deployment secret already used for
156/// feature fingerprints, so bucket assignment cannot be reconstructed — or gamed — by a caller
157/// who can choose their own session id.
158#[must_use]
159pub fn bucket_of(salt: &str, key: RolloutKey, key_value: &str) -> u32 {
160 use sha2::{Digest, Sha256};
161 let mut h = Sha256::new();
162 h.update(salt.as_bytes());
163 h.update([0u8]); // separator: salt||tag must not collide with a different split
164 h.update(key.tag().as_bytes());
165 h.update([0u8]);
166 h.update(key_value.as_bytes());
167 let d = h.finalize();
168 u32::from_be_bytes([d[0], d[1], d[2], d[3]]) % BUCKETS
169}
170
171/// Whether a bucket is inside a `percent` rollout.
172///
173/// Strictly `<` so that `percent = 0` enforces nothing and `percent = 100` enforces everything —
174/// both endpoints exact, which matters because "ramp to 0" is how an operator backs out.
175#[must_use]
176pub fn in_rollout(bucket: u32, percent: f64) -> bool {
177 if percent <= 0.0 {
178 return false;
179 }
180 if percent >= 100.0 {
181 return true;
182 }
183 #[expect(
184 clippy::cast_possible_truncation,
185 clippy::cast_sign_loss,
186 reason = "percent is validated finite in [0,100]; the product is within u32"
187 )]
188 let cutoff = (percent / 100.0 * f64::from(BUCKETS)) as u32;
189 bucket < cutoff
190}
191
192/// A stable identity for one request, for use as bucketing input when there is no session to key
193/// on. Hashes the request bytes, so the same request always lands in the same arm.
194///
195/// The digest is truncated and the input is never retained — this exists so rollout can bucket
196/// stateless traffic without Firstpass holding on to prompt content.
197#[must_use]
198pub fn request_identity(body: &[u8]) -> String {
199 use sha2::{Digest, Sha256};
200 let mut h = Sha256::new();
201 h.update(body);
202 hex::encode(&h.finalize()[..8])
203}
204
205/// The decision recorded on the trace, so an auditor can re-derive which arm a request was in.
206///
207/// Without this a mixed-population receipt log cannot be split back into the enforced and
208/// observed arms, and every savings or failure figure computed over it silently blends two
209/// different regimes.
210#[derive(Debug, Clone, Copy, PartialEq)]
211pub struct RolloutDecision {
212 /// Configured percent at decision time.
213 pub percent: f64,
214 /// Which identity was held constant.
215 pub key: RolloutKey,
216 /// The bucket this request landed in.
217 pub bucket: u32,
218 /// Whether it enforced.
219 pub enforced: bool,
220}
221
222/// Decide the arm for one request.
223#[must_use]
224pub fn decide(salt: &str, rollout: &Rollout, key_value: &str) -> RolloutDecision {
225 let bucket = bucket_of(salt, rollout.key, key_value);
226 RolloutDecision {
227 percent: rollout.percent,
228 key: rollout.key,
229 bucket,
230 enforced: in_rollout(bucket, rollout.percent),
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 /// The property the whole design rests on: the same key always lands in the same arm, so a
239 /// conversation cannot flip mid-thread and the served population stays a stable sample.
240 #[test]
241 fn assignment_is_stable_for_a_key() {
242 let r = Rollout {
243 percent: 5.0,
244 key: RolloutKey::Session,
245 };
246 let first = decide("salt-a", &r, "session-123");
247 for _ in 0..1000 {
248 assert_eq!(decide("salt-a", &r, "session-123"), first);
249 }
250 }
251
252 /// Different salts must reshuffle, or two deployments would enforce on exactly the same
253 /// sessions and a caller who learned one deployment's arm would know the other's.
254 #[test]
255 fn salt_reshuffles_assignment() {
256 let differing = (0..200)
257 .filter(|i| {
258 let k = format!("session-{i}");
259 bucket_of("salt-a", RolloutKey::Session, &k)
260 != bucket_of("salt-b", RolloutKey::Session, &k)
261 })
262 .count();
263 assert!(
264 differing > 190,
265 "salt barely changed assignment: {differing}/200"
266 );
267 }
268
269 /// The same value under two key kinds must not inherit one assignment.
270 #[test]
271 fn key_kind_is_domain_separated() {
272 let v = "same-identifier";
273 let s = bucket_of("salt", RolloutKey::Session, v);
274 let t = bucket_of("salt", RolloutKey::Tenant, v);
275 let q = bucket_of("salt", RolloutKey::Request, v);
276 assert!(s != t || t != q, "key kinds collapsed to one bucket");
277 }
278
279 /// A skewed hash would silently ramp to the wrong percentage — the operator would ask for 10%
280 /// and get 3%, and only notice through a savings number that never moved.
281 #[test]
282 fn buckets_are_roughly_uniform() {
283 for pct in [1.0, 5.0, 10.0, 50.0] {
284 let r = Rollout {
285 percent: pct,
286 key: RolloutKey::Session,
287 };
288 let n = 20_000;
289 let hits = (0..n)
290 .filter(|i| decide("salt", &r, &format!("s{i}")).enforced)
291 .count();
292 let observed = hits as f64 / f64::from(n) * 100.0;
293 assert!(
294 (observed - pct).abs() < 1.0,
295 "{pct}% rollout produced {observed:.2}% of {n} keys"
296 );
297 }
298 }
299
300 /// Both endpoints must be exact: 0 is how an operator backs out, 100 is full enforcement.
301 #[test]
302 fn endpoints_are_exact() {
303 for i in 0..500 {
304 let k = format!("s{i}");
305 assert!(
306 !decide(
307 "salt",
308 &Rollout {
309 percent: 0.0,
310 key: RolloutKey::Session
311 },
312 &k
313 )
314 .enforced
315 );
316 assert!(
317 decide(
318 "salt",
319 &Rollout {
320 percent: 100.0,
321 key: RolloutKey::Session
322 },
323 &k
324 )
325 .enforced
326 );
327 }
328 }
329
330 /// Ramping up must never eject a key that was already enforcing — an operator going 5% → 25%
331 /// expects to add traffic, not to reshuffle who is in the experiment.
332 #[test]
333 fn ramping_up_is_monotonic() {
334 let keys: Vec<String> = (0..2000).map(|i| format!("s{i}")).collect();
335 let arm = |pct: f64| -> Vec<bool> {
336 keys.iter()
337 .map(|k| {
338 decide(
339 "salt",
340 &Rollout {
341 percent: pct,
342 key: RolloutKey::Session,
343 },
344 k,
345 )
346 .enforced
347 })
348 .collect()
349 };
350 let (small, large) = (arm(5.0), arm(25.0));
351 for (i, (s, l)) in small.iter().zip(&large).enumerate() {
352 assert!(!*s || *l, "key {i} enforced at 5% but not at 25%");
353 }
354 }
355
356 #[test]
357 fn validate_rejects_impossible_percentages() {
358 for bad in [-0.1, 100.1, f64::NAN, f64::INFINITY] {
359 assert!(
360 Rollout {
361 percent: bad,
362 key: RolloutKey::Session
363 }
364 .validate()
365 .is_err()
366 );
367 }
368 for good in [0.0, 0.01, 50.0, 100.0] {
369 assert!(
370 Rollout {
371 percent: good,
372 key: RolloutKey::Session
373 }
374 .validate()
375 .is_ok()
376 );
377 }
378 }
379
380 /// Shadow sampling must be INDEPENDENT of the rollout arm. If both hashed the same input
381 /// under the same tag, every shadow-sampled request would sit in the same arm, and the
382 /// projection would describe a biased subset of traffic rather than the traffic an operator
383 /// is about to enforce on.
384 #[test]
385 fn shadow_sampling_is_independent_of_the_rollout_arm() {
386 let roll = Rollout {
387 percent: 50.0,
388 key: RolloutKey::Session,
389 };
390 let shadow = Shadow {
391 sample_rate: 0.5,
392 max_usd_per_day: 5.0,
393 };
394 let (mut both, mut only_shadow, mut only_arm, mut neither) = (0, 0, 0, 0);
395 for i in 0..4000 {
396 let k = format!("s{i}");
397 match (
398 decide("salt", &roll, &k).enforced,
399 shadow.sampled("salt", &k),
400 ) {
401 (true, true) => both += 1,
402 (false, true) => only_shadow += 1,
403 (true, false) => only_arm += 1,
404 (false, false) => neither += 1,
405 }
406 }
407 // Independent 50/50 splits put ~25% in each cell; a shared hash would empty two of them.
408 for (name, n) in [
409 ("both", both),
410 ("shadow-only", only_shadow),
411 ("arm-only", only_arm),
412 ("neither", neither),
413 ] {
414 let pct = f64::from(n) / 4000.0 * 100.0;
415 assert!(
416 (pct - 25.0).abs() < 3.0,
417 "{name} cell held {pct:.1}% — shadow sampling correlates with the rollout arm"
418 );
419 }
420 }
421
422 #[test]
423 fn shadow_sample_rate_endpoints_are_exact() {
424 for i in 0..300 {
425 let k = format!("s{i}");
426 assert!(
427 !Shadow {
428 sample_rate: 0.0,
429 max_usd_per_day: 1.0
430 }
431 .sampled("salt", &k)
432 );
433 assert!(
434 Shadow {
435 sample_rate: 1.0,
436 max_usd_per_day: 1.0
437 }
438 .sampled("salt", &k)
439 );
440 }
441 }
442
443 #[test]
444 fn shadow_validate_rejects_impossible_settings() {
445 for bad in [-0.1, 1.1, f64::NAN] {
446 assert!(
447 Shadow {
448 sample_rate: bad,
449 max_usd_per_day: 1.0
450 }
451 .validate()
452 .is_err()
453 );
454 }
455 for bad in [-1.0, f64::INFINITY] {
456 assert!(
457 Shadow {
458 sample_rate: 0.1,
459 max_usd_per_day: bad
460 }
461 .validate()
462 .is_err()
463 );
464 }
465 assert!(
466 Shadow {
467 sample_rate: 0.1,
468 max_usd_per_day: 5.0
469 }
470 .validate()
471 .is_ok()
472 );
473 }
474}