Skip to main content

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}