Skip to main content

auth_cloudflare/
policy.rs

1//! Policy - status, rank, and primary-agent eligibility for known models.
2//!
3//! This module owns the bundled policy record: the
4//! status, rank, default flag, and primary-agent eligibility of every model
5//! the project has an explicit opinion about. Unknown models default to
6//! `Available`; the policy never claims a model is ineligible unless a
7//! policy entry says so. The JSON contract serializes as snake_case so the
8//! CLI and the Hermes plugin can consume it without transformation.
9
10use serde::{Deserialize, Serialize};
11
12use crate::catalog::{ModelRecord, ModelRole, PricingPerMillion};
13use crate::health::{ModelVerification, VerificationStatus};
14use crate::{DEFAULT_MODEL, EXPERIMENTAL_MODEL, PREMIUM_CODING_MODEL, PREMIUM_REASONING_MODEL};
15
16/// Version of the bundled default policy document (YAML `version: 1`).
17pub const POLICY_VERSION: &str = "1";
18
19/// Picker status for a model (status vocabulary).
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum ModelStatus {
23	/// Top picker tier; shown first.
24	Recommended,
25	/// Normal picker tier.
26	#[default]
27	Available,
28	/// Selectable but not default; delivery conformance not yet validated.
29	Experimental,
30	/// Verified delivery below threshold; warning surfaced.
31	Degraded,
32	/// Not selectable.
33	Blocked,
34	/// Legacy; superseded.
35	Deprecated,
36	/// Hidden from the normal picker (safety/classification).
37	Hidden,
38}
39
40fn default_true() -> bool {
41	true
42}
43
44/// One policy opinion about one model.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub struct PolicyEntry {
48	pub model_id: String,
49	pub status: ModelStatus,
50	/// Lower rank = shown higher in the picker (10, 20, 30…).
51	pub rank: u32,
52	#[serde(default)]
53	pub default: bool,
54	#[serde(default = "default_true")]
55	pub primary_agent_eligible: bool,
56	pub roles: Vec<ModelRole>,
57	/// Human-readable reason, surfaced in picker/doctor output.
58	pub reason: String,
59	pub cost_tier: Option<String>,
60}
61
62/// Versioned policy document - the JSON contract envelope for `policy get`.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub struct ModelPolicy {
66	pub version: String,
67	pub models: Vec<PolicyEntry>,
68}
69
70impl Default for ModelPolicy {
71	fn default() -> Self {
72		Self::default_policy()
73	}
74}
75
76impl ModelPolicy {
77	/// Bundled default policy:
78	/// DeepSeek V4 Flash recommended/default, GLM-5.3 Flash experimental,
79	/// Llama Guard 3 8B hidden and never primary-agent eligible.
80	pub fn default_policy() -> Self {
81		Self {
82			version: POLICY_VERSION.to_string(),
83			models: vec![
84				PolicyEntry {
85					model_id: DEFAULT_MODEL.to_string(),
86					status: ModelStatus::Recommended,
87					rank: 10,
88					default: true,
89					primary_agent_eligible: true,
90					roles: vec![ModelRole::CodingAgent, ModelRole::GeneralChat],
91					reason: "Development default: validated reliability and tool-agent suitability.".to_string(),
92					cost_tier: None,
93				},
94				PolicyEntry {
95					model_id: PREMIUM_REASONING_MODEL.to_string(),
96					status: ModelStatus::Recommended,
97					rank: 20,
98					default: false,
99					primary_agent_eligible: true,
100					roles: vec![ModelRole::Reasoning],
101					reason: "Premium reasoning model.".to_string(),
102					cost_tier: Some("high".to_string()),
103				},
104				PolicyEntry {
105					model_id: PREMIUM_CODING_MODEL.to_string(),
106					status: ModelStatus::Recommended,
107					rank: 30,
108					default: false,
109					primary_agent_eligible: true,
110					roles: vec![ModelRole::CodingAgent],
111					reason: "Premium coding model.".to_string(),
112					cost_tier: Some("high".to_string()),
113				},
114				PolicyEntry {
115					model_id: "@cf/openai/gpt-oss-120b".to_string(),
116					status: ModelStatus::Available,
117					rank: 40,
118					default: false,
119					primary_agent_eligible: true,
120					roles: vec![ModelRole::GeneralChat],
121					reason: "General fallback model.".to_string(),
122					cost_tier: None,
123				},
124				PolicyEntry {
125					model_id: "@cf/openai/gpt-oss-20b".to_string(),
126					status: ModelStatus::Available,
127					rank: 50,
128					default: false,
129					primary_agent_eligible: true,
130					roles: vec![ModelRole::GeneralChat],
131					reason: "Budget general model.".to_string(),
132					cost_tier: None,
133				},
134				PolicyEntry {
135					model_id: EXPERIMENTAL_MODEL.to_string(),
136					status: ModelStatus::Experimental,
137					rank: 900,
138					default: false,
139					primary_agent_eligible: true,
140					roles: vec![ModelRole::CodingAgent],
141					reason: "Observed delivery failures; requires passing conformance suite.".to_string(),
142					cost_tier: None,
143				},
144				PolicyEntry {
145					model_id: "@cf/zai-org/glm-5.3".to_string(),
146					status: ModelStatus::Experimental,
147					rank: 910,
148					default: false,
149					primary_agent_eligible: true,
150					roles: vec![ModelRole::CodingAgent],
151					reason: "Delivery conformance not validated".to_string(),
152					cost_tier: None,
153				},
154				PolicyEntry {
155					model_id: "@cf/meta/llama-guard-3-8b".to_string(),
156					status: ModelStatus::Hidden,
157					rank: 1000,
158					default: false,
159					primary_agent_eligible: false,
160					roles: vec![ModelRole::Safety],
161					reason: "Safety classifier.".to_string(),
162					cost_tier: None,
163				},
164			],
165		}
166	}
167
168	/// Status for a model; unknown models are `Available`, never blocked by absence.
169	pub fn status_for(&self, model_id: &str) -> ModelStatus {
170		self.models
171			.iter()
172			.find(|entry| entry.model_id == model_id)
173			.map(|entry| entry.status)
174			.unwrap_or_default()
175	}
176
177	/// Primary-agent eligibility; unknown models are eligible unless a policy
178	/// entry (e.g. Llama Guard) explicitly excludes them.
179	pub fn is_primary_agent_eligible(&self, model_id: &str) -> bool {
180		self.models
181			.iter()
182			.find(|entry| entry.model_id == model_id)
183			.map(|entry| entry.primary_agent_eligible)
184			.unwrap_or(true)
185	}
186
187	/// The single default model id, if the policy marks one.
188	pub fn default_model(&self) -> Option<&str> {
189		self.models
190			.iter()
191			.find(|entry| entry.default)
192			.map(|entry| entry.model_id.as_str())
193	}
194}
195
196/// Weight of the recent-delivery component in the provider ranking score
197/// (score = 0.40R + 0.25T + 0.15C + 0.10L + 0.10P).
198pub const WEIGHT_DELIVERY: f64 = 0.40;
199/// Weight of the multi-turn tool-loop conformance component.
200pub const WEIGHT_TOOL_LOOP: f64 = 0.25;
201/// Weight of the context-window suitability component.
202pub const WEIGHT_CONTEXT: f64 = 0.15;
203/// Weight of the latency component.
204pub const WEIGHT_LATENCY: f64 = 0.10;
205/// Weight of the price/value component.
206pub const WEIGHT_PRICE: f64 = 0.10;
207
208/// Context window (tokens) that normalizes to 1.0 - DeepSeek V4 Flash's
209/// advertised window. Anything at or beyond this caps at 1.0.
210pub const REFERENCE_CONTEXT_TOKENS: u64 = 1_310_720;
211/// Median latency (ms) at or beyond which the latency score is 0.0 (60s).
212pub const REFERENCE_LATENCY_MS: f64 = 60_000.0;
213/// Average per-million-token price (USD) at or beyond which the price score
214/// is 0.0.
215pub const REFERENCE_PRICE_PER_MILLION: f64 = 10.0;
216
217/// Evidence breakdown for one model's [`ranking_score`] - the picker renders
218/// one line per component from this struct, so every ranking decision is
219/// explainable (evidence lines).
220///
221/// The component fields hold the same normalized 0..1 scores used by
222/// [`ranking_score`]: the weighted dot product of components × weights IS
223/// the score whenever the score is `Some`. The picker renders RAW evidence
224/// (success rates, token counts, dollar prices) directly from
225/// [`ModelVerification`]/[`ModelRecord`] alongside these normalized values.
226#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
227#[serde(rename_all = "snake_case")]
228pub struct RankingBreakdown {
229	pub model_id: String,
230	/// [`ranking_score`] result: `None` when the model is not primary-agent
231	/// eligible, unverified, or not passing - unverified never outranks.
232	pub score: Option<f64>,
233	pub delivery: f64,
234	pub tool_loop: f64,
235	pub context: f64,
236	pub latency: f64,
237	pub price: f64,
238	pub weight_delivery: f64,
239	pub weight_tool_loop: f64,
240	pub weight_context: f64,
241	pub weight_latency: f64,
242	pub weight_price: f64,
243}
244
245impl RankingBreakdown {
246	/// Render the full evidence breakdown for one model + verification pair
247	/// (normalized component scores, the fixed weights, and the resulting
248	/// score). Components are still rendered when the score is `None` so the
249	/// picker can explain WHY the model did not rank.
250	pub fn breakdown_for(model: &ModelRecord, verification: Option<&ModelVerification>) -> Self {
251		let delivery = verification.map(delivery_score).unwrap_or(0.0);
252		let tool_loop = verification.and_then(|v| v.multi_turn_tool_success_rate).unwrap_or(0.0);
253		let context = context_score(model);
254		let latency = verification.map(latency_score).unwrap_or(0.0);
255		let price = price_score(&model.pricing);
256		let score = ranking_score(model, verification);
257		Self {
258			model_id: model.id.clone(),
259			score,
260			delivery,
261			tool_loop,
262			context,
263			latency,
264			price,
265			weight_delivery: WEIGHT_DELIVERY,
266			weight_tool_loop: WEIGHT_TOOL_LOOP,
267			weight_context: WEIGHT_CONTEXT,
268			weight_latency: WEIGHT_LATENCY,
269			weight_price: WEIGHT_PRICE,
270		}
271	}
272}
273
274/// Provider ranking score (score = 0.40R + 0.25T + 0.15C + 0.10L + 0.10P).
275///
276/// Returns `None` - and therefore NEVER outranks anything - when:
277///
278/// - the model is not primary-agent eligible per
279///   [`ModelPolicy::default_policy()`] (e.g. Llama Guard);
280/// - `verification` is `None` (unverified never outranks, even for cheaper
281///   or larger-context models);
282/// - `verification.status` is not [`VerificationStatus::Passing`] (a
283///   Degraded-but-cheaper model still scores `None`).
284///
285/// Components, with the exact normalization formulas (documented so the
286/// picker can explain every number):
287///
288/// - `R` delivery = `successful_runs / total_runs.max(1)` (a zero-run
289///   record contributes 0.0, never a bonus);
290/// - `T` tool-loop = `multi_turn_tool_success_rate`, missing → 0.0;
291/// - `C` context = `min(context_tokens / 1_310_720, 1.0)`, missing → 0.0
292///   (bigger window up to the reference window is better; beyond it the
293///   score is capped at 1.0);
294/// - `L` latency = `1.0 - min(median_latency_ms / 60_000, 1.0)`, missing →
295///   0.0 (lower median latency is better; 60s or more scores 0.0);
296/// - `P` price = `1.0 - min(avg_price_per_million / 10.0, 1.0)` where
297///   `avg_price_per_million = (input + output) / 2` when both prices are
298///   present, else the present one, else 0.0 - a model with NO price data
299///   scores 0.0 (missing is never treated as free); cheaper is better.
300pub fn ranking_score(model: &ModelRecord, verification: Option<&ModelVerification>) -> Option<f64> {
301	let policy = ModelPolicy::default_policy();
302	if !policy.is_primary_agent_eligible(&model.id) {
303		return None;
304	}
305	let verification = verification?;
306	if verification.status != VerificationStatus::Passing {
307		return None;
308	}
309	Some(
310		WEIGHT_DELIVERY * delivery_score(verification)
311			+ WEIGHT_TOOL_LOOP * verification.multi_turn_tool_success_rate.unwrap_or(0.0)
312			+ WEIGHT_CONTEXT * context_score(model)
313			+ WEIGHT_LATENCY * latency_score(verification)
314			+ WEIGHT_PRICE * price_score(&model.pricing),
315	)
316}
317
318/// Delivery component: `successful_runs / total_runs.max(1)` - a zero-run
319/// record contributes 0.0, never a bonus.
320fn delivery_score(verification: &ModelVerification) -> f64 {
321	verification.successful_runs as f64 / verification.total_runs.max(1) as f64
322}
323
324/// Context component: `min(context_tokens / 1_310_720, 1.0)`, missing → 0.0.
325fn context_score(model: &ModelRecord) -> f64 {
326	model
327		.limits
328		.context_tokens
329		.map(|tokens| (tokens as f64 / REFERENCE_CONTEXT_TOKENS as f64).min(1.0))
330		.unwrap_or(0.0)
331}
332
333/// Latency component: `1.0 - min(median_latency_ms / 60_000, 1.0)`, missing
334/// → 0.0; lower is better.
335fn latency_score(verification: &ModelVerification) -> f64 {
336	verification
337		.median_latency_ms
338		.map(|ms| 1.0 - (ms as f64 / REFERENCE_LATENCY_MS).min(1.0))
339		.unwrap_or(0.0)
340}
341
342/// Price component: `1.0 - min(avg_price_per_million / 10.0, 1.0)` where
343/// `avg_price_per_million = (input + output) / 2` when both prices are
344/// present, else the present one, else 0.0; a model with NO price data
345/// scores 0.0 (missing is never free); cheaper is better.
346fn price_score(pricing: &PricingPerMillion) -> f64 {
347	match (pricing.input, pricing.output) {
348		(None, None) => 0.0,
349		(Some(input), Some(output)) => 1.0 - (((input + output) / 2.0) / REFERENCE_PRICE_PER_MILLION).min(1.0),
350		(Some(input), None) => 1.0 - (input / REFERENCE_PRICE_PER_MILLION).min(1.0),
351		(None, Some(output)) => 1.0 - (output / REFERENCE_PRICE_PER_MILLION).min(1.0),
352	}
353}
354
355#[cfg(test)]
356mod tests {
357	use super::*;
358
359	#[test]
360	fn defaults_match_policy() {
361		let policy = ModelPolicy::default_policy();
362		let deepseek = policy
363			.models
364			.iter()
365			.find(|entry| entry.model_id == DEFAULT_MODEL)
366			.expect("deepseek entry present");
367		assert_eq!(deepseek.status, ModelStatus::Recommended);
368		assert_eq!(deepseek.rank, 10);
369		assert!(deepseek.default);
370
371		let glm = policy
372			.models
373			.iter()
374			.find(|entry| entry.model_id == EXPERIMENTAL_MODEL)
375			.expect("glm entry present");
376		assert_eq!(glm.status, ModelStatus::Experimental);
377		assert_eq!(glm.rank, 900);
378		assert!(!glm.default);
379		assert_eq!(glm.reason, "Observed delivery failures; requires passing conformance suite.");
380
381		let glm_53 = policy
382			.models
383			.iter()
384			.find(|entry| entry.model_id == "@cf/zai-org/glm-5.3")
385			.expect("glm-5.3 entry present");
386		assert_eq!(glm_53.status, ModelStatus::Experimental);
387		assert_eq!(glm_53.rank, 910);
388		assert!(!glm_53.default);
389		assert!(glm_53.primary_agent_eligible);
390		assert_eq!(glm_53.reason, "Delivery conformance not validated");
391
392		let guard = policy
393			.models
394			.iter()
395			.find(|entry| entry.model_id == "@cf/meta/llama-guard-3-8b")
396			.expect("guard entry present");
397		assert_eq!(guard.status, ModelStatus::Hidden);
398		assert!(!guard.primary_agent_eligible);
399		assert_eq!(guard.reason, "Safety classifier.");
400	}
401
402	#[test]
403	fn status_for_unknown_is_available() {
404		let policy = ModelPolicy::default_policy();
405		assert_eq!(policy.status_for("@cf/some-org/unknown-model"), ModelStatus::Available);
406		assert_eq!(policy.status_for(DEFAULT_MODEL), ModelStatus::Recommended);
407		assert_eq!(policy.status_for(EXPERIMENTAL_MODEL), ModelStatus::Experimental);
408		assert_eq!(policy.status_for("@cf/meta/llama-guard-3-8b"), ModelStatus::Hidden);
409	}
410
411	#[test]
412	fn primary_eligibility_follows_entries() {
413		let policy = ModelPolicy::default_policy();
414		assert!(policy.is_primary_agent_eligible(DEFAULT_MODEL));
415		assert!(policy.is_primary_agent_eligible("@cf/openai/gpt-oss-120b"));
416		assert!(policy.is_primary_agent_eligible("@cf/unknown/not-in-policy"));
417		assert!(!policy.is_primary_agent_eligible("@cf/meta/llama-guard-3-8b"));
418	}
419
420	#[test]
421	fn default_model_is_deepseek() {
422		let policy = ModelPolicy::default_policy();
423		assert_eq!(policy.default_model(), Some(DEFAULT_MODEL));
424	}
425
426	#[test]
427	fn policy_roundtrips_serde() {
428		let policy = ModelPolicy::default_policy();
429		let json = serde_json::to_string(&policy).expect("serializes");
430		let back: ModelPolicy = serde_json::from_str(&json).expect("deserializes");
431		assert_eq!(policy, back);
432		let value: serde_json::Value = serde_json::from_str(&json).expect("parses");
433		assert_eq!(value["version"], "1");
434		assert_eq!(value["models"][0]["status"], "recommended");
435		assert_eq!(value["models"][5]["status"], "experimental");
436		assert_eq!(value["models"][6]["status"], "experimental");
437		assert_eq!(value["models"][7]["status"], "hidden");
438	}
439}
440
441#[cfg(test)]
442mod ranking_tests {
443	use super::*;
444	use crate::health::{CONFORMANCE_SUITE_VERSION, VerificationConfidence};
445
446	/// Build a normalized catalog record with the given id, context window and
447	/// per-million prices (through the real OpenRouter normalization path).
448	fn record(id: &str, context: u64, input: f64, output: f64) -> ModelRecord {
449		let entry = serde_json::json!({
450			"id": id,
451			"name": id,
452			"context_length": context,
453			"created": 1_788_800_000,
454			"pricing": {
455				"prompt": format!("{:.8}", input / 1_000_000.0),
456				"completion": format!("{:.8}", output / 1_000_000.0),
457			},
458		});
459		ModelRecord::from_openrouter(&entry).expect("record normalizes")
460	}
461
462	/// Build a verification record with the given status, run counts,
463	/// multi-turn tool-loop rate, and median latency.
464	fn verification(
465		status: VerificationStatus,
466		total: u32,
467		successful: u32,
468		multi: Option<f64>,
469		latency: Option<u64>,
470	) -> ModelVerification {
471		ModelVerification {
472			model_id: "ranking-test".to_string(),
473			latest_run_at: None,
474			expires_at: None,
475			suite_version: CONFORMANCE_SUITE_VERSION.to_string(),
476			runner_version: "test".to_string(),
477			status,
478			agent_eligible: true,
479			confidence: VerificationConfidence::ConformanceTested,
480			total_runs: total,
481			successful_runs: successful,
482			text_completion_success_rate: None,
483			stream_completion_success_rate: None,
484			single_tool_success_rate: Some(0.96),
485			multi_turn_tool_success_rate: multi,
486			structured_output_success_rate: None,
487			median_latency_ms: latency,
488			p95_latency_ms: None,
489			total_failures: total.saturating_sub(successful),
490			timeout_failures: 0,
491			transport_failures: 0,
492			provider_5xx_failures: 0,
493			malformed_response_failures: 0,
494			malformed_tool_call_failures: 0,
495			tool_loop_failures: 0,
496			last_failure: None,
497		}
498	}
499
500	#[test]
501	fn unverified_models_never_rank() {
502		let model = record(DEFAULT_MODEL, 1_310_720, 0.15, 0.5);
503		assert_eq!(ranking_score(&model, None), None);
504		let breakdown = RankingBreakdown::breakdown_for(&model, None);
505		assert_eq!(breakdown.score, None);
506		assert_eq!(breakdown.model_id, DEFAULT_MODEL);
507	}
508
509	#[test]
510	fn non_passing_verification_never_ranks() {
511		let model = record(DEFAULT_MODEL, 1_310_720, 0.15, 0.5);
512		for status in [
513			VerificationStatus::Degraded,
514			VerificationStatus::Failing,
515			VerificationStatus::Untested,
516			VerificationStatus::Expired,
517		] {
518			let verification = verification(status, 100, 99, Some(0.96), Some(2_000));
519			assert_eq!(ranking_score(&model, Some(&verification)), None, "{status:?} must not rank");
520		}
521	}
522
523	#[test]
524	fn non_primary_agent_models_never_rank() {
525		// Llama Guard is hidden and never primary-agent eligible - even with
526		// a passing verification record it must not rank.
527		let model = record("@cf/meta/llama-guard-3-8b", 131_072, 0.05, 0.05);
528		let verification = verification(VerificationStatus::Passing, 100, 100, Some(0.99), Some(1_000));
529		assert_eq!(ranking_score(&model, Some(&verification)), None);
530	}
531
532	#[test]
533	fn passing_deepseek_like_outscores_passing_glm_like_on_tool_loop() {
534		let deepseek = record(DEFAULT_MODEL, 1_310_720, 0.15, 0.5);
535		let glm = record(EXPERIMENTAL_MODEL, 1_310_720, 0.15, 0.5);
536		let ds_verification = verification(VerificationStatus::Passing, 100, 99, Some(0.96), Some(2_000));
537		let glm_verification = verification(VerificationStatus::Passing, 100, 99, Some(0.70), Some(2_000));
538		let ds_score = ranking_score(&deepseek, Some(&ds_verification)).expect("deepseek ranks");
539		let glm_score = ranking_score(&glm, Some(&glm_verification)).expect("glm ranks");
540		assert!(ds_score > glm_score, "deepseek {ds_score} must beat glm {glm_score}");
541		// Everything else is identical, so the whole gap is the 0.25-weighted
542		// tool-loop component (0.96 vs 0.70).
543		let gap = ds_score - glm_score;
544		assert!((gap - WEIGHT_TOOL_LOOP * 0.26).abs() < 1e-9, "gap {gap}");
545	}
546
547	#[test]
548	fn breakdown_weights_sum_to_one() {
549		let model = record(DEFAULT_MODEL, 1_310_720, 0.15, 0.5);
550		let verification = verification(VerificationStatus::Passing, 100, 99, Some(0.96), Some(2_000));
551		let breakdown = RankingBreakdown::breakdown_for(&model, Some(&verification));
552		let sum = breakdown.weight_delivery
553			+ breakdown.weight_tool_loop
554			+ breakdown.weight_context
555			+ breakdown.weight_latency
556			+ breakdown.weight_price;
557		assert!((sum - 1.0).abs() < 1e-12, "weights must sum to 1.0, got {sum}");
558		assert_eq!(breakdown.score, ranking_score(&model, Some(&verification)));
559		// The score IS the weighted dot product of the components.
560		let dot = WEIGHT_DELIVERY * breakdown.delivery
561			+ WEIGHT_TOOL_LOOP * breakdown.tool_loop
562			+ WEIGHT_CONTEXT * breakdown.context
563			+ WEIGHT_LATENCY * breakdown.latency
564			+ WEIGHT_PRICE * breakdown.price;
565		assert!((dot - breakdown.score.expect("score present")).abs() < 1e-9);
566	}
567
568	#[test]
569	fn bigger_context_increases_score_other_components_held() {
570		let small = record(DEFAULT_MODEL, 262_144, 0.15, 0.5);
571		let big = record(DEFAULT_MODEL, 1_310_720, 0.15, 0.5);
572		let verification = verification(VerificationStatus::Passing, 100, 99, Some(0.96), Some(2_000));
573		let small_score = ranking_score(&small, Some(&verification)).expect("small ranks");
574		let big_score = ranking_score(&big, Some(&verification)).expect("big ranks");
575		assert!(big_score > small_score);
576		// The context score caps at 1.0: a 2M window equals the reference.
577		let capped = record(DEFAULT_MODEL, 2_000_000, 0.15, 0.5);
578		let capped_score = ranking_score(&capped, Some(&verification)).expect("capped ranks");
579		assert!((capped_score - big_score).abs() < 1e-9);
580	}
581
582	#[test]
583	fn lower_latency_increases_score_other_components_held() {
584		let model = record(DEFAULT_MODEL, 1_310_720, 0.15, 0.5);
585		let fast = verification(VerificationStatus::Passing, 100, 99, Some(0.96), Some(2_000));
586		let slow = verification(VerificationStatus::Passing, 100, 99, Some(0.96), Some(30_000));
587		let fast_score = ranking_score(&model, Some(&fast)).expect("fast ranks");
588		let slow_score = ranking_score(&model, Some(&slow)).expect("slow ranks");
589		assert!(fast_score > slow_score);
590		// Missing latency contributes 0.0 (2s → 0.9667, 30s → 0.5, none → 0.0).
591		let missing = verification(VerificationStatus::Passing, 100, 99, Some(0.96), None);
592		let missing_score = ranking_score(&model, Some(&missing)).expect("missing-latency ranks");
593		assert!((missing_score - (slow_score - WEIGHT_LATENCY * 0.5)).abs() < 1e-9);
594		// Median latency at or beyond 60s scores 0.0.
595		let maxed = verification(VerificationStatus::Passing, 100, 99, Some(0.96), Some(61_000));
596		let maxed_score = ranking_score(&model, Some(&maxed)).expect("maxed-latency ranks");
597		assert!((maxed_score - (slow_score - WEIGHT_LATENCY * 0.5)).abs() < 1e-9);
598	}
599
600	#[test]
601	fn lower_price_increases_score_other_components_held() {
602		let cheap = record(DEFAULT_MODEL, 1_310_720, 0.15, 0.5);
603		let expensive = record(DEFAULT_MODEL, 1_310_720, 10.0, 12.0);
604		let verification = verification(VerificationStatus::Passing, 100, 99, Some(0.96), Some(2_000));
605		let cheap_score = ranking_score(&cheap, Some(&verification)).expect("cheap ranks");
606		let expensive_score = ranking_score(&expensive, Some(&verification)).expect("expensive ranks");
607		assert!(cheap_score > expensive_score);
608		// Missing price data scores 0.0 - unknown price is never free.
609		let no_price = ModelRecord {
610			pricing: crate::catalog::PricingPerMillion { input: None, cached_input: None, output: None },
611			..cheap.clone()
612		};
613		let no_price_score = ranking_score(&no_price, Some(&verification)).expect("no-price ranks");
614		assert!(no_price_score < cheap_score);
615		let cheap_breakdown = RankingBreakdown::breakdown_for(&cheap, Some(&verification));
616		let no_price_breakdown = RankingBreakdown::breakdown_for(&no_price, Some(&verification));
617		assert!(cheap_breakdown.price > no_price_breakdown.price);
618		assert_eq!(no_price_breakdown.price, 0.0);
619	}
620
621	#[test]
622	fn degraded_but_cheaper_never_ranks() {
623		// A very cheap, high-context model still scores None while Degraded.
624		let model = record(DEFAULT_MODEL, 1_310_720, 0.05, 0.10);
625		let verification = verification(VerificationStatus::Degraded, 100, 60, Some(0.50), Some(1_000));
626		assert_eq!(ranking_score(&model, Some(&verification)), None);
627	}
628
629	#[test]
630	fn breakdown_serde_is_snake_case() {
631		let model = record(DEFAULT_MODEL, 1_310_720, 0.15, 0.5);
632		let verification = verification(VerificationStatus::Passing, 100, 99, Some(0.96), Some(2_000));
633		let breakdown = RankingBreakdown::breakdown_for(&model, Some(&verification));
634		let json = serde_json::to_string(&breakdown).expect("ser");
635		// The JSON text carries the snake_case contract.
636		let value: serde_json::Value = serde_json::from_str(&json).expect("parse");
637		for key in [
638			"model_id",
639			"score",
640			"delivery",
641			"tool_loop",
642			"context",
643			"latency",
644			"price",
645			"weight_delivery",
646			"weight_tool_loop",
647			"weight_context",
648			"weight_latency",
649			"weight_price",
650		] {
651			assert!(value.get(key).is_some(), "missing snake_case key {key}");
652		}
653		// f64 values lose ~1 ulp through the JSON text roundtrip (serde_json
654		// arbitrary_precision), so compare floats with tolerance.
655		let back: RankingBreakdown = serde_json::from_str(&json).expect("de");
656		assert_eq!(back.model_id, breakdown.model_id);
657		assert!(close_f64(back.score, breakdown.score), "score {back:?} vs {breakdown:?}");
658		for (actual, expected) in [
659			(back.delivery, breakdown.delivery),
660			(back.tool_loop, breakdown.tool_loop),
661			(back.context, breakdown.context),
662			(back.latency, breakdown.latency),
663			(back.price, breakdown.price),
664			(back.weight_delivery, breakdown.weight_delivery),
665			(back.weight_tool_loop, breakdown.weight_tool_loop),
666			(back.weight_context, breakdown.weight_context),
667			(back.weight_latency, breakdown.weight_latency),
668			(back.weight_price, breakdown.weight_price),
669		] {
670			assert!((actual - expected).abs() < 1e-12, "{actual} vs {expected}");
671		}
672	}
673
674	fn close_f64(actual: Option<f64>, expected: Option<f64>) -> bool {
675		match (actual, expected) {
676			(Some(actual), Some(expected)) => (actual - expected).abs() < 1e-12,
677			(None, None) => true,
678			_ => false,
679		}
680	}
681}