Skip to main content

firstpass_proxy/
shadow.rs

1//! Shadow-spend ledger (ADR 0009 D2).
2//!
3//! Shadow scoring makes **real model calls** to answer "what would Firstpass have served, and what
4//! would it have cost?" for traffic that is only being observed. That is the number an operator
5//! needs before flipping to enforce, and it is not free.
6//!
7//! So the ceiling is not advisory. This ledger is checked before every shadow evaluation and
8//! debited after it, and when a day's budget is exhausted shadow work stops and *says so* — a
9//! measurement that silently degrades is worse than one that never ran, because the operator
10//! keeps trusting a projection that has quietly stopped tracking their traffic.
11//!
12//! Scoped per (tenant, route) so one tenant's shadow spend cannot consume another's budget, which
13//! matters the moment this runs anywhere multi-tenant (ADR 0004).
14
15use std::collections::HashMap;
16use std::sync::Mutex;
17
18/// UTC day number, used as the reset boundary. A calendar day is the unit an operator budgets in.
19fn day_of(ts: jiff::Timestamp) -> i64 {
20    ts.as_second().div_euclid(86_400)
21}
22
23/// Why a shadow evaluation did not run. Recorded rather than swallowed.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Skip {
26    /// This request was not in the configured sample.
27    NotSampled,
28    /// The day's shadow budget is spent.
29    BudgetExhausted,
30}
31
32/// Per-(tenant, route) shadow spend for the current UTC day.
33#[derive(Debug, Default)]
34pub struct ShadowLedger {
35    inner: Mutex<HashMap<(String, usize), (i64, f64)>>, // key -> (day, spent_usd)
36}
37
38impl ShadowLedger {
39    /// A fresh ledger.
40    #[must_use]
41    pub fn new() -> Self {
42        Self::default()
43    }
44
45    /// Whether a shadow evaluation may start, given the day's ceiling.
46    ///
47    /// Checked *before* the call rather than after, because the point of a ceiling is to not spend
48    /// the money — discovering the overrun afterwards defeats it. The trade is that a single
49    /// evaluation can overshoot by its own cost; with per-call costs in fractions of a cent
50    /// against a dollar-scale daily cap that is immaterial, and erring toward one extra cheap call
51    /// is better than erring toward silently skipping measurements.
52    pub fn may_spend(
53        &self,
54        tenant: &str,
55        route_ix: usize,
56        max_usd_per_day: f64,
57        now: jiff::Timestamp,
58    ) -> bool {
59        if max_usd_per_day <= 0.0 {
60            return false;
61        }
62        let today = day_of(now);
63        let mut g = match self.inner.lock() {
64            Ok(g) => g,
65            // A poisoned lock must not take shadow down with it, and must not be treated as
66            // "budget available" either — refusing to spend is the safe direction.
67            Err(_) => return false,
68        };
69        let e = g
70            .entry((tenant.to_owned(), route_ix))
71            .or_insert((today, 0.0));
72        if e.0 != today {
73            *e = (today, 0.0); // new UTC day, fresh budget
74        }
75        e.1 < max_usd_per_day
76    }
77
78    /// Record what a completed shadow evaluation cost.
79    pub fn debit(&self, tenant: &str, route_ix: usize, usd: f64, now: jiff::Timestamp) {
80        if !usd.is_finite() || usd <= 0.0 {
81            return;
82        }
83        let today = day_of(now);
84        if let Ok(mut g) = self.inner.lock() {
85            let e = g
86                .entry((tenant.to_owned(), route_ix))
87                .or_insert((today, 0.0));
88            if e.0 != today {
89                *e = (today, 0.0);
90            }
91            e.1 += usd;
92        }
93    }
94
95    /// Spend so far today, for reporting.
96    #[must_use]
97    pub fn spent_today(&self, tenant: &str, route_ix: usize, now: jiff::Timestamp) -> f64 {
98        let today = day_of(now);
99        self.inner
100            .lock()
101            .ok()
102            .and_then(|g| g.get(&(tenant.to_owned(), route_ix)).copied())
103            .filter(|(d, _)| *d == today)
104            .map_or(0.0, |(_, spent)| spent)
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    fn ts(secs: i64) -> jiff::Timestamp {
113        jiff::Timestamp::from_second(secs).unwrap()
114    }
115
116    #[test]
117    fn spending_stops_at_the_ceiling() {
118        let l = ShadowLedger::new();
119        let now = ts(1_000_000);
120        assert!(
121            l.may_spend("t", 0, 1.00, now),
122            "fresh budget must allow the first call"
123        );
124        l.debit("t", 0, 0.60, now);
125        assert!(
126            l.may_spend("t", 0, 1.00, now),
127            "0.60 of 1.00 spent — still under"
128        );
129        l.debit("t", 0, 0.50, now);
130        assert!(
131            !l.may_spend("t", 0, 1.00, now),
132            "1.10 of 1.00 spent — must refuse, or the ceiling is decoration"
133        );
134    }
135
136    /// The budget is per UTC day; a new day must restore it, or shadow silently dies after the
137    /// first busy day and the operator's projection stops tracking without any signal.
138    #[test]
139    fn budget_resets_on_a_new_utc_day() {
140        let l = ShadowLedger::new();
141        let day1 = ts(86_400 * 100);
142        l.debit("t", 0, 5.0, day1);
143        assert!(!l.may_spend("t", 0, 1.0, day1));
144        let day2 = ts(86_400 * 101);
145        assert!(
146            l.may_spend("t", 0, 1.0, day2),
147            "new UTC day must restore the budget"
148        );
149        assert!((l.spent_today("t", 0, day2) - 0.0).abs() < f64::EPSILON);
150    }
151
152    /// One tenant must not be able to exhaust another's shadow budget.
153    #[test]
154    fn budgets_are_isolated_per_tenant_and_route() {
155        let l = ShadowLedger::new();
156        let now = ts(1_000_000);
157        l.debit("noisy", 0, 99.0, now);
158        assert!(!l.may_spend("noisy", 0, 1.0, now));
159        assert!(
160            l.may_spend("quiet", 0, 1.0, now),
161            "another tenant's budget was consumed"
162        );
163        assert!(
164            l.may_spend("noisy", 1, 1.0, now),
165            "another route's budget was consumed"
166        );
167    }
168
169    /// A zero or negative ceiling means "never", not "unlimited" — the failure direction of a
170    /// misconfigured budget must be to spend nothing.
171    #[test]
172    fn non_positive_ceiling_never_spends() {
173        let l = ShadowLedger::new();
174        let now = ts(1_000_000);
175        assert!(!l.may_spend("t", 0, 0.0, now));
176        assert!(!l.may_spend("t", 0, -5.0, now));
177    }
178
179    #[test]
180    fn nonsense_debits_are_ignored() {
181        let l = ShadowLedger::new();
182        let now = ts(1_000_000);
183        for bad in [f64::NAN, f64::INFINITY, -1.0, 0.0] {
184            l.debit("t", 0, bad, now);
185        }
186        assert!((l.spent_today("t", 0, now) - 0.0).abs() < f64::EPSILON);
187        assert!(l.may_spend("t", 0, 1.0, now));
188    }
189}