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};
28use std::path::Path;
29
30use crate::core::config::{RoutingRules, parse_route_target};
31use crate::core::gain::model_pricing::{ModelPricing, PricingMatchKind};
32use crate::core::intent_engine::{classify, route_intent};
33use crate::core::ocla::types::{ExperimentRequest, ExperimentResult};
34
35use super::suite::EvalSuite;
36
37/// Configuration for one routing off-vs-on run.
38#[derive(Debug, Clone)]
39pub struct RoutingEvalConfig {
40    /// The model the off-arm assumes every request targets — the org's
41    /// day-to-day default (e.g. the counterfactual `reference_model`).
42    pub requested_model: String,
43    /// The rule set under test — normally the deployment's `[proxy.routing]`.
44    pub rules: RoutingRules,
45}
46
47/// One task's routing decision + rate delta.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct RoutingTaskRecord {
50    pub task_id: String,
51    /// Intent tier the production classifier assigned (`fast|standard|premium`).
52    pub tier: String,
53    /// Model serving the on-arm (= `requested` when the router kept it).
54    pub serving_model: String,
55    /// True when the router changed the model (alias or tier hit).
56    pub routed: bool,
57    /// List input rate (USD/MTok) of the requested model.
58    pub requested_input_rate: f64,
59    /// List input rate (USD/MTok) of the serving model.
60    pub serving_input_rate: f64,
61    /// Rate-card saving per 1M input tokens for this task (0 when kept).
62    pub input_rate_saving_per_mtok: f64,
63}
64
65/// Deterministic off-vs-on routing report — the `savings_ledger`'s
66/// attribution witness for the ROUTE mechanism.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct RoutingEvalReport {
69    pub suite: String,
70    pub requested_model: String,
71    pub records: Vec<RoutingTaskRecord>,
72    pub routed_count: usize,
73    pub kept_count: usize,
74    /// Tasks classified premium that the rules downgraded anyway. The gate
75    /// requires 0: premium work is never silently downgraded (enterprise#13).
76    pub premium_downgrades: usize,
77    /// Mean rate saving per 1M input tokens across *all* tasks (kept = 0).
78    pub mean_input_rate_saving_per_mtok: f64,
79}
80
81impl RoutingEvalReport {
82    /// Canonical JSON for artifacts and digests.
83    ///
84    /// # Panics
85    /// Only if serde serialization of the report itself fails (plain data).
86    #[must_use]
87    pub fn to_json(&self) -> String {
88        serde_json::to_string_pretty(self).expect("routing report serializes")
89    }
90
91    /// Byte-stable digest over the canonical JSON (#498).
92    #[must_use]
93    pub fn determinism_digest(&self) -> String {
94        super::sha256_hex(self.to_json().as_bytes())
95    }
96
97    /// True when the safety gate holds: routing never downgraded premium work.
98    #[must_use]
99    pub fn gate_passes(&self) -> bool {
100        self.premium_downgrades == 0
101    }
102
103    /// Human-readable side-by-side summary.
104    #[must_use]
105    pub fn render(&self) -> String {
106        use std::fmt::Write;
107        let mut out = String::new();
108        let _ = writeln!(
109            out,
110            "routing off-vs-on — suite '{}', requested model '{}'",
111            self.suite, self.requested_model
112        );
113        let _ = writeln!(
114            out,
115            "{:<28} {:<9} {:<26} {:>12}",
116            "task", "tier", "serving model", "Δ USD/MTok-in"
117        );
118        for r in &self.records {
119            let _ = writeln!(
120                out,
121                "{:<28} {:<9} {:<26} {:>12.3}",
122                r.task_id, r.tier, r.serving_model, r.input_rate_saving_per_mtok
123            );
124        }
125        let _ = writeln!(
126            out,
127            "\nrouted {}/{} tasks · mean saving {:.3} USD per 1M input tokens · premium downgrades: {}",
128            self.routed_count,
129            self.routed_count + self.kept_count,
130            self.mean_input_rate_saving_per_mtok,
131            self.premium_downgrades
132        );
133        out
134    }
135}
136
137/// The ROUTE-mechanism attribution formula shared with the savings ledger
138/// (enterprise#19): USD saved on `input_tokens` by serving `serving` instead
139/// of `requested`, at list input rates. Negative deltas (an upgrade) count as
140/// negative savings — the ledger must not hide regressions.
141#[must_use]
142pub fn routing_saving_usd(
143    pricing: &ModelPricing,
144    requested: &str,
145    serving: &str,
146    input_tokens: u64,
147) -> f64 {
148    let from = pricing.quote(Some(requested)).cost.input_per_m;
149    let to = pricing.quote(Some(serving)).cost.input_per_m;
150    #[allow(clippy::cast_precision_loss)]
151    let tokens = input_tokens as f64;
152    (from - to) / 1_000_000.0 * tokens
153}
154
155/// Runs the routing off-vs-on comparison over a suite's real task prompts.
156///
157/// Mirrors the proxy's decision order (`proxy::routing::route_request`):
158/// alias on the requested model first, then the intent tier of the query.
159/// Unknown/unpriced targets keep the task on the requested model — the eval
160/// must not claim savings the proxy would not realize.
161///
162/// # Errors
163/// When the rule set is inactive (nothing to evaluate) or the suite is empty.
164pub fn run_routing_eval(
165    suite: &EvalSuite,
166    suite_name: &str,
167    pricing: &ModelPricing,
168    cfg: &RoutingEvalConfig,
169) -> anyhow::Result<RoutingEvalReport> {
170    if !cfg.rules.is_active() {
171        anyhow::bail!(
172            "routing rules are inactive (enabled + at least one alias/tier required) — \
173             configure [proxy.routing] or pass explicit rules"
174        );
175    }
176    if suite.tasks.is_empty() {
177        anyhow::bail!("suite has no tasks");
178    }
179
180    let requested_quote = pricing.quote(Some(&cfg.requested_model));
181    let mut records = Vec::with_capacity(suite.tasks.len());
182    let mut premium_downgrades = 0usize;
183
184    for task in &suite.tasks {
185        let query = task.query();
186        let classification = classify(query);
187        let tier = route_intent(query, &classification).model_tier;
188        let tier_label = tier.as_str().to_string();
189
190        // Alias first, then tier — the proxy's exact precedence.
191        let target = cfg
192            .rules
193            .aliases
194            .get(&cfg.requested_model)
195            .cloned()
196            .or_else(|| {
197                cfg.rules
198                    .tiers
199                    .get(&tier_label)
200                    .map(|t| t.trim().to_string())
201                    .filter(|t| !t.is_empty())
202            });
203
204        let serving_model = target
205            .as_deref()
206            .and_then(parse_route_target)
207            .map(|(_, model)| model.to_string())
208            .filter(|m| m != &cfg.requested_model)
209            // Unpriced target = the fallback quote → no provable saving; keep.
210            .filter(|m| pricing.quote(Some(m)).match_kind != PricingMatchKind::Fallback);
211
212        let routed = serving_model.is_some();
213        if routed && tier_label == "premium" {
214            premium_downgrades += 1;
215        }
216        let serving_model = serving_model.unwrap_or_else(|| cfg.requested_model.clone());
217        let serving_rate = pricing.quote(Some(&serving_model)).cost.input_per_m;
218
219        records.push(RoutingTaskRecord {
220            task_id: task.id.clone(),
221            tier: tier_label,
222            serving_model,
223            routed,
224            requested_input_rate: requested_quote.cost.input_per_m,
225            serving_input_rate: serving_rate,
226            input_rate_saving_per_mtok: requested_quote.cost.input_per_m - serving_rate,
227        });
228    }
229
230    let routed_count = records.iter().filter(|r| r.routed).count();
231    #[allow(clippy::cast_precision_loss)]
232    let mean = records
233        .iter()
234        .map(|r| r.input_rate_saving_per_mtok)
235        .sum::<f64>()
236        / records.len() as f64;
237
238    Ok(RoutingEvalReport {
239        suite: suite_name.to_string(),
240        requested_model: cfg.requested_model.clone(),
241        records,
242        routed_count,
243        kept_count: suite.tasks.len() - routed_count,
244        premium_downgrades,
245        mean_input_rate_saving_per_mtok: mean,
246    })
247}
248
249/// Production OCLA callsite for the routing A/B experiment.
250///
251/// `experiment_ref` identifies the NDJSON suite selected by the caller. The
252/// report digest becomes the outcome ref, so the OCLA result points at the
253/// exact deterministic evaluation instead of fabricating a completion token.
254pub fn run_routing_experiment(
255    request: &ExperimentRequest,
256    requested_model: &str,
257    rules: &RoutingRules,
258    pricing: &ModelPricing,
259) -> anyhow::Result<ExperimentResult> {
260    let suite_path = Path::new(&request.experiment_ref);
261    let suite = EvalSuite::load(suite_path)?;
262    let suite_name = suite_path.file_name().map_or_else(
263        || request.experiment_ref.clone(),
264        |name| name.to_string_lossy().into_owned(),
265    );
266    let report = run_routing_eval(
267        &suite,
268        &suite_name,
269        pricing,
270        &RoutingEvalConfig {
271            requested_model: requested_model.to_string(),
272            rules: rules.clone(),
273        },
274    )?;
275
276    Ok(ExperimentResult {
277        experiment_ref: request.experiment_ref.clone(),
278        outcome_ref: format!(
279            "outcome:{}:{}",
280            request.experiment_ref,
281            report.determinism_digest()
282        ),
283        rollback_ref: Some(format!("rollback:{}", request.cohort_ref)),
284    })
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    fn suite_with(prompts: &[(&str, &str)]) -> (tempfile::TempDir, EvalSuite) {
292        let root = tempfile::tempdir().unwrap();
293        let ws = root.path().join("corpus");
294        std::fs::create_dir_all(&ws).unwrap();
295        std::fs::write(ws.join("readme.md"), "fixture corpus").unwrap();
296        let raw = prompts
297            .iter()
298            .map(|(id, prompt)| {
299                format!(
300                    r#"{{"id":"{id}","domain":"qa","prompt":"{prompt}","workspace":"corpus","answers":["x"]}}"#
301                )
302            })
303            .collect::<Vec<_>>()
304            .join("\n");
305        let suite = EvalSuite::parse(&raw, root.path().to_path_buf()).unwrap();
306        (root, suite)
307    }
308
309    fn rules(tiers: &[(&str, &str)]) -> RoutingRules {
310        RoutingRules {
311            enabled: Some(true),
312            aliases: std::collections::BTreeMap::default(),
313            tiers: tiers
314                .iter()
315                .map(|(k, v)| (k.to_string(), v.to_string()))
316                .collect(),
317        }
318    }
319
320    fn experiment_request(suite: &std::path::Path) -> ExperimentRequest {
321        ExperimentRequest {
322            context: crate::core::ocla::types::OclaRequestContext {
323                request_id: "request-1".into(),
324                session_id: "session-1".into(),
325                agent_id: "agent-test".into(),
326                content_ref: "ref:test".into(),
327                tenant_id: None,
328                trace_id: "tr-unit".into(),
329            },
330            experiment_ref: suite.to_string_lossy().into_owned(),
331            cohort_ref: "cohort:treatment".into(),
332            holdout: None,
333            stop_conditions: None,
334        }
335    }
336
337    #[test]
338    fn off_vs_on_routes_cheap_tiers_and_never_premium() {
339        let (_root, suite) = suite_with(&[
340            (
341                "explore-q",
342                "how does the session cache work in this project?",
343            ),
344            (
345                "premium-gen",
346                "implement a new distributed lock manager with leader election and fencing tokens",
347            ),
348        ]);
349        let cfg = RoutingEvalConfig {
350            requested_model: "claude-opus-4.5".into(),
351            rules: rules(&[("fast", "foundry:Phi-4"), ("standard", "foundry:Phi-4")]),
352        };
353        let pricing = ModelPricing::embedded();
354        let report = run_routing_eval(&suite, "fixture", &pricing, &cfg).unwrap();
355
356        assert!(report.gate_passes(), "premium must never be downgraded");
357        assert_eq!(report.routed_count, 1, "the explore query routes");
358        let routed = report.records.iter().find(|r| r.routed).unwrap();
359        // claude-opus-4.5 $5.00/MTok − phi-4 $0.125/MTok = $4.875 per MTok input.
360        assert!((routed.input_rate_saving_per_mtok - 4.875).abs() < 1e-9);
361        let premium = &report.records[1];
362        assert_eq!(premium.tier, "premium");
363        assert!(!premium.routed);
364        assert_eq!(premium.input_rate_saving_per_mtok, 0.0);
365
366        // Byte-stable evidence (#498): identical inputs → identical digest.
367        let again = run_routing_eval(&suite, "fixture", &pricing, &cfg).unwrap();
368        assert_eq!(report.determinism_digest(), again.determinism_digest());
369    }
370
371    #[test]
372    fn unpriced_target_claims_no_saving() {
373        let (_root, suite) = suite_with(&[("q", "how does the config loader work?")]);
374        let cfg = RoutingEvalConfig {
375            requested_model: "claude-opus-4.5".into(),
376            rules: rules(&[
377                ("fast", "foundry:totally-unknown-model"),
378                ("standard", "foundry:totally-unknown-model"),
379            ]),
380        };
381        let report = run_routing_eval(&suite, "s", &ModelPricing::embedded(), &cfg).unwrap();
382        assert_eq!(report.routed_count, 0, "unpriced target must not route");
383        assert_eq!(report.mean_input_rate_saving_per_mtok, 0.0);
384    }
385
386    #[test]
387    fn inactive_rules_error_instead_of_empty_claim() {
388        let (_root, suite) = suite_with(&[("q", "anything")]);
389        let cfg = RoutingEvalConfig {
390            requested_model: "gpt-5.4".into(),
391            rules: RoutingRules::default(),
392        };
393        assert!(run_routing_eval(&suite, "s", &ModelPricing::embedded(), &cfg).is_err());
394    }
395
396    #[test]
397    fn ledger_formula_prices_measured_tokens() {
398        let pricing = ModelPricing::embedded();
399        // 2M input tokens routed opus→phi-4: 2 × (5.00 − 0.125) = 9.75 USD.
400        let usd = routing_saving_usd(&pricing, "claude-opus-4.5", "phi-4", 2_000_000);
401        assert!((usd - 9.75).abs() < 1e-9);
402        // Upgrades are negative savings — never hidden.
403        assert!(routing_saving_usd(&pricing, "phi-4", "claude-opus-4.5", 1_000_000) < 0.0);
404    }
405
406    #[test]
407    fn ocla_adapter_returns_evaluation_digest_and_rollback_ref() {
408        let root = tempfile::tempdir().unwrap();
409        let ws = root.path().join("corpus");
410        std::fs::create_dir_all(&ws).unwrap();
411        std::fs::write(ws.join("readme.md"), "fixture corpus").unwrap();
412        let suite_path = root.path().join("suite.ndjson");
413        std::fs::write(
414            &suite_path,
415            r#"{"id":"q","domain":"qa","prompt":"how does config work?","workspace":"corpus","answers":["config"]}"#,
416        )
417        .unwrap();
418
419        let result = run_routing_experiment(
420            &experiment_request(&suite_path),
421            "claude-opus-4.5",
422            &rules(&[("standard", "foundry:Phi-4")]),
423            &ModelPricing::embedded(),
424        )
425        .unwrap();
426
427        assert!(result.outcome_ref.starts_with("outcome:"));
428        assert_eq!(
429            result.rollback_ref.as_deref(),
430            Some("rollback:cohort:treatment")
431        );
432    }
433
434    #[test]
435    fn ocla_adapter_propagates_inactive_routing_rules() {
436        let (_root, suite) = suite_with(&[("q", "how does config work?")]);
437        let suite_path = suite.dir.join("suite.ndjson");
438        std::fs::write(
439            &suite_path,
440            r#"{"id":"q","domain":"qa","prompt":"how does config work?","workspace":"corpus","answers":["config"]}"#,
441        )
442        .unwrap();
443        let request = experiment_request(&suite_path);
444        let error = run_routing_experiment(
445            &request,
446            "claude-opus-4.5",
447            &RoutingRules::default(),
448            &ModelPricing::embedded(),
449        );
450        assert!(error.is_err());
451    }
452}