Skip to main content

lean_ctx/core/eval_ab/
routing_eval.rs

1//! Routing off-vs-on savings proof (enterprise#21).
2//!
3//! Answers "does the active router (enterprise#13) produce *real*, auditable
4//! savings?" the same way the context A/B answers the quality question —
5//! deterministically, at real list prices, over real queries:
6//!
7//! * **off-arm**: every task is served by the model the client requested.
8//! * **on-arm**: the task's last user query runs through the *production*
9//!   classifier ([`classify`] → [`route_intent`]) and the configured
10//!   [`RoutingRules`] — exactly the logic the proxy applies in-flight — and is
11//!   priced at the model the router selects.
12//!
13//! The savings claim is a pure **rate-card delta**: `input_rate(requested) −
14//! input_rate(serving)` per routed task, priced from the shared
15//! [`ModelPricing`] table (real provider list prices; enterprise#14). No token
16//! counts are invented — absolute USD amounts come from the usage ledger
17//! (enterprise#19), which applies the same formula to *measured*
18//! `usage_events` rows (`routed_from` × real input tokens). This eval proves
19//! the mechanism and the classification distribution; the ledger supplies the
20//! volumes.
21//!
22//! Everything here is a deterministic function of (suite, rules, pricing
23//! table): the classifier is lexical, the price table is embedded, and the
24//! report digest is byte-stable (#498) — so the artifact is reproducible
25//! evidence, not a demo.
26
27use serde::{Deserialize, Serialize};
28
29use crate::core::config::{RoutingRules, parse_route_target};
30use crate::core::gain::model_pricing::{ModelPricing, PricingMatchKind};
31use crate::core::intent_engine::{classify, route_intent};
32
33use super::suite::EvalSuite;
34
35/// Configuration for one routing off-vs-on run.
36#[derive(Debug, Clone)]
37pub struct RoutingEvalConfig {
38    /// The model the off-arm assumes every request targets — the org's
39    /// day-to-day default (e.g. the counterfactual `reference_model`).
40    pub requested_model: String,
41    /// The rule set under test — normally the deployment's `[proxy.routing]`.
42    pub rules: RoutingRules,
43}
44
45/// One task's routing decision + rate delta.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct RoutingTaskRecord {
48    pub task_id: String,
49    /// Intent tier the production classifier assigned (`fast|standard|premium`).
50    pub tier: String,
51    /// Model serving the on-arm (= `requested` when the router kept it).
52    pub serving_model: String,
53    /// True when the router changed the model (alias or tier hit).
54    pub routed: bool,
55    /// List input rate (USD/MTok) of the requested model.
56    pub requested_input_rate: f64,
57    /// List input rate (USD/MTok) of the serving model.
58    pub serving_input_rate: f64,
59    /// Rate-card saving per 1M input tokens for this task (0 when kept).
60    pub input_rate_saving_per_mtok: f64,
61}
62
63/// Deterministic off-vs-on routing report — the `savings_ledger`'s
64/// attribution witness for the ROUTE mechanism.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct RoutingEvalReport {
67    pub suite: String,
68    pub requested_model: String,
69    pub records: Vec<RoutingTaskRecord>,
70    pub routed_count: usize,
71    pub kept_count: usize,
72    /// Tasks classified premium that the rules downgraded anyway. The gate
73    /// requires 0: premium work is never silently downgraded (enterprise#13).
74    pub premium_downgrades: usize,
75    /// Mean rate saving per 1M input tokens across *all* tasks (kept = 0).
76    pub mean_input_rate_saving_per_mtok: f64,
77}
78
79impl RoutingEvalReport {
80    /// Canonical JSON for artifacts and digests.
81    ///
82    /// # Panics
83    /// Only if serde serialization of the report itself fails (plain data).
84    #[must_use]
85    pub fn to_json(&self) -> String {
86        serde_json::to_string_pretty(self).expect("routing report serializes")
87    }
88
89    /// Byte-stable digest over the canonical JSON (#498).
90    #[must_use]
91    pub fn determinism_digest(&self) -> String {
92        super::sha256_hex(self.to_json().as_bytes())
93    }
94
95    /// True when the safety gate holds: routing never downgraded premium work.
96    #[must_use]
97    pub fn gate_passes(&self) -> bool {
98        self.premium_downgrades == 0
99    }
100
101    /// Human-readable side-by-side summary.
102    #[must_use]
103    pub fn render(&self) -> String {
104        use std::fmt::Write;
105        let mut out = String::new();
106        let _ = writeln!(
107            out,
108            "routing off-vs-on — suite '{}', requested model '{}'",
109            self.suite, self.requested_model
110        );
111        let _ = writeln!(
112            out,
113            "{:<28} {:<9} {:<26} {:>12}",
114            "task", "tier", "serving model", "Δ USD/MTok-in"
115        );
116        for r in &self.records {
117            let _ = writeln!(
118                out,
119                "{:<28} {:<9} {:<26} {:>12.3}",
120                r.task_id, r.tier, r.serving_model, r.input_rate_saving_per_mtok
121            );
122        }
123        let _ = writeln!(
124            out,
125            "\nrouted {}/{} tasks · mean saving {:.3} USD per 1M input tokens · premium downgrades: {}",
126            self.routed_count,
127            self.routed_count + self.kept_count,
128            self.mean_input_rate_saving_per_mtok,
129            self.premium_downgrades
130        );
131        out
132    }
133}
134
135/// The ROUTE-mechanism attribution formula shared with the savings ledger
136/// (enterprise#19): USD saved on `input_tokens` by serving `serving` instead
137/// of `requested`, at list input rates. Negative deltas (an upgrade) count as
138/// negative savings — the ledger must not hide regressions.
139#[must_use]
140pub fn routing_saving_usd(
141    pricing: &ModelPricing,
142    requested: &str,
143    serving: &str,
144    input_tokens: u64,
145) -> f64 {
146    let from = pricing.quote(Some(requested)).cost.input_per_m;
147    let to = pricing.quote(Some(serving)).cost.input_per_m;
148    #[allow(clippy::cast_precision_loss)]
149    let tokens = input_tokens as f64;
150    (from - to) / 1_000_000.0 * tokens
151}
152
153/// Runs the routing off-vs-on comparison over a suite's real task prompts.
154///
155/// Mirrors the proxy's decision order (`proxy::routing::route_request`):
156/// alias on the requested model first, then the intent tier of the query.
157/// Unknown/unpriced targets keep the task on the requested model — the eval
158/// must not claim savings the proxy would not realize.
159///
160/// # Errors
161/// When the rule set is inactive (nothing to evaluate) or the suite is empty.
162pub fn run_routing_eval(
163    suite: &EvalSuite,
164    suite_name: &str,
165    pricing: &ModelPricing,
166    cfg: &RoutingEvalConfig,
167) -> anyhow::Result<RoutingEvalReport> {
168    if !cfg.rules.is_active() {
169        anyhow::bail!(
170            "routing rules are inactive (enabled + at least one alias/tier required) — \
171             configure [proxy.routing] or pass explicit rules"
172        );
173    }
174    if suite.tasks.is_empty() {
175        anyhow::bail!("suite has no tasks");
176    }
177
178    let requested_quote = pricing.quote(Some(&cfg.requested_model));
179    let mut records = Vec::with_capacity(suite.tasks.len());
180    let mut premium_downgrades = 0usize;
181
182    for task in &suite.tasks {
183        let query = task.query();
184        let classification = classify(query);
185        let tier = route_intent(query, &classification).model_tier;
186        let tier_label = tier.as_str().to_string();
187
188        // Alias first, then tier — the proxy's exact precedence.
189        let target = cfg
190            .rules
191            .aliases
192            .get(&cfg.requested_model)
193            .cloned()
194            .or_else(|| {
195                cfg.rules
196                    .tiers
197                    .get(&tier_label)
198                    .map(|t| t.trim().to_string())
199                    .filter(|t| !t.is_empty())
200            });
201
202        let serving_model = target
203            .as_deref()
204            .and_then(parse_route_target)
205            .map(|(_, model)| model.to_string())
206            .filter(|m| m != &cfg.requested_model)
207            // Unpriced target = the fallback quote → no provable saving; keep.
208            .filter(|m| pricing.quote(Some(m)).match_kind != PricingMatchKind::Fallback);
209
210        let routed = serving_model.is_some();
211        if routed && tier_label == "premium" {
212            premium_downgrades += 1;
213        }
214        let serving_model = serving_model.unwrap_or_else(|| cfg.requested_model.clone());
215        let serving_rate = pricing.quote(Some(&serving_model)).cost.input_per_m;
216
217        records.push(RoutingTaskRecord {
218            task_id: task.id.clone(),
219            tier: tier_label,
220            serving_model,
221            routed,
222            requested_input_rate: requested_quote.cost.input_per_m,
223            serving_input_rate: serving_rate,
224            input_rate_saving_per_mtok: requested_quote.cost.input_per_m - serving_rate,
225        });
226    }
227
228    let routed_count = records.iter().filter(|r| r.routed).count();
229    #[allow(clippy::cast_precision_loss)]
230    let mean = records
231        .iter()
232        .map(|r| r.input_rate_saving_per_mtok)
233        .sum::<f64>()
234        / records.len() as f64;
235
236    Ok(RoutingEvalReport {
237        suite: suite_name.to_string(),
238        requested_model: cfg.requested_model.clone(),
239        records,
240        routed_count,
241        kept_count: suite.tasks.len() - routed_count,
242        premium_downgrades,
243        mean_input_rate_saving_per_mtok: mean,
244    })
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    fn suite_with(prompts: &[(&str, &str)]) -> (tempfile::TempDir, EvalSuite) {
252        let root = tempfile::tempdir().unwrap();
253        let ws = root.path().join("corpus");
254        std::fs::create_dir_all(&ws).unwrap();
255        std::fs::write(ws.join("readme.md"), "fixture corpus").unwrap();
256        let raw = prompts
257            .iter()
258            .map(|(id, prompt)| {
259                format!(
260                    r#"{{"id":"{id}","domain":"qa","prompt":"{prompt}","workspace":"corpus","answers":["x"]}}"#
261                )
262            })
263            .collect::<Vec<_>>()
264            .join("\n");
265        let suite = EvalSuite::parse(&raw, root.path().to_path_buf()).unwrap();
266        (root, suite)
267    }
268
269    fn rules(tiers: &[(&str, &str)]) -> RoutingRules {
270        RoutingRules {
271            enabled: Some(true),
272            aliases: std::collections::BTreeMap::default(),
273            tiers: tiers
274                .iter()
275                .map(|(k, v)| (k.to_string(), v.to_string()))
276                .collect(),
277        }
278    }
279
280    #[test]
281    fn off_vs_on_routes_cheap_tiers_and_never_premium() {
282        let (_root, suite) = suite_with(&[
283            (
284                "explore-q",
285                "how does the session cache work in this project?",
286            ),
287            (
288                "premium-gen",
289                "implement a new distributed lock manager with leader election and fencing tokens",
290            ),
291        ]);
292        let cfg = RoutingEvalConfig {
293            requested_model: "claude-opus-4.5".into(),
294            rules: rules(&[("fast", "foundry:Phi-4"), ("standard", "foundry:Phi-4")]),
295        };
296        let pricing = ModelPricing::embedded();
297        let report = run_routing_eval(&suite, "fixture", &pricing, &cfg).unwrap();
298
299        assert!(report.gate_passes(), "premium must never be downgraded");
300        assert_eq!(report.routed_count, 1, "the explore query routes");
301        let routed = report.records.iter().find(|r| r.routed).unwrap();
302        // claude-opus-4.5 $5.00/MTok − phi-4 $0.125/MTok = $4.875 per MTok input.
303        assert!((routed.input_rate_saving_per_mtok - 4.875).abs() < 1e-9);
304        let premium = &report.records[1];
305        assert_eq!(premium.tier, "premium");
306        assert!(!premium.routed);
307        assert_eq!(premium.input_rate_saving_per_mtok, 0.0);
308
309        // Byte-stable evidence (#498): identical inputs → identical digest.
310        let again = run_routing_eval(&suite, "fixture", &pricing, &cfg).unwrap();
311        assert_eq!(report.determinism_digest(), again.determinism_digest());
312    }
313
314    #[test]
315    fn unpriced_target_claims_no_saving() {
316        let (_root, suite) = suite_with(&[("q", "how does the config loader work?")]);
317        let cfg = RoutingEvalConfig {
318            requested_model: "claude-opus-4.5".into(),
319            rules: rules(&[
320                ("fast", "foundry:totally-unknown-model"),
321                ("standard", "foundry:totally-unknown-model"),
322            ]),
323        };
324        let report = run_routing_eval(&suite, "s", &ModelPricing::embedded(), &cfg).unwrap();
325        assert_eq!(report.routed_count, 0, "unpriced target must not route");
326        assert_eq!(report.mean_input_rate_saving_per_mtok, 0.0);
327    }
328
329    #[test]
330    fn inactive_rules_error_instead_of_empty_claim() {
331        let (_root, suite) = suite_with(&[("q", "anything")]);
332        let cfg = RoutingEvalConfig {
333            requested_model: "gpt-5.4".into(),
334            rules: RoutingRules::default(),
335        };
336        assert!(run_routing_eval(&suite, "s", &ModelPricing::embedded(), &cfg).is_err());
337    }
338
339    #[test]
340    fn ledger_formula_prices_measured_tokens() {
341        let pricing = ModelPricing::embedded();
342        // 2M input tokens routed opus→phi-4: 2 × (5.00 − 0.125) = 9.75 USD.
343        let usd = routing_saving_usd(&pricing, "claude-opus-4.5", "phi-4", 2_000_000);
344        assert!((usd - 9.75).abs() < 1e-9);
345        // Upgrades are negative savings — never hidden.
346        assert!(routing_saving_usd(&pricing, "phi-4", "claude-opus-4.5", 1_000_000) < 0.0);
347    }
348}