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 (feedback 01/02/05/06): 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 (feedback 02 YAML `version: 1`).
17pub const POLICY_VERSION: &str = "1";
18
19/// Picker status for a model (feedback 02 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 (feedback 01/05/06):
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/// (feedback 02: 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 (feedback 02 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 (feedback 02: score = 0.40R + 0.25T + 0.15C +
275/// 0.10L + 0.10P).
276///
277/// Returns `None` - and therefore NEVER outranks anything - when:
278///
279/// - the model is not primary-agent eligible per
280///   [`ModelPolicy::default_policy()`] (e.g. Llama Guard);
281/// - `verification` is `None` (unverified never outranks, even for cheaper
282///   or larger-context models);
283/// - `verification.status` is not [`VerificationStatus::Passing`] (a
284///   Degraded-but-cheaper model still scores `None`).
285///
286/// Components, with the exact normalization formulas (documented so the
287/// picker can explain every number):
288///
289/// - `R` delivery = `successful_runs / total_runs.max(1)` (a zero-run
290///   record contributes 0.0, never a bonus);
291/// - `T` tool-loop = `multi_turn_tool_success_rate`, missing → 0.0;
292/// - `C` context = `min(context_tokens / 1_310_720, 1.0)`, missing → 0.0
293///   (bigger window up to the reference window is better; beyond it the
294///   score is capped at 1.0);
295/// - `L` latency = `1.0 - min(median_latency_ms / 60_000, 1.0)`, missing →
296///   0.0 (lower median latency is better; 60s or more scores 0.0);
297/// - `P` price = `1.0 - min(avg_price_per_million / 10.0, 1.0)` where
298///   `avg_price_per_million = (input + output) / 2` when both prices are
299///   present, else the present one, else 0.0 - a model with NO price data
300///   scores 0.0 (missing is never treated as free); cheaper is better.
301pub fn ranking_score(model: &ModelRecord, verification: Option<&ModelVerification>) -> Option<f64> {
302	let policy = ModelPolicy::default_policy();
303	if !policy.is_primary_agent_eligible(&model.id) {
304		return None;
305	}
306	let verification = verification?;
307	if verification.status != VerificationStatus::Passing {
308		return None;
309	}
310	Some(
311		WEIGHT_DELIVERY * delivery_score(verification)
312			+ WEIGHT_TOOL_LOOP * verification.multi_turn_tool_success_rate.unwrap_or(0.0)
313			+ WEIGHT_CONTEXT * context_score(model)
314			+ WEIGHT_LATENCY * latency_score(verification)
315			+ WEIGHT_PRICE * price_score(&model.pricing),
316	)
317}
318
319/// Delivery component: `successful_runs / total_runs.max(1)` - a zero-run
320/// record contributes 0.0, never a bonus.
321fn delivery_score(verification: &ModelVerification) -> f64 {
322	verification.successful_runs as f64 / verification.total_runs.max(1) as f64
323}
324
325/// Context component: `min(context_tokens / 1_310_720, 1.0)`, missing → 0.0.
326fn context_score(model: &ModelRecord) -> f64 {
327	model
328		.limits
329		.context_tokens
330		.map(|tokens| (tokens as f64 / REFERENCE_CONTEXT_TOKENS as f64).min(1.0))
331		.unwrap_or(0.0)
332}
333
334/// Latency component: `1.0 - min(median_latency_ms / 60_000, 1.0)`, missing
335/// → 0.0; lower is better.
336fn latency_score(verification: &ModelVerification) -> f64 {
337	verification
338		.median_latency_ms
339		.map(|ms| 1.0 - (ms as f64 / REFERENCE_LATENCY_MS).min(1.0))
340		.unwrap_or(0.0)
341}
342
343/// Price component: `1.0 - min(avg_price_per_million / 10.0, 1.0)` where
344/// `avg_price_per_million = (input + output) / 2` when both prices are
345/// present, else the present one, else 0.0; a model with NO price data
346/// scores 0.0 (missing is never free); cheaper is better.
347fn price_score(pricing: &PricingPerMillion) -> f64 {
348	match (pricing.input, pricing.output) {
349		(None, None) => 0.0,
350		(Some(input), Some(output)) => 1.0 - (((input + output) / 2.0) / REFERENCE_PRICE_PER_MILLION).min(1.0),
351		(Some(input), None) => 1.0 - (input / REFERENCE_PRICE_PER_MILLION).min(1.0),
352		(None, Some(output)) => 1.0 - (output / REFERENCE_PRICE_PER_MILLION).min(1.0),
353	}
354}
355
356#[cfg(test)]
357mod tests {
358	use super::*;
359
360	#[test]
361	fn defaults_match_feedback() {
362		let policy = ModelPolicy::default_policy();
363		let deepseek = policy
364			.models
365			.iter()
366			.find(|entry| entry.model_id == DEFAULT_MODEL)
367			.expect("deepseek entry present");
368		assert_eq!(deepseek.status, ModelStatus::Recommended);
369		assert_eq!(deepseek.rank, 10);
370		assert!(deepseek.default);
371
372		let glm = policy
373			.models
374			.iter()
375			.find(|entry| entry.model_id == EXPERIMENTAL_MODEL)
376			.expect("glm entry present");
377		assert_eq!(glm.status, ModelStatus::Experimental);
378		assert_eq!(glm.rank, 900);
379		assert!(!glm.default);
380		assert_eq!(glm.reason, "Observed delivery failures; requires passing conformance suite.");
381
382		let glm_53 = policy
383			.models
384			.iter()
385			.find(|entry| entry.model_id == "@cf/zai-org/glm-5.3")
386			.expect("glm-5.3 entry present");
387		assert_eq!(glm_53.status, ModelStatus::Experimental);
388		assert_eq!(glm_53.rank, 910);
389		assert!(!glm_53.default);
390		assert!(glm_53.primary_agent_eligible);
391		assert_eq!(glm_53.reason, "Delivery conformance not validated");
392
393		let guard = policy
394			.models
395			.iter()
396			.find(|entry| entry.model_id == "@cf/meta/llama-guard-3-8b")
397			.expect("guard entry present");
398		assert_eq!(guard.status, ModelStatus::Hidden);
399		assert!(!guard.primary_agent_eligible);
400		assert_eq!(guard.reason, "Safety classifier.");
401	}
402
403	#[test]
404	fn status_for_unknown_is_available() {
405		let policy = ModelPolicy::default_policy();
406		assert_eq!(policy.status_for("@cf/some-org/unknown-model"), ModelStatus::Available);
407		assert_eq!(policy.status_for(DEFAULT_MODEL), ModelStatus::Recommended);
408		assert_eq!(policy.status_for(EXPERIMENTAL_MODEL), ModelStatus::Experimental);
409		assert_eq!(policy.status_for("@cf/meta/llama-guard-3-8b"), ModelStatus::Hidden);
410	}
411
412	#[test]
413	fn primary_eligibility_follows_entries() {
414		let policy = ModelPolicy::default_policy();
415		assert!(policy.is_primary_agent_eligible(DEFAULT_MODEL));
416		assert!(policy.is_primary_agent_eligible("@cf/openai/gpt-oss-120b"));
417		assert!(policy.is_primary_agent_eligible("@cf/unknown/not-in-policy"));
418		assert!(!policy.is_primary_agent_eligible("@cf/meta/llama-guard-3-8b"));
419	}
420
421	#[test]
422	fn default_model_is_deepseek() {
423		let policy = ModelPolicy::default_policy();
424		assert_eq!(policy.default_model(), Some(DEFAULT_MODEL));
425	}
426
427	#[test]
428	fn policy_roundtrips_serde() {
429		let policy = ModelPolicy::default_policy();
430		let json = serde_json::to_string(&policy).expect("serializes");
431		let back: ModelPolicy = serde_json::from_str(&json).expect("deserializes");
432		assert_eq!(policy, back);
433		let value: serde_json::Value = serde_json::from_str(&json).expect("parses");
434		assert_eq!(value["version"], "1");
435		assert_eq!(value["models"][0]["status"], "recommended");
436		assert_eq!(value["models"][5]["status"], "experimental");
437		assert_eq!(value["models"][6]["status"], "experimental");
438		assert_eq!(value["models"][7]["status"], "hidden");
439	}
440}
441
442#[cfg(test)]
443mod ranking_tests {
444	use super::*;
445	use crate::health::{CONFORMANCE_SUITE_VERSION, VerificationConfidence};
446
447	/// Build a normalized catalog record with the given id, context window and
448	/// per-million prices (through the real OpenRouter normalization path).
449	fn record(id: &str, context: u64, input: f64, output: f64) -> ModelRecord {
450		let entry = serde_json::json!({
451			"id": id,
452			"name": id,
453			"context_length": context,
454			"created": 1_788_800_000,
455			"pricing": {
456				"prompt": format!("{:.8}", input / 1_000_000.0),
457				"completion": format!("{:.8}", output / 1_000_000.0),
458			},
459		});
460		ModelRecord::from_openrouter(&entry).expect("record normalizes")
461	}
462
463	/// Build a verification record with the given status, run counts,
464	/// multi-turn tool-loop rate, and median latency.
465	fn verification(
466		status: VerificationStatus,
467		total: u32,
468		successful: u32,
469		multi: Option<f64>,
470		latency: Option<u64>,
471	) -> ModelVerification {
472		ModelVerification {
473			model_id: "ranking-test".to_string(),
474			latest_run_at: None,
475			expires_at: None,
476			suite_version: CONFORMANCE_SUITE_VERSION.to_string(),
477			runner_version: "test".to_string(),
478			status,
479			agent_eligible: true,
480			confidence: VerificationConfidence::ConformanceTested,
481			total_runs: total,
482			successful_runs: successful,
483			text_completion_success_rate: None,
484			stream_completion_success_rate: None,
485			single_tool_success_rate: Some(0.96),
486			multi_turn_tool_success_rate: multi,
487			structured_output_success_rate: None,
488			median_latency_ms: latency,
489			p95_latency_ms: None,
490			total_failures: total.saturating_sub(successful),
491			timeout_failures: 0,
492			transport_failures: 0,
493			provider_5xx_failures: 0,
494			malformed_response_failures: 0,
495			malformed_tool_call_failures: 0,
496			tool_loop_failures: 0,
497			last_failure: None,
498		}
499	}
500
501	#[test]
502	fn unverified_models_never_rank() {
503		let model = record(DEFAULT_MODEL, 1_310_720, 0.15, 0.5);
504		assert_eq!(ranking_score(&model, None), None);
505		let breakdown = RankingBreakdown::breakdown_for(&model, None);
506		assert_eq!(breakdown.score, None);
507		assert_eq!(breakdown.model_id, DEFAULT_MODEL);
508	}
509
510	#[test]
511	fn non_passing_verification_never_ranks() {
512		let model = record(DEFAULT_MODEL, 1_310_720, 0.15, 0.5);
513		for status in [
514			VerificationStatus::Degraded,
515			VerificationStatus::Failing,
516			VerificationStatus::Untested,
517			VerificationStatus::Expired,
518		] {
519			let verification = verification(status, 100, 99, Some(0.96), Some(2_000));
520			assert_eq!(ranking_score(&model, Some(&verification)), None, "{status:?} must not rank");
521		}
522	}
523
524	#[test]
525	fn non_primary_agent_models_never_rank() {
526		// Llama Guard is hidden and never primary-agent eligible - even with
527		// a passing verification record it must not rank.
528		let model = record("@cf/meta/llama-guard-3-8b", 131_072, 0.05, 0.05);
529		let verification = verification(VerificationStatus::Passing, 100, 100, Some(0.99), Some(1_000));
530		assert_eq!(ranking_score(&model, Some(&verification)), None);
531	}
532
533	#[test]
534	fn passing_deepseek_like_outscores_passing_glm_like_on_tool_loop() {
535		let deepseek = record(DEFAULT_MODEL, 1_310_720, 0.15, 0.5);
536		let glm = record(EXPERIMENTAL_MODEL, 1_310_720, 0.15, 0.5);
537		let ds_verification = verification(VerificationStatus::Passing, 100, 99, Some(0.96), Some(2_000));
538		let glm_verification = verification(VerificationStatus::Passing, 100, 99, Some(0.70), Some(2_000));
539		let ds_score = ranking_score(&deepseek, Some(&ds_verification)).expect("deepseek ranks");
540		let glm_score = ranking_score(&glm, Some(&glm_verification)).expect("glm ranks");
541		assert!(ds_score > glm_score, "deepseek {ds_score} must beat glm {glm_score}");
542		// Everything else is identical, so the whole gap is the 0.25-weighted
543		// tool-loop component (0.96 vs 0.70).
544		let gap = ds_score - glm_score;
545		assert!((gap - WEIGHT_TOOL_LOOP * 0.26).abs() < 1e-9, "gap {gap}");
546	}
547
548	#[test]
549	fn breakdown_weights_sum_to_one() {
550		let model = record(DEFAULT_MODEL, 1_310_720, 0.15, 0.5);
551		let verification = verification(VerificationStatus::Passing, 100, 99, Some(0.96), Some(2_000));
552		let breakdown = RankingBreakdown::breakdown_for(&model, Some(&verification));
553		let sum = breakdown.weight_delivery
554			+ breakdown.weight_tool_loop
555			+ breakdown.weight_context
556			+ breakdown.weight_latency
557			+ breakdown.weight_price;
558		assert!((sum - 1.0).abs() < 1e-12, "weights must sum to 1.0, got {sum}");
559		assert_eq!(breakdown.score, ranking_score(&model, Some(&verification)));
560		// The score IS the weighted dot product of the components.
561		let dot = WEIGHT_DELIVERY * breakdown.delivery
562			+ WEIGHT_TOOL_LOOP * breakdown.tool_loop
563			+ WEIGHT_CONTEXT * breakdown.context
564			+ WEIGHT_LATENCY * breakdown.latency
565			+ WEIGHT_PRICE * breakdown.price;
566		assert!((dot - breakdown.score.expect("score present")).abs() < 1e-9);
567	}
568
569	#[test]
570	fn bigger_context_increases_score_other_components_held() {
571		let small = record(DEFAULT_MODEL, 262_144, 0.15, 0.5);
572		let big = record(DEFAULT_MODEL, 1_310_720, 0.15, 0.5);
573		let verification = verification(VerificationStatus::Passing, 100, 99, Some(0.96), Some(2_000));
574		let small_score = ranking_score(&small, Some(&verification)).expect("small ranks");
575		let big_score = ranking_score(&big, Some(&verification)).expect("big ranks");
576		assert!(big_score > small_score);
577		// The context score caps at 1.0: a 2M window equals the reference.
578		let capped = record(DEFAULT_MODEL, 2_000_000, 0.15, 0.5);
579		let capped_score = ranking_score(&capped, Some(&verification)).expect("capped ranks");
580		assert!((capped_score - big_score).abs() < 1e-9);
581	}
582
583	#[test]
584	fn lower_latency_increases_score_other_components_held() {
585		let model = record(DEFAULT_MODEL, 1_310_720, 0.15, 0.5);
586		let fast = verification(VerificationStatus::Passing, 100, 99, Some(0.96), Some(2_000));
587		let slow = verification(VerificationStatus::Passing, 100, 99, Some(0.96), Some(30_000));
588		let fast_score = ranking_score(&model, Some(&fast)).expect("fast ranks");
589		let slow_score = ranking_score(&model, Some(&slow)).expect("slow ranks");
590		assert!(fast_score > slow_score);
591		// Missing latency contributes 0.0 (2s → 0.9667, 30s → 0.5, none → 0.0).
592		let missing = verification(VerificationStatus::Passing, 100, 99, Some(0.96), None);
593		let missing_score = ranking_score(&model, Some(&missing)).expect("missing-latency ranks");
594		assert!((missing_score - (slow_score - WEIGHT_LATENCY * 0.5)).abs() < 1e-9);
595		// Median latency at or beyond 60s scores 0.0.
596		let maxed = verification(VerificationStatus::Passing, 100, 99, Some(0.96), Some(61_000));
597		let maxed_score = ranking_score(&model, Some(&maxed)).expect("maxed-latency ranks");
598		assert!((maxed_score - (slow_score - WEIGHT_LATENCY * 0.5)).abs() < 1e-9);
599	}
600
601	#[test]
602	fn lower_price_increases_score_other_components_held() {
603		let cheap = record(DEFAULT_MODEL, 1_310_720, 0.15, 0.5);
604		let expensive = record(DEFAULT_MODEL, 1_310_720, 10.0, 12.0);
605		let verification = verification(VerificationStatus::Passing, 100, 99, Some(0.96), Some(2_000));
606		let cheap_score = ranking_score(&cheap, Some(&verification)).expect("cheap ranks");
607		let expensive_score = ranking_score(&expensive, Some(&verification)).expect("expensive ranks");
608		assert!(cheap_score > expensive_score);
609		// Missing price data scores 0.0 - unknown price is never free.
610		let no_price = ModelRecord {
611			pricing: crate::catalog::PricingPerMillion { input: None, cached_input: None, output: None },
612			..cheap.clone()
613		};
614		let no_price_score = ranking_score(&no_price, Some(&verification)).expect("no-price ranks");
615		assert!(no_price_score < cheap_score);
616		let cheap_breakdown = RankingBreakdown::breakdown_for(&cheap, Some(&verification));
617		let no_price_breakdown = RankingBreakdown::breakdown_for(&no_price, Some(&verification));
618		assert!(cheap_breakdown.price > no_price_breakdown.price);
619		assert_eq!(no_price_breakdown.price, 0.0);
620	}
621
622	#[test]
623	fn degraded_but_cheaper_never_ranks() {
624		// A very cheap, high-context model still scores None while Degraded.
625		let model = record(DEFAULT_MODEL, 1_310_720, 0.05, 0.10);
626		let verification = verification(VerificationStatus::Degraded, 100, 60, Some(0.50), Some(1_000));
627		assert_eq!(ranking_score(&model, Some(&verification)), None);
628	}
629
630	#[test]
631	fn breakdown_serde_is_snake_case() {
632		let model = record(DEFAULT_MODEL, 1_310_720, 0.15, 0.5);
633		let verification = verification(VerificationStatus::Passing, 100, 99, Some(0.96), Some(2_000));
634		let breakdown = RankingBreakdown::breakdown_for(&model, Some(&verification));
635		let json = serde_json::to_string(&breakdown).expect("ser");
636		// The JSON text carries the snake_case contract.
637		let value: serde_json::Value = serde_json::from_str(&json).expect("parse");
638		for key in [
639			"model_id",
640			"score",
641			"delivery",
642			"tool_loop",
643			"context",
644			"latency",
645			"price",
646			"weight_delivery",
647			"weight_tool_loop",
648			"weight_context",
649			"weight_latency",
650			"weight_price",
651		] {
652			assert!(value.get(key).is_some(), "missing snake_case key {key}");
653		}
654		// f64 values lose ~1 ulp through the JSON text roundtrip (serde_json
655		// arbitrary_precision), so compare floats with tolerance.
656		let back: RankingBreakdown = serde_json::from_str(&json).expect("de");
657		assert_eq!(back.model_id, breakdown.model_id);
658		assert!(close_f64(back.score, breakdown.score), "score {back:?} vs {breakdown:?}");
659		for (actual, expected) in [
660			(back.delivery, breakdown.delivery),
661			(back.tool_loop, breakdown.tool_loop),
662			(back.context, breakdown.context),
663			(back.latency, breakdown.latency),
664			(back.price, breakdown.price),
665			(back.weight_delivery, breakdown.weight_delivery),
666			(back.weight_tool_loop, breakdown.weight_tool_loop),
667			(back.weight_context, breakdown.weight_context),
668			(back.weight_latency, breakdown.weight_latency),
669			(back.weight_price, breakdown.weight_price),
670		] {
671			assert!((actual - expected).abs() < 1e-12, "{actual} vs {expected}");
672		}
673	}
674
675	fn close_f64(actual: Option<f64>, expected: Option<f64>) -> bool {
676		match (actual, expected) {
677			(Some(actual), Some(expected)) => (actual - expected).abs() < 1e-12,
678			(None, None) => true,
679			_ => false,
680		}
681	}
682}