1use 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#[derive(Debug, Clone)]
39pub struct RoutingEvalConfig {
40 pub requested_model: String,
43 pub rules: RoutingRules,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct RoutingTaskRecord {
50 pub task_id: String,
51 pub tier: String,
53 pub serving_model: String,
55 pub routed: bool,
57 pub requested_input_rate: f64,
59 pub serving_input_rate: f64,
61 pub input_rate_saving_per_mtok: f64,
63}
64
65#[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 pub premium_downgrades: usize,
77 pub mean_input_rate_saving_per_mtok: f64,
79}
80
81impl RoutingEvalReport {
82 #[must_use]
87 pub fn to_json(&self) -> String {
88 serde_json::to_string_pretty(self).expect("routing report serializes")
89 }
90
91 #[must_use]
93 pub fn determinism_digest(&self) -> String {
94 super::sha256_hex(self.to_json().as_bytes())
95 }
96
97 #[must_use]
99 pub fn gate_passes(&self) -> bool {
100 self.premium_downgrades == 0
101 }
102
103 #[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#[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
155pub 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 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 .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
249pub 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 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 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 let usd = routing_saving_usd(&pricing, "claude-opus-4.5", "phi-4", 2_000_000);
401 assert!((usd - 9.75).abs() < 1e-9);
402 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}