Skip to main content

car_inference/
adaptive_router.rs

1//! Adaptive model routing — three-phase routing with learned performance profiles.
2//!
3//! Phase 1: **Filter** — hard constraints (capability, availability, memory, cost).
4//! Phase 2: **Score** — blend quality, latency, and cost using observed profiles
5//!          or schema defaults on cold start.
6//! Phase 3: **Select** — Thompson Sampling (Beta distribution per model) for
7//!          natural exploration-exploitation balance. Models with fewer observations
8//!          have wider distributions, giving them chances to prove themselves.
9//!
10//! Replaces the hardcoded `ModelRouter` from `router.rs`.
11
12use rand::Rng;
13use serde::{Deserialize, Serialize};
14
15use std::sync::{Arc, Mutex};
16
17use crate::hardware::HardwareInfo;
18use crate::outcome::{InferenceTask, OutcomeTracker};
19use crate::registry::UnifiedRegistry;
20use crate::routing_ext::CircuitBreakerRegistry;
21use crate::schema::{ModelCapability, ModelSchema};
22use crate::tasks::RoutingWorkload;
23
24/// Prompt complexity assessment (migrated from router.rs).
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum TaskComplexity {
28    Simple,
29    Medium,
30    Code,
31    Complex,
32}
33
34impl TaskComplexity {
35    /// Assess complexity of a prompt string.
36    ///
37    /// Uses tree-sitter AST parsing (when the `ast` feature is enabled) for
38    /// accurate code detection: if a code block parses successfully as any
39    /// supported language, it's definitively code. Falls back to keyword
40    /// heuristics for prompts that mention code without containing code blocks.
41    pub fn assess(prompt: &str) -> Self {
42        let lower = prompt.to_lowercase();
43        let word_count = prompt.split_whitespace().count();
44        let estimated_tokens = (word_count as f64 * 1.3) as usize;
45
46        let has_code = Self::detect_code(prompt);
47
48        let repair_markers = [
49            "fix", "repair", "debug", "refactor", "broken", "failing", "error", "bug",
50        ];
51        let has_repair = repair_markers.iter().any(|m| lower.contains(m));
52
53        let reasoning_markers = [
54            "analyze",
55            "compare",
56            "explain why",
57            "step by step",
58            "think through",
59            "evaluate",
60            "trade-off",
61            "tradeoff",
62            "pros and cons",
63            "architecture",
64            "design",
65            "strategy",
66            "optimize",
67            "comprehensive",
68        ];
69        let has_reasoning = reasoning_markers.iter().any(|m| lower.contains(m));
70
71        let simple_patterns = [
72            "what is",
73            "who is",
74            "when did",
75            "where is",
76            "how many",
77            "yes or no",
78            "true or false",
79            "name the",
80            "list the",
81            "define ",
82        ];
83        let is_simple = simple_patterns.iter().any(|p| lower.contains(p));
84
85        if has_code || has_repair {
86            TaskComplexity::Code
87        } else if has_reasoning || estimated_tokens > 500 {
88            TaskComplexity::Complex
89        } else if is_simple || estimated_tokens < 30 {
90            TaskComplexity::Simple
91        } else {
92            TaskComplexity::Medium
93        }
94    }
95
96    /// Detect whether a prompt contains code.
97    ///
98    /// With `ast` feature: extracts code blocks (``` delimited), attempts to
99    /// parse each with tree-sitter. If any parses into symbols, it's real code.
100    /// Without `ast` feature: falls back to keyword heuristics.
101    fn detect_code(prompt: &str) -> bool {
102        // First try AST-based detection on code blocks
103        #[cfg(feature = "ast")]
104        {
105            if let Some(is_code) = Self::detect_code_ast(prompt) {
106                return is_code;
107            }
108        }
109
110        // Fallback: keyword heuristics
111        let code_markers = [
112            "```",
113            "fn ",
114            "def ",
115            "class ",
116            "import ",
117            "require(",
118            "async fn",
119            "pub fn",
120            "function ",
121            "const ",
122            "let ",
123            "var ",
124            "#include",
125            "package ",
126            "impl ",
127        ];
128        code_markers.iter().any(|m| prompt.contains(m))
129    }
130
131    /// AST-based code detection: parse code blocks with tree-sitter.
132    /// Returns Some(true) if code found, Some(false) if blocks exist but
133    /// don't parse, None if no code blocks found (fall through to heuristics).
134    #[cfg(feature = "ast")]
135    fn detect_code_ast(prompt: &str) -> Option<bool> {
136        // Extract code blocks between ``` markers
137        let mut blocks = Vec::new();
138        let mut rest = prompt;
139        while let Some(start) = rest.find("```") {
140            let after_fence = &rest[start + 3..];
141            // Skip optional language tag on the opening fence
142            let code_start = after_fence.find('\n').map(|i| i + 1).unwrap_or(0);
143            if let Some(end) = after_fence[code_start..].find("```") {
144                blocks.push(&after_fence[code_start..code_start + end]);
145                rest = &after_fence[code_start + end + 3..];
146            } else {
147                break;
148            }
149        }
150
151        if blocks.is_empty() {
152            return None; // No code blocks — let heuristics decide
153        }
154
155        // Try to parse each block with tree-sitter
156        let languages = [
157            car_ast::Language::Rust,
158            car_ast::Language::Python,
159            car_ast::Language::TypeScript,
160            car_ast::Language::JavaScript,
161            car_ast::Language::Go,
162        ];
163
164        for block in &blocks {
165            let trimmed = block.trim();
166            if trimmed.is_empty() {
167                continue;
168            }
169
170            for lang in &languages {
171                if let Some(parsed) = car_ast::parse(trimmed, *lang) {
172                    // If it parsed into any symbols, it's definitely code
173                    if !parsed.symbols.is_empty() {
174                        return Some(true);
175                    }
176                }
177            }
178        }
179
180        // Had code blocks but none parsed into symbols — could be
181        // pseudocode, output, or unsupported language
182        Some(false)
183    }
184
185    /// Map complexity to required capabilities.
186    pub fn required_capabilities(&self) -> Vec<ModelCapability> {
187        match self {
188            TaskComplexity::Simple => vec![ModelCapability::Generate],
189            TaskComplexity::Medium => vec![ModelCapability::Generate],
190            TaskComplexity::Code => vec![ModelCapability::Code],
191            TaskComplexity::Complex => vec![ModelCapability::Reasoning],
192        }
193    }
194
195    /// Map complexity to the InferenceTask type.
196    pub fn inference_task(&self) -> InferenceTask {
197        match self {
198            TaskComplexity::Simple | TaskComplexity::Medium => InferenceTask::Generate,
199            TaskComplexity::Code => InferenceTask::Code,
200            TaskComplexity::Complex => InferenceTask::Reasoning,
201        }
202    }
203}
204
205/// Configuration for routing behavior.
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct RoutingConfig {
208    /// Minimum observations before trusting a model's profile over schema defaults.
209    pub min_observations: u64,
210    /// Scoring weights (must sum to 1.0).
211    pub quality_weight: f64,
212    pub latency_weight: f64,
213    pub cost_weight: f64,
214    /// Hard constraint: maximum latency budget in ms.
215    pub max_latency_ms: Option<u64>,
216    /// Hard constraint: maximum cost per call in USD.
217    pub max_cost_usd: Option<f64>,
218    /// Prefer local models over remote (all else being equal).
219    pub prefer_local: bool,
220    /// Thompson Sampling prior strength: the concentration of an unobserved
221    /// model's draw around its Phase 2 score, in virtual calls. Each observed
222    /// call adds one. Higher = picks follow the scores more closely, lower =
223    /// more exploration.
224    pub prior_strength: f64,
225    /// Prefer trusted remote models for quality-critical tasks until local models
226    /// have enough task-specific evidence to be promoted.
227    pub quality_first_cold_start: bool,
228    /// Minimum task-specific observations required before a local model can
229    /// compete with trusted remote models during cold start.
230    pub bootstrap_min_task_observations: u64,
231    /// Minimum task-specific EMA quality required before a local model can
232    /// displace trusted remote models during cold start.
233    pub bootstrap_quality_floor: f64,
234}
235
236impl Default for RoutingConfig {
237    fn default() -> Self {
238        Self {
239            min_observations: 2,
240            quality_weight: 0.45,
241            latency_weight: 0.4,
242            cost_weight: 0.15,
243            max_latency_ms: None,
244            max_cost_usd: None,
245            prefer_local: true,
246            prior_strength: 20.0,
247            quality_first_cold_start: true,
248            bootstrap_min_task_observations: 8,
249            bootstrap_quality_floor: 0.8,
250        }
251    }
252}
253
254/// How a model was selected.
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
256#[serde(rename_all = "snake_case")]
257pub enum RoutingStrategy {
258    /// Using declared schema capabilities (no observed data).
259    SchemaBased,
260    /// Using observed performance profiles (exploitation).
261    ProfileBased,
262    /// Deliberately trying an under-tested model (exploration).
263    Exploration,
264    /// User explicitly specified the model.
265    Explicit,
266}
267
268/// One scored candidate considered during adaptive routing.
269///
270/// Advisory surface: lets a caller (UI, operator, audit) see *why* a model was
271/// picked and what the alternatives cost in reliability terms — the tradeoff
272/// the router resolved on their behalf. `selected` marks the chosen model;
273/// `in_band` marks models inside the outcome-first reliability band (the set the
274/// router would accept). Cost/latency only break ties *within* the band, so a
275/// cheaper out-of-band model is shown but was never eligible.
276#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct RouteCandidate {
278    /// Candidate model id.
279    pub model_id: String,
280    /// Outcome-derived reliability (quality-only band key, 0.0-1.0).
281    pub reliability: f64,
282    /// Full routing score (reliability plus cost/latency/context tie-breakers).
283    pub score: f64,
284    /// Whether this candidate was the one selected.
285    pub selected: bool,
286    /// Whether this candidate fell inside the outcome-first reliability band.
287    pub in_band: bool,
288    /// External grades (`outcomes.record_external_grade`) pooled into this
289    /// candidate's `reliability` for the routed task, one per source. Empty —
290    /// and absent from the JSON — when no outside source graded the model.
291    #[serde(default, skip_serializing_if = "Vec::is_empty")]
292    pub external_grades: Vec<RouteExternalGrade>,
293}
294
295/// One outside source's graded evidence, as `models.route` reports it.
296#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
297pub struct RouteExternalGrade {
298    /// The source as stored: the name an operator reported under, or
299    /// `<reporter>/<source>` for a report from a supervised agent (see
300    /// `outcome::external_grade_key`).
301    pub source: String,
302    /// The source's current quality for this model and task, in `[0, 1]`.
303    pub quality: f64,
304    /// Graded cases the source reported, uncapped.
305    pub observations: u64,
306    /// Cases this source counts for on its own — `observations` capped per
307    /// source, `0` once the source has gone stale. The per-reporter and total
308    /// caps applied when pooling may count it for less.
309    pub evidence: u64,
310}
311
312/// Why an otherwise eligible inference lane was removed before dispatch.
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub enum RouterExclusionReason {
315    /// A 429 excluded the lane for the rest of this engine session.
316    RateLimited,
317    /// The lane's circuit breaker is blocking requests during its cooldown.
318    CircuitOpen,
319}
320
321impl RouterExclusionReason {
322    /// Stable journal spelling consumed by the coder audit log.
323    pub fn as_str(self) -> &'static str {
324        match self {
325            Self::RateLimited => "rate_limited",
326            Self::CircuitOpen => "circuit_open",
327        }
328    }
329}
330
331/// One router-side lane removal that happened before provider dispatch.
332#[derive(Debug, Clone, PartialEq, Eq)]
333pub struct RouterExclusion {
334    /// Canonical catalog id of the lane removed from consideration.
335    pub lane: String,
336    /// Availability guard that removed it.
337    pub reason: RouterExclusionReason,
338    /// Unix timestamp when the lane may be retried. `None` means the exclusion
339    /// lasts for the engine session (rate limits) or until a half-open probe
340    /// completes (circuit breaker).
341    pub until: Option<u64>,
342}
343
344/// The result of adaptive routing.
345#[derive(Debug, Clone, Serialize, Deserialize)]
346pub struct AdaptiveRoutingDecision {
347    /// Selected model id.
348    pub model_id: String,
349    /// Selected model name (display).
350    pub model_name: String,
351    /// Task type.
352    pub task: InferenceTask,
353    /// Assessed complexity.
354    pub complexity: TaskComplexity,
355    /// Human-readable reason.
356    pub reason: String,
357    /// How the model was selected.
358    pub strategy: RoutingStrategy,
359    /// Predicted quality (0.0-1.0).
360    pub predicted_quality: f64,
361    /// Fallback chain (ordered list of alternative model ids).
362    pub fallbacks: Vec<String>,
363    /// Context window of the selected model (tokens). 0 = unknown.
364    pub context_length: usize,
365    /// Whether the prompt needs compaction to fit the selected model's context window.
366    pub needs_compaction: bool,
367    /// Ranked candidates considered (advisory). Empty for explicit-model and
368    /// error paths where no ranking occurred; populated only by the adaptive path.
369    #[serde(default)]
370    pub candidates: Vec<RouteCandidate>,
371}
372
373/// Adaptive router with three-phase model selection.
374pub struct AdaptiveRouter {
375    hw: HardwareInfo,
376    config: RoutingConfig,
377    /// Circuit breaker registry — blocks models after consecutive failures (#25).
378    pub circuit_breakers: Arc<Mutex<CircuitBreakerRegistry>>,
379}
380
381/// All inputs to a routing decision packed into one struct so adding
382/// a new combinator (per-tenant routing, streaming awareness, …)
383/// only adds a field rather than another `route_*` sibling method.
384/// See [`AdaptiveRouter::route_with`].
385///
386/// Use [`RouteRequest::new`] for the common defaults, then mutate
387/// just the fields the caller cares about:
388///
389/// ```ignore
390/// let decision = router.route_with(RouteRequest {
391///     has_tools: true,
392///     intent: Some(&hint),
393///     ..RouteRequest::new(prompt, &registry, &tracker)
394/// });
395/// ```
396pub struct RouteRequest<'a> {
397    pub prompt: &'a str,
398    pub registry: &'a UnifiedRegistry,
399    pub tracker: &'a OutcomeTracker,
400    /// Estimated total context footprint (prompt + reserved output) for
401    /// context-window-aware scoring. `0` skips the compaction-headroom check.
402    pub estimated_total_tokens: usize,
403    /// Estimated uncapped prompt tokens for prompt-size pricing tiers. When
404    /// zero, routing falls back to `estimated_total_tokens - output`.
405    pub estimated_input_tokens: usize,
406    /// Expected generated tokens for request-cost scoring.
407    pub estimated_output_tokens: usize,
408    /// Prompt tokens expected to be served from provider cache.
409    pub estimated_cache_read_tokens: usize,
410    /// Prompt tokens expected to be written to provider cache.
411    pub estimated_cache_write_tokens: usize,
412    pub has_tools: bool,
413    pub has_vision: bool,
414    pub workload: RoutingWorkload,
415    /// Caller-supplied intent hint. `prefer_local: true` overrides
416    /// `workload` to [`RoutingWorkload::LocalPreferred`].
417    pub intent: Option<&'a crate::intent::IntentHint>,
418}
419
420impl<'a> RouteRequest<'a> {
421    /// Build a request with the same defaults the bare
422    /// [`AdaptiveRouter::route`] uses: interactive workload, no tools,
423    /// no vision, no context-aware sizing, no intent.
424    pub fn new(
425        prompt: &'a str,
426        registry: &'a UnifiedRegistry,
427        tracker: &'a OutcomeTracker,
428    ) -> Self {
429        Self {
430            prompt,
431            registry,
432            tracker,
433            estimated_total_tokens: 0,
434            estimated_input_tokens: 0,
435            estimated_output_tokens: 0,
436            estimated_cache_read_tokens: 0,
437            estimated_cache_write_tokens: 0,
438            has_tools: false,
439            has_vision: false,
440            workload: RoutingWorkload::Interactive,
441            intent: None,
442        }
443    }
444}
445
446/// Credential-backed availability captured once per routing decision. Keychain
447/// and environment state may change between decisions, but never halfway
448/// through filtering/fallback construction for one decision.
449#[derive(Clone, Copy)]
450struct AvailabilitySnapshot {
451    openrouter: Option<crate::openrouter::CredentialSource>,
452}
453
454impl AvailabilitySnapshot {
455    fn capture(registry: &UnifiedRegistry) -> Self {
456        let has_openrouter = registry.list().into_iter().any(|schema| {
457            matches!(
458                schema.source,
459                crate::schema::ModelSource::RemoteApi {
460                    protocol: crate::schema::ApiProtocol::OpenRouter,
461                    ..
462                }
463            )
464        });
465        Self {
466            openrouter: has_openrouter
467                .then(crate::openrouter::credential_source)
468                .flatten(),
469        }
470    }
471
472    fn is_available(self, schema: &ModelSchema) -> bool {
473        match schema.source {
474            crate::schema::ModelSource::RemoteApi {
475                protocol: crate::schema::ApiProtocol::OpenRouter,
476                ..
477            } => self.openrouter.is_some(),
478            _ => schema.available,
479        }
480    }
481}
482
483/// Per-benchmark frontier reference: the best score any builtin-catalog model
484/// reaches on that benchmark. The anchor that maps a benchmark's hard absolute
485/// scale onto the quality tier (`benchmark_frontier_ref`).
486///
487/// Derived from the catalog, computed once, so the calibration is data — not a
488/// hardcoded literal that rots. Adding only weaker models leaves a reference
489/// unchanged (it's a max); a genuinely stronger model raises it and the tier
490/// re-scales. A benchmark no catalog model carries simply isn't here
491/// (→ uncalibrated clamp). Today: `car-judged → 1.0` (gpt-6.1-sol).
492///
493/// INVARIANT (#371): max is a valid frontier anchor *only when a frontier-class
494/// model carries the benchmark*. Today that holds because the benchmarked
495/// entries include Anthropic/OpenAI flagships (the `"frontier"` catalog tag).
496/// The hazard when the catalog grows: a benchmark carried ONLY by mid-tier
497/// models self-anchors low — the best of those weak models gets pinned to CEIL,
498/// inflating it to "best-in-class" on a benchmark whose true frontier is higher.
499///
500/// The guard below enforces the invariant rather than trusting it to hold by
501/// accident of catalog composition: a benchmark is anchored ONLY if at least one
502/// `"frontier"`-tagged model carries it. A benchmark whose entire population is
503/// non-frontier gets NO reference (→ uncalibrated clamp path in
504/// `normalized_benchmark_quality`), so a mid-tier model can never be inflated to
505/// best-in-class on a benchmark whose real frontier is absent from the catalog.
506/// The anchor itself is still the catalog max (NOT a percentile — a percentile
507/// would push the actual champion above CEIL and clamp away the very ordering
508/// signal we want, per review on #371).
509static BENCHMARK_FRONTIER_REFS: std::sync::LazyLock<std::collections::HashMap<String, f64>> =
510    std::sync::LazyLock::new(|| compute_frontier_refs(crate::registry::builtin_catalog().iter()));
511
512/// Build the per-benchmark frontier-reference table from a model population,
513/// applying the #371 frontier-class guard. Factored out of the `LazyLock` so the
514/// guard is testable against synthetic fixtures (the static itself is bound to
515/// the real catalog). See `BENCHMARK_FRONTIER_REFS`.
516fn compute_frontier_refs<'a>(
517    models: impl Iterator<Item = &'a ModelSchema>,
518) -> std::collections::HashMap<String, f64> {
519    // max score per benchmark across ALL carriers, and whether ANY carrier is
520    // frontier-class. Tracked separately so the anchor stays the true champion
521    // score while the *eligibility* gate is the frontier-class presence.
522    let mut max_score: std::collections::HashMap<String, f64> = std::collections::HashMap::new();
523    let mut has_frontier_carrier: std::collections::HashMap<String, bool> =
524        std::collections::HashMap::new();
525    for model in models {
526        let frontier = model_is_frontier_class(model);
527        for b in &model.public_benchmarks {
528            let entry = max_score.entry(b.name.clone()).or_insert(0.0);
529            *entry = entry.max(b.score);
530            let seen = has_frontier_carrier.entry(b.name.clone()).or_insert(false);
531            *seen = *seen || frontier;
532        }
533    }
534    // #371 guard: drop any benchmark with no frontier-class carrier. Its max is
535    // NOT a trustworthy frontier, so it must not self-anchor a mid-tier model to
536    // CEIL — it falls back to the uncalibrated clamp path instead.
537    max_score
538        .into_iter()
539        .filter(|(name, _)| {
540            has_frontier_carrier.get(name).copied().unwrap_or(false) || {
541                tracing::warn!(
542                    benchmark = %name,
543                    "benchmark has no frontier-class carrier in the catalog — \
544                     refusing to self-anchor (uncalibrated clamp). Add a \
545                     frontier-tagged model that carries it, or an explicit \
546                     reference, before it can calibrate the quality tier (#371)."
547                );
548                false
549            }
550        })
551        .collect()
552}
553
554/// A model is *frontier-class* — a trustworthy frontier anchor for any benchmark
555/// it carries (#371) — iff the catalog tags it `"frontier"`. The tag is curated
556/// catalog data (today, among others: claude-opus-4-8, gpt-5.5, the gpt-5.6
557/// sol/terra/luna rows, gpt-6.1-sol), reviewable in the diff, rather than a
558/// name-matching heuristic that would rot as model families turn over.
559///
560/// The tag does a second job: on a **Curated** remote row with no published
561/// benchmark, `schema_quality_estimate` turns it into the 0.85 frontier
562/// routing prior (car-releases#105). Retagging a row for anchor purposes
563/// therefore also moves its unmeasured routing quality.
564fn model_is_frontier_class(model: &ModelSchema) -> bool {
565    model.tags.iter().any(|t| t == "frontier")
566}
567
568impl AdaptiveRouter {
569    pub fn new(hw: HardwareInfo, config: RoutingConfig) -> Self {
570        let circuit_breakers = Arc::new(Mutex::new(
571            CircuitBreakerRegistry::new(3, 300), // 3 failures, 5 min cooldown
572        ));
573        Self {
574            hw,
575            config,
576            circuit_breakers,
577        }
578    }
579
580    pub fn with_default_config(hw: HardwareInfo) -> Self {
581        Self::new(hw, RoutingConfig::default())
582    }
583
584    pub fn config(&self) -> &RoutingConfig {
585        &self.config
586    }
587
588    pub fn set_config(&mut self, config: RoutingConfig) {
589        self.config = config;
590    }
591
592    /// Canonical entry point. The seven `route_*` sibling methods
593    /// each build a `RouteRequest` with their fixed defaults and
594    /// call this — adding a new combinator (per-tenant routing,
595    /// streaming awareness, …) only adds a field here, not another
596    /// public method. Closes #108.
597    pub fn route_with(&self, req: RouteRequest<'_>) -> AdaptiveRoutingDecision {
598        let mut ignore_exclusion = |_: RouterExclusion| {};
599        self.route_with_exclusion_observer(req, &mut ignore_exclusion)
600    }
601
602    /// Route while reporting lanes removed by session rate limits or an open
603    /// circuit before dispatch. The callback is synchronous and receives no
604    /// prompt or provider body, only bounded operational metadata.
605    pub(crate) fn route_with_exclusion_observer(
606        &self,
607        req: RouteRequest<'_>,
608        exclusion_observer: &mut dyn FnMut(RouterExclusion),
609    ) -> AdaptiveRoutingDecision {
610        // Intent-hint precedence over the caller-supplied workload:
611        // `prefer_fast` wins outright (voice fast track is the most
612        // latency-sensitive path we have); `prefer_local` is the
613        // long-standing override; absent either, the caller's
614        // workload stands.
615        let workload = Self::resolve_workload(req.intent, req.workload);
616        self.route_inner_with_intent(
617            req.prompt,
618            req.registry,
619            req.tracker,
620            req.has_tools,
621            req.has_vision,
622            req.estimated_total_tokens,
623            req.estimated_input_tokens,
624            req.estimated_output_tokens,
625            req.estimated_cache_read_tokens,
626            req.estimated_cache_write_tokens,
627            workload,
628            req.intent,
629            exclusion_observer,
630        )
631    }
632
633    /// Route a generation request to the best model.
634    /// If `has_tools` is true, requires ToolUse capability (#13).
635    pub fn route(
636        &self,
637        prompt: &str,
638        registry: &UnifiedRegistry,
639        tracker: &OutcomeTracker,
640    ) -> AdaptiveRoutingDecision {
641        self.route_with(RouteRequest::new(prompt, registry, tracker))
642    }
643
644    /// Route an "editor" request — cheap mechanical work (context compaction,
645    /// edit materialization, title generation, replanning). Uses
646    /// [`RoutingWorkload::Background`] so cost/quality weights favour cheaper
647    /// models, and lets hosting layers express the Aider-style architect/editor
648    /// split without plumbing a raw model-name override through every layer.
649    pub fn route_editor(
650        &self,
651        prompt: &str,
652        registry: &UnifiedRegistry,
653        tracker: &OutcomeTracker,
654    ) -> AdaptiveRoutingDecision {
655        self.route_with(RouteRequest {
656            workload: RoutingWorkload::Background,
657            ..RouteRequest::new(prompt, registry, tracker)
658        })
659    }
660
661    /// Route with tool_use requirement — filters to models that support structured tool calls.
662    pub fn route_with_tools(
663        &self,
664        prompt: &str,
665        registry: &UnifiedRegistry,
666        tracker: &OutcomeTracker,
667    ) -> AdaptiveRoutingDecision {
668        self.route_with(RouteRequest {
669            has_tools: true,
670            ..RouteRequest::new(prompt, registry, tracker)
671        })
672    }
673
674    /// Route with image input requirement — filters to models that support vision.
675    pub fn route_with_vision(
676        &self,
677        prompt: &str,
678        registry: &UnifiedRegistry,
679        tracker: &OutcomeTracker,
680        has_tools: bool,
681    ) -> AdaptiveRoutingDecision {
682        self.route_with(RouteRequest {
683            has_tools,
684            has_vision: true,
685            ..RouteRequest::new(prompt, registry, tracker)
686        })
687    }
688
689    /// Route with caller-supplied intent — see [`crate::IntentHint`].
690    /// The hint can override the auto-detected `InferenceTask`, add hard
691    /// `require` capability filters on top of the prompt-derived ones,
692    /// and bias the score profile toward local models when
693    /// `prefer_local` is set.
694    pub fn route_with_intent<'a>(
695        &self,
696        prompt: &'a str,
697        registry: &'a UnifiedRegistry,
698        tracker: &'a OutcomeTracker,
699        intent: &'a crate::intent::IntentHint,
700    ) -> AdaptiveRoutingDecision {
701        self.route_with(RouteRequest {
702            intent: Some(intent),
703            ..RouteRequest::new(prompt, registry, tracker)
704        })
705    }
706
707    /// Route with context awareness — estimates prompt tokens and prefers models
708    /// whose context window can fit the full prompt without compaction.
709    pub fn route_context_aware(
710        &self,
711        prompt: &str,
712        estimated_total_tokens: usize,
713        registry: &UnifiedRegistry,
714        tracker: &OutcomeTracker,
715        has_tools: bool,
716        has_vision: bool,
717        workload: RoutingWorkload,
718    ) -> AdaptiveRoutingDecision {
719        self.route_with(RouteRequest {
720            estimated_total_tokens,
721            has_tools,
722            has_vision,
723            workload,
724            ..RouteRequest::new(prompt, registry, tracker)
725        })
726    }
727
728    /// Context-aware routing with caller-supplied intent. Same context
729    /// math as [`Self::route_context_aware`]; the intent layers on
730    /// top — task override, additional `require` filters,
731    /// `prefer_local` workload override.
732    pub fn route_context_aware_with_intent<'a>(
733        &self,
734        prompt: &'a str,
735        estimated_total_tokens: usize,
736        registry: &'a UnifiedRegistry,
737        tracker: &'a OutcomeTracker,
738        has_tools: bool,
739        has_vision: bool,
740        workload: RoutingWorkload,
741        intent: &'a crate::intent::IntentHint,
742    ) -> AdaptiveRoutingDecision {
743        self.route_with(RouteRequest {
744            estimated_total_tokens,
745            has_tools,
746            has_vision,
747            workload,
748            intent: Some(intent),
749            ..RouteRequest::new(prompt, registry, tracker)
750        })
751    }
752
753    fn route_inner_with_intent(
754        &self,
755        prompt: &str,
756        registry: &UnifiedRegistry,
757        tracker: &OutcomeTracker,
758        has_tools: bool,
759        has_vision: bool,
760        estimated_total_tokens: usize,
761        estimated_input_tokens: usize,
762        estimated_output_tokens: usize,
763        estimated_cache_read_tokens: usize,
764        estimated_cache_write_tokens: usize,
765        workload: RoutingWorkload,
766        intent: Option<&crate::intent::IntentHint>,
767        exclusion_observer: &mut dyn FnMut(RouterExclusion),
768    ) -> AdaptiveRoutingDecision {
769        let complexity = TaskComplexity::assess(prompt);
770        // Caller intent overrides the prompt-derived task when supplied.
771        let task = intent
772            .and_then(|h| h.task)
773            .map(task_hint_to_inference_task)
774            .unwrap_or_else(|| complexity.inference_task());
775        // A caller that names its task decides the hard capability filter; the
776        // keyword guess applies only when it does not. The guess is substring
777        // matching ("let " in "let me know", "fix" in "prefix", "error" in any
778        // mention of one), so unioning it with an explicit `task=chat` made a
779        // chat turn require a Code model and dropped Apple's on-device model
780        // and the small locals. An explicit `task=code` still requires Code
781        // (the car#52 case).
782        let mut required_caps = base_required_capabilities(complexity, intent, task);
783        if let Some(hint) = intent {
784            for cap in &hint.require {
785                if !required_caps.contains(cap) {
786                    required_caps.push(*cap);
787                }
788            }
789        }
790        if has_vision {
791            required_caps.push(ModelCapability::Vision);
792        }
793        if has_tools {
794            required_caps.push(ModelCapability::ToolUse);
795            // Detect multi-step prompts that need multiple tool calls in one response.
796            // Patterns: numbered lists ("1) ... 2) ..."), multiple instructions, explicit multi-edit.
797            if Self::needs_multi_tool_call(prompt) {
798                required_caps.push(ModelCapability::MultiToolCall);
799            }
800        }
801
802        // Adversarial-reviewer separation (car#358): the set of model ids to
803        // keep OFF the adaptive pick. Each excluded id is unioned with its
804        // MLX-equivalent twin so excluding the GGUF id a user sees in
805        // `models.list` also excludes the MLX model it is actually executed
806        // as (and vice-versa is already covered, since a GGUF candidate is
807        // substituted *to* the MLX id before the filter below). Applied to
808        // both the main scoring path and the cold-start fallbacks.
809        let exclude_set = self.build_exclude_set(intent, registry);
810        let strict_exclusions = intent.is_some_and(|hint| hint.strict_exclusions);
811        let availability = AvailabilitySnapshot::capture(registry);
812
813        // Phase 1: Filter candidates. When the caller is on a deadline
814        // (`require_ready`), first try models that need no download; if that
815        // empties the set, fall back to the unconstrained filter rather than
816        // failing — a slow answer beats no answer (car#638).
817        let want_ready = intent.is_some_and(|h| h.require_ready);
818        let mut candidates = self.filter_candidates(
819            &required_caps,
820            registry,
821            tracker,
822            has_vision,
823            availability,
824            want_ready,
825            exclusion_observer,
826        );
827        if candidates.is_empty() && want_ready {
828            candidates = self.filter_candidates(
829                &required_caps,
830                registry,
831                tracker,
832                has_vision,
833                availability,
834                false,
835                exclusion_observer,
836            );
837        }
838
839        // Fallback: if requiring MultiToolCall eliminates all candidates, drop the
840        // requirement and let the best ToolUse model handle it with multiple round-trips.
841        if candidates.is_empty() && required_caps.contains(&ModelCapability::MultiToolCall) {
842            required_caps.retain(|c| *c != ModelCapability::MultiToolCall);
843            candidates = self.filter_candidates(
844                &required_caps,
845                registry,
846                tracker,
847                has_vision,
848                availability,
849                want_ready,
850                exclusion_observer,
851            );
852            if candidates.is_empty() && want_ready {
853                candidates = self.filter_candidates(
854                    &required_caps,
855                    registry,
856                    tracker,
857                    has_vision,
858                    availability,
859                    false,
860                    exclusion_observer,
861                );
862            }
863        }
864
865        // `on_device_only` is a hard constraint: the engine routes with a
866        // registry holding only models on this machine, and the router holds
867        // to it as well, so a registry that still lists one elsewhere cannot
868        // reach the pick, and nothing left is no route rather than the
869        // complexity default (which may be a model elsewhere, or none at all).
870        if intent.is_some_and(|hint| hint.on_device_only) {
871            candidates.retain(|model| model.is_local());
872            if candidates.is_empty() {
873                return Self::no_on_device_model_decision(complexity, task);
874            }
875        }
876
877        if candidates.is_empty() {
878            // Nothing passed the hard filter — defer to the schema-based
879            // decision, carrying the FULL required-cap set so it can't name a
880            // capability-lacking default.
881            return self.cold_start_decision(
882                complexity,
883                task,
884                &required_caps,
885                registry,
886                has_vision,
887                &exclude_set,
888                strict_exclusions,
889                availability,
890            );
891        }
892
893        // Apple Silicon convergence (#333): a local GGUF candidate that has an
894        // MLX equivalent is actually EXECUTED as that MLX model (the engine's
895        // resolve_mlx_equivalent redirect fires in the execution loop), and —
896        // since the alias-canonicalization fix — outcome feedback is booked
897        // against the MLX id too. Substitute the equivalent into the candidate
898        // set HERE, before scoring/breaker-gating/Thompson reads, so the router
899        // proposes, gates, and learns the SAME canonical id it will run. Without
900        // this the router scores the GGUF id's perpetually-empty profile while
901        // feedback lands on the MLX id, treating a proven model as forever
902        // underexplored. Dedup by id (two GGUF candidates can map to one MLX
903        // equivalent, or the MLX model may already be a direct candidate);
904        // order is preserved so downstream tie-breaks are unchanged.
905        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
906        {
907            let mut seen = std::collections::HashSet::new();
908            candidates = candidates
909                .into_iter()
910                .map(|m| registry.resolve_mlx_equivalent(&m).cloned().unwrap_or(m))
911                .filter(|m| seen.insert(m.id.clone()))
912                .collect();
913        }
914
915        // Adversarial-reviewer separation (car#358): drop caller-excluded
916        // model ids so a reviewer is never the model that produced the work.
917        // Applied AFTER the MLX-equivalent substitution above, and the
918        // exclude set already carries both id forms, so it matches whichever
919        // canonical id a candidate now wears. Soft by default: if excluding
920        // empties the set, keep the full set — a same-model review beats no
921        // review. Callers enforcing a separation boundary opt into a refusal
922        // instead with `strict_exclusions`.
923        if !exclude_set.is_empty() {
924            let filtered: Vec<ModelSchema> = candidates
925                .iter()
926                .filter(|m| !exclude_set.contains(&m.id))
927                .cloned()
928                .collect();
929            if filtered.is_empty() {
930                if strict_exclusions {
931                    return Self::no_eligible_model_decision(complexity, task, exclude_set.len());
932                }
933                tracing::debug!(
934                    ?exclude_set,
935                    "model exclusion left no candidates; keeping full set"
936                );
937            } else {
938                candidates = filtered;
939            }
940        }
941
942        candidates = self.apply_quality_first_bootstrap_policy(
943            candidates, task, tracker, has_vision, has_tools, workload,
944        );
945
946        // Context-aware filtering: if we know the prompt size, prefer models that fit.
947        // Phase 1b: separate candidates into "fits" and "needs compaction" groups.
948        let (fits, needs_compaction_candidates) = if estimated_total_tokens > 0 {
949            let mut fits = Vec::new();
950            let mut tight = Vec::new();
951            for m in &candidates {
952                if m.context_length == 0 || m.context_length >= estimated_total_tokens {
953                    fits.push(m.clone());
954                } else {
955                    tight.push(m.clone());
956                }
957            }
958            (fits, tight)
959        } else {
960            (candidates.clone(), Vec::new())
961        };
962
963        // Prefer models that fit; fall back to compaction-required models if none fit
964        let (scoring_candidates, compaction_needed) = if !fits.is_empty() {
965            (fits, false)
966        } else if !needs_compaction_candidates.is_empty() {
967            tracing::info!(
968                prompt_tokens = estimated_total_tokens,
969                candidates = needs_compaction_candidates.len(),
970                "no model fits full prompt — compaction will be needed"
971            );
972            (needs_compaction_candidates.clone(), true)
973        } else {
974            (candidates.clone(), false)
975        };
976
977        // (Outcome-first banding is applied at SELECTION below — not as a
978        // pre-filter — so every candidate is still scored and remains available
979        // as a fallback. A worse model beats no model if the primary errors.)
980
981        // Phase 2: Score candidates (with context headroom bonus)
982        let pricing_input_tokens = estimated_input_tokens
983            .max(estimated_total_tokens.saturating_sub(estimated_output_tokens));
984        let scored = self.score_candidates_context_aware(
985            &scoring_candidates,
986            task,
987            tracker,
988            estimated_total_tokens,
989            pricing_input_tokens,
990            estimated_output_tokens,
991            estimated_cache_read_tokens,
992            estimated_cache_write_tokens,
993            workload,
994        );
995        // Routing forensics — `RUST_LOG=car_inference::adaptive_router=debug`
996        // shows the workload, candidate set, and scores. The live agent-build
997        // shakedown used this to confirm prefer_quality picks the best FITTING
998        // model (capable models can be filtered out by the memory budget on a
999        // small machine — that's a hardware ceiling, not a routing bug).
1000        tracing::debug!(
1001            ?workload,
1002            ?task,
1003            candidates = ?scoring_candidates.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(),
1004            ?scored,
1005            "route scoring"
1006        );
1007
1008        // Reliability band (neo design): the SELECTED model must come from the
1009        // band — the models within OUTCOME_FIRST_BAND of the best reliability —
1010        // so cost/latency/bonuses break ties WITHIN the band but can never flip
1011        // a real quality gap. All candidates were still scored above and stay in
1012        // the fallback chain. The `Quality` lane bands too (its argmax then runs
1013        // over the blended score WITHIN the band); explicit Fastest/Background/
1014        // LocalPreferred lanes skip it (subsumption: honor the caller's
1015        // cost/latency/local preference). See `applies_reliability_band`.
1016        // band_ids: the reliability band, or on a lane without it the models
1017        // that clear MIN_ANSWER_QUALITY (None when nothing is excluded).
1018        // Computed once and reused for BOTH selection and fallback ordering.
1019        let band_ids: Option<std::collections::HashSet<String>> =
1020            if Self::applies_reliability_band(task, workload) {
1021                Some(
1022                    self.outcome_first_band(scoring_candidates.clone(), task, tracker)
1023                        .into_iter()
1024                        .map(|m| m.id)
1025                        .collect(),
1026                )
1027            } else {
1028                // Lanes without the band still choose among models that answer.
1029                self.competent_candidates(&scoring_candidates, task, tracker)
1030            };
1031        if let Some(ref ids) = band_ids {
1032            // Observability (neo): the band confidently enforces whatever
1033            // reliability() says, so make its decision visible — a stale/miskeyed
1034            // prior shows up as a surprising band here rather than silently
1035            // shaping every route.
1036            tracing::debug!(
1037                ?task,
1038                band = Self::OUTCOME_FIRST_BAND,
1039                band_size = ids.len(),
1040                "outcome-first: selection restricted to reliability band"
1041            );
1042        }
1043        let selection_pool: Vec<(String, f64)> = if let Some(ref ids) = band_ids {
1044            let pool: Vec<(String, f64)> = scored
1045                .iter()
1046                .filter(|(id, _)| ids.contains(id))
1047                .cloned()
1048                .collect();
1049            if pool.is_empty() {
1050                // Structurally unreachable: the band is computed from
1051                // `scoring_candidates` and `scored` maps the same slice, so a
1052                // banded id is always present. If it ever isn't, the Quality
1053                // lane would argmax over the FULL set and the bonus-floating bug
1054                // returns — make that invariant load-bearing, not incidental.
1055                debug_assert!(
1056                    false,
1057                    "reliability band produced no in-`scored` ids (workload {workload:?})"
1058                );
1059                scored.clone()
1060            } else {
1061                pool
1062            }
1063        } else {
1064            scored.clone()
1065        };
1066
1067        // Phase 3: selection. Quality-critical requests (prefer_quality) take
1068        // the highest-scored candidate DETERMINISTICALLY — no Thompson
1069        // exploration. Exploration trades the best model now for learning
1070        // signal later, which is exactly wrong for an infrequent, one-shot,
1071        // correctness-critical call (building/verifying an agent, deriving a
1072        // contract): a weak model loses the work, and there's no stream of
1073        // repeats for the learning to pay back. Crucially the argmax runs over
1074        // `selection_pool`, which the Quality lane HAS restricted to the
1075        // reliability band above — so the deterministic pick is the best
1076        // blended score AMONG the top-reliability models, not a low-quality
1077        // model that floated up on local/MLX bonuses. Every other workload keeps
1078        // Thompson sampling (within the band when one applies).
1079        let (selected_id, strategy) = if workload == RoutingWorkload::Quality {
1080            selection_pool
1081                .iter()
1082                .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
1083                .map(|(id, _)| (id.clone(), RoutingStrategy::SchemaBased))
1084                .unwrap_or_else(|| self.select_with_thompson_sampling(&selection_pool, tracker))
1085        } else {
1086            self.select_with_thompson_sampling(&selection_pool, tracker)
1087        };
1088
1089        // Build fallback chain. Under outcome-first, in-band (high-reliability)
1090        // models come BEFORE out-of-band ones, so a retry after a primary
1091        // failure still prefers a quality-acceptable model over a cheaper-but-
1092        // worse one — the band must hold for positions 1..n, not just the
1093        // primary (neo). Blended-score order (from `scored`) is preserved within
1094        // each group; `partition` is stable.
1095        let mut fallbacks: Vec<String> = if let Some(ref ids) = band_ids {
1096            let (in_band, out_band): (Vec<String>, Vec<String>) = scored
1097                .iter()
1098                .filter(|(id, _)| *id != selected_id)
1099                .map(|(id, _)| id.clone())
1100                .partition(|id| ids.contains(id));
1101            in_band.into_iter().chain(out_band).collect()
1102        } else {
1103            scored
1104                .iter()
1105                .filter(|(id, _)| *id != selected_id)
1106                .map(|(id, _)| id.clone())
1107                .collect()
1108        };
1109        // Add compaction candidates to the end of the fallback chain
1110        if !compaction_needed {
1111            for m in &needs_compaction_candidates {
1112                if m.id != selected_id && !fallbacks.contains(&m.id) {
1113                    fallbacks.push(m.id.clone());
1114                }
1115            }
1116        }
1117
1118        let predicted_quality = scored
1119            .iter()
1120            .find(|(id, _)| *id == selected_id)
1121            .map(|(_, score)| *score)
1122            .unwrap_or(0.5);
1123
1124        let selected_schema = registry
1125            .get(&selected_id)
1126            .or_else(|| registry.find_by_name(&selected_id));
1127        let model_name = selected_schema
1128            .map(|m| m.name.clone())
1129            .unwrap_or_else(|| selected_id.clone());
1130        let context_length = selected_schema.map(|m| m.context_length).unwrap_or(0);
1131
1132        let needs_compact = compaction_needed
1133            || (estimated_total_tokens > 0
1134                && context_length > 0
1135                && estimated_total_tokens > context_length);
1136
1137        let compaction_note = if needs_compact {
1138            format!(
1139                " [compaction needed: {}→{}tok]",
1140                estimated_total_tokens, context_length
1141            )
1142        } else {
1143            String::new()
1144        };
1145
1146        let cost_quality_note = selected_schema
1147            .map(|schema| {
1148                let is_openrouter = schema.tags.iter().any(|tag| tag == "openrouter");
1149                if is_openrouter && schema.tags.iter().any(|tag| tag == "cheap") {
1150                    let prices = schema.cost.prices_for(pricing_input_tokens);
1151                    format!(
1152                        ", low-cost tier (${:.3}/MTok input, ${:.3}/MTok output at {} prompt tokens)",
1153                        prices.input_per_mtok.unwrap_or(0.0),
1154                        prices.output_per_mtok.unwrap_or(0.0),
1155                        pricing_input_tokens,
1156                    )
1157                } else if is_openrouter && model_is_frontier_class(schema) {
1158                    ", frontier quality tier".to_string()
1159                } else {
1160                    String::new()
1161                }
1162            })
1163            .unwrap_or_default();
1164
1165        let reason = format!(
1166            "{:?} task → {} via {:?} (quality: {:.2}, {} candidates{}){}",
1167            complexity,
1168            model_name,
1169            strategy,
1170            predicted_quality,
1171            scoring_candidates.len(),
1172            cost_quality_note,
1173            compaction_note,
1174        );
1175
1176        // Advisory candidate ranking (Task 7): expose the full scored set so a
1177        // caller can see the tradeoff the router resolved on their behalf —
1178        // which models were eligible (in_band), their quality-only reliability,
1179        // their blended score, and which one won. `scored` is already in
1180        // descending blended-score order. When not outcome-first (band_ids is
1181        // None) every candidate is eligible, so in_band = true.
1182        let candidates: Vec<RouteCandidate> = scored
1183            .iter()
1184            .map(|(id, score)| {
1185                // Every id in `scored` came out of `scoring_candidates`
1186                // (`score_candidates_context_aware` maps over that same slice),
1187                // so this lookup is structurally unreachable-to-miss; 0.0 is an
1188                // unreachable floor, not a value the router can legitimately
1189                // emit. The debug_assert is the canary if those two ever diverge.
1190                let reliability = match scoring_candidates.iter().find(|m| m.id == *id) {
1191                    Some(m) => self.reliability(m, task, tracker),
1192                    None => {
1193                        debug_assert!(false, "scored id {id} absent from scoring_candidates");
1194                        0.0
1195                    }
1196                };
1197                let now = crate::outcome::now_unix();
1198                let external_grades = tracker
1199                    .profile(id)
1200                    .and_then(|p| p.task_stats(task))
1201                    .map(|ts| {
1202                        ts.external_grades
1203                            .iter()
1204                            .map(|(source, grade)| RouteExternalGrade {
1205                                source: source.clone(),
1206                                quality: grade.quality,
1207                                observations: grade.observations,
1208                                evidence: if grade.is_stale_at(now) {
1209                                    0
1210                                } else {
1211                                    grade.evidence()
1212                                },
1213                            })
1214                            .collect()
1215                    })
1216                    .unwrap_or_default();
1217                RouteCandidate {
1218                    model_id: id.clone(),
1219                    reliability,
1220                    score: *score,
1221                    selected: *id == selected_id,
1222                    in_band: band_ids.as_ref().is_none_or(|b| b.contains(id)),
1223                    external_grades,
1224                }
1225            })
1226            .collect();
1227
1228        AdaptiveRoutingDecision {
1229            model_id: selected_id,
1230            model_name,
1231            task,
1232            complexity,
1233            reason,
1234            strategy,
1235            predicted_quality,
1236            fallbacks,
1237            context_length,
1238            needs_compaction: needs_compact,
1239            candidates,
1240        }
1241    }
1242
1243    /// Route to the best embedding model.
1244    pub fn route_embedding(&self, registry: &UnifiedRegistry) -> String {
1245        let embed_models = registry.query_by_capability(ModelCapability::Embed);
1246        embed_models
1247            .first()
1248            .map(|m| m.name.clone())
1249            .unwrap_or_else(|| "Qwen3-Embedding-0.6B".to_string())
1250    }
1251
1252    /// Route to the smallest available model (for classification).
1253    pub fn route_small(&self, registry: &UnifiedRegistry) -> String {
1254        let gen_models = registry.query_by_capability(ModelCapability::Generate);
1255        // Pick smallest by size
1256        gen_models
1257            .iter()
1258            .filter(|m| m.is_local())
1259            .min_by_key(|m| m.size_mb())
1260            .map(|m| m.name.clone())
1261            .unwrap_or_else(|| "Qwen3-0.6B".to_string())
1262    }
1263
1264    // --- Internal phases ---
1265
1266    // --- Scoring constants ---
1267
1268    /// Latency ceiling: requests taking longer than this score 0.0.
1269    const LATENCY_CEILING_MS: f64 = 10000.0;
1270    /// Answer rate assumed for a remote row with no measured one: the median
1271    /// of the ten frontier rows car-bench measured on 2026-10-26 (68.8–134
1272    /// tok/s, end to end).
1273    const UNMEASURED_REMOTE_TPS: f64 = 93.0;
1274    /// TPS ceiling: models faster than this score 1.0.
1275    const _TPS_CEILING: f64 = 150.0;
1276    /// MoE throughput penalty for Candle: naive expert routing runs at ~10% of
1277    /// declared TPS. Applies to *declared* (hand-seeded) rates only — see
1278    /// `schema_latency_estimate`.
1279    const MOE_TPS_MULTIPLIER: f64 = 0.10;
1280    /// MoE throughput multiplier for MLX: fused Metal kernels run at ~50% of
1281    /// declared TPS. Same caveat as [`Self::MOE_TPS_MULTIPLIER`].
1282    const MLX_MOE_TPS_MULTIPLIER: f64 = 0.50;
1283    /// Cost ceiling: models costing more than this per 1K output tokens score 0.0.
1284    const COST_CEILING_PER_1K: f64 = 0.1;
1285    /// Local preference bonus added to the weighted score (before normalization).
1286    const LOCAL_BONUS: f64 = 0.15;
1287    /// True on platforms where local inference has a GPU/NPU backend
1288    /// (Apple Silicon with MLX). On Intel Macs and on hosts built with
1289    /// `--cfg=car_skip_mlx`, local inference falls through to candle/
1290    /// CPU which is far slower than cloud for non-trivial models. The
1291    /// router suppresses LOCAL_BONUS when this is false so cloud
1292    /// models rank fairly instead of losing to a CPU-bound 4B that
1293    /// will take 30s to first-token.
1294    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1295    const HAS_GPU_BACKEND: bool = true;
1296    #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
1297    const HAS_GPU_BACKEND: bool = false;
1298    /// Extra bonus for MLX models on Apple Silicon (stacks with LOCAL_BONUS).
1299    /// MLX gets fused Metal kernels and better memory layout vs Candle on Mac.
1300    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1301    const MLX_BONUS: f64 = 0.10;
1302    /// Extra bonus for system-owned models — no model file on disk, no
1303    /// download to provision, framework-managed memory. Currently only
1304    /// `apple/foundation:default` qualifies. Tag-driven so future
1305    /// system-LLM integrations (e.g. Android AICore) inherit the
1306    /// scoring without a code change. Stacks with LOCAL_BONUS but
1307    /// **excludes** MLX_BONUS (system models aren't MLX); the net
1308    /// effect is FoundationModels ranks roughly even with a
1309    /// well-warmed MLX 4B for short fast-turn tasks instead of
1310    /// strictly losing because its catalog `tokens_per_second` /
1311    /// `size_mb` are null.
1312    const SYSTEM_LLM_BONUS: f64 = 0.12;
1313
1314    // --- Internal phases ---
1315
1316    /// Phase 1: Filter by hard constraints.
1317    /// Build the set of model ids to exclude from the adaptive pick from a
1318    /// caller's `IntentHint::exclude_models` (car#358). Each id is unioned
1319    /// with its MLX-equivalent twin (on Apple Silicon) so excluding the
1320    /// GGUF id a user holds also excludes the MLX model it is executed as.
1321    ///
1322    /// Each entry is resolved through the registry by id *or* by name, and the
1323    /// canonical `ModelSchema.id` is added alongside the caller's string. The
1324    /// filter downstream compares `ModelSchema.id`, so without this an entry
1325    /// naming a model the way `InferenceResult::model_used` reports it —
1326    /// `schema.name` — matched nothing and the exclusion was a silent no-op.
1327    /// That is not a hypothetical: `model_used` is the only identifier a caller
1328    /// holding a result *has*, and for every personal-OpenRouter model the two
1329    /// differ (`id = openrouter/google/gemini-3.1-pro-preview`, `name =
1330    /// google/gemini-3.1-pro-preview`) — which is exactly the fallback lane
1331    /// coder contract derivation must be able to rotate away from (car#889).
1332    pub(crate) fn build_exclude_set(
1333        &self,
1334        intent: Option<&crate::intent::IntentHint>,
1335        registry: &UnifiedRegistry,
1336    ) -> std::collections::HashSet<String> {
1337        let mut set = std::collections::HashSet::new();
1338        let Some(hint) = intent else {
1339            return set;
1340        };
1341        for id in &hint.exclude_models {
1342            set.insert(id.clone());
1343            let Some(schema) = registry.get(id).or_else(|| registry.find_by_name(id)) else {
1344                continue;
1345            };
1346            set.insert(schema.id.clone());
1347            #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
1348            {
1349                if let Some(mlx) = registry.resolve_mlx_equivalent(schema) {
1350                    set.insert(mlx.id.clone());
1351                }
1352            }
1353        }
1354        set
1355    }
1356
1357    fn filter_candidates(
1358        &self,
1359        required_caps: &[ModelCapability],
1360        registry: &UnifiedRegistry,
1361        tracker: &OutcomeTracker,
1362        has_vision: bool,
1363        availability: AvailabilitySnapshot,
1364        require_ready: bool,
1365        exclusion_observer: &mut dyn FnMut(RouterExclusion),
1366    ) -> Vec<ModelSchema> {
1367        let mut candidates = registry
1368            .list()
1369            .into_iter()
1370            .filter(|m| {
1371                // Must have all required capabilities
1372                if !required_caps.iter().all(|c| m.has_capability(*c)) {
1373                    return false;
1374                }
1375                // Hard exclusion (car-releases#50): `mlx-vlm-cli`
1376                // models route exclusively through the mlx-vlm CLI
1377                // for image inference and the backend hard-rejects
1378                // text-only input ("text-only-on-mlx-vlm-id not
1379                // implemented"). They advertise `generate`, so a
1380                // text-only request (e.g. task=classify) would
1381                // otherwise pass the capability filter and get
1382                // dispatched, only to fail at the backend. A
1383                // text-only request must never select one.
1384                if !has_vision && m.tags.iter().any(|t| t == "mlx-vlm-cli") {
1385                    return false;
1386                }
1387                // Must be available (downloaded for local, API key set for remote)
1388                if !availability.is_available(m) {
1389                    return false;
1390                }
1391                // Caller is on a deadline and asked for models that need no
1392                // download first (car#638). `available` doesn't answer that for
1393                // MLX — a declared hf_repo makes it true before anything is
1394                // fetched — so gate on the narrower `weights_ready`. Soft: the
1395                // caller re-runs without the constraint if this empties the set
1396                // (see `filter_ready_soft` at the call site).
1397                if require_ready && !m.weights_ready {
1398                    return false;
1399                }
1400                // Local models must fit in memory (strict: >= excludes models at the limit)
1401                if m.is_local() && m.size_mb() >= self.hw.max_model_mb {
1402                    return false;
1403                }
1404                // Hard latency constraint
1405                if let Some(max) = self.config.max_latency_ms {
1406                    if let Some(p50) = m.performance.latency_p50_ms {
1407                        if p50 > max {
1408                            return false;
1409                        }
1410                    }
1411                }
1412                // Hard cost constraint
1413                if let Some(max) = self.config.max_cost_usd {
1414                    if m.cost_per_1k_output() > max {
1415                        return false;
1416                    }
1417                }
1418                // Hard exclusion: prefer_local=false excludes all local models (#12)
1419                if !self.config.prefer_local && m.is_local() {
1420                    return false;
1421                }
1422                // Hard exclusion: rate-limited models excluded for this session (#13)
1423                if tracker.is_excluded(&m.id) {
1424                    exclusion_observer(RouterExclusion {
1425                        lane: m.id.clone(),
1426                        reason: RouterExclusionReason::RateLimited,
1427                        until: None,
1428                    });
1429                    return false;
1430                }
1431                // Circuit breaker: skip models with consecutive failures (#25)
1432                if let Ok(mut cb) = self.circuit_breakers.lock() {
1433                    if !cb.allow_request(&m.id) {
1434                        let until = cb.blocked_until(&m.id);
1435                        tracing::debug!(model = %m.id, ?until, "skipped by circuit breaker");
1436                        exclusion_observer(RouterExclusion {
1437                            lane: m.id.clone(),
1438                            reason: RouterExclusionReason::CircuitOpen,
1439                            until,
1440                        });
1441                        return false;
1442                    }
1443                }
1444                true
1445            })
1446            .cloned()
1447            .collect::<Vec<_>>();
1448        // A deprecated row is superseded in the catalog, often for cause
1449        // (glm-4.7-flash:4bit loops on every vllm-mlx version), and nothing
1450        // should suggest it. It stays usable by name, and stays a candidate
1451        // when it is the only one left, so an install holding nothing else
1452        // still gets an answer.
1453        if candidates.iter().any(|m| !m.deprecated) {
1454            candidates.retain(|m| !m.deprecated);
1455        }
1456        candidates
1457    }
1458
1459    fn apply_quality_first_bootstrap_policy(
1460        &self,
1461        candidates: Vec<ModelSchema>,
1462        task: InferenceTask,
1463        tracker: &OutcomeTracker,
1464        has_vision: bool,
1465        has_tools: bool,
1466        workload: RoutingWorkload,
1467    ) -> Vec<ModelSchema> {
1468        // Only a plain interactive request is biased toward trusted remotes.
1469        // `prefer_local` (LocalPreferred) and `prefer_fast` (Fastest) already
1470        // say what the caller wants: removing every local model there sent a
1471        // request that asked for a local model to the cloud whenever any key
1472        // was set, and every voice turn to a remote frontier model.
1473        if !self.config.quality_first_cold_start
1474            || workload != RoutingWorkload::Interactive
1475            || !self.is_quality_critical_bootstrap_task(task, has_vision, has_tools)
1476        {
1477            return candidates;
1478        }
1479
1480        let trusted_remote: Vec<ModelSchema> = candidates
1481            .iter()
1482            .filter(|model| self.is_trusted_quality_remote(model))
1483            .cloned()
1484            .collect();
1485
1486        if trusted_remote.is_empty() {
1487            return candidates;
1488        }
1489
1490        // A local with a measured benchmark is proven when its quality is
1491        // within the reliability band of the best trusted remote here: the
1492        // same "no real quality gap" test the Quality lane applies.
1493        let best_remote_quality = trusted_remote
1494            .iter()
1495            .map(|model| self.schema_quality_estimate(model))
1496            .fold(0.0_f64, f64::max);
1497        let proven_local: Vec<ModelSchema> = candidates
1498            .iter()
1499            .filter(|model| {
1500                model.is_local()
1501                    && (self.is_local_model_proven_for_task(model, task, tracker)
1502                        || (!model.public_benchmarks.is_empty()
1503                            && self.schema_quality_estimate(model)
1504                                >= best_remote_quality - Self::OUTCOME_FIRST_BAND))
1505            })
1506            .cloned()
1507            .collect();
1508
1509        if !proven_local.is_empty() {
1510            // A proven local competes with the trusted remotes on the blended
1511            // score rather than replacing them: interactive latency still
1512            // counts.
1513            return trusted_remote.into_iter().chain(proven_local).collect();
1514        }
1515
1516        trusted_remote
1517    }
1518
1519    /// Phase 2: Score candidates with context awareness.
1520    /// Applies a headroom bonus to models with more context window for the prompt.
1521    /// When estimated_total_tokens is 0, no context bonus/penalty is applied.
1522    fn score_candidates_context_aware(
1523        &self,
1524        candidates: &[ModelSchema],
1525        task: InferenceTask,
1526        tracker: &OutcomeTracker,
1527        estimated_total_tokens: usize,
1528        estimated_input_tokens: usize,
1529        estimated_output_tokens: usize,
1530        estimated_cache_read_tokens: usize,
1531        estimated_cache_write_tokens: usize,
1532        workload: RoutingWorkload,
1533    ) -> Vec<(String, f64)> {
1534        let mut scored: Vec<(String, f64)> = candidates
1535            .iter()
1536            .map(|m| {
1537                let base_score = self.score_model(
1538                    m,
1539                    task,
1540                    tracker,
1541                    workload,
1542                    estimated_input_tokens,
1543                    estimated_output_tokens,
1544                    estimated_cache_read_tokens,
1545                    estimated_cache_write_tokens,
1546                );
1547                // Context headroom bonus: prefer models with more room to spare.
1548                // Max bonus: 0.10 (at 4x headroom or more). No bonus if unknown.
1549                let headroom_bonus = if estimated_total_tokens > 0 && m.context_length > 0 {
1550                    let ratio = m.context_length as f64 / estimated_total_tokens as f64;
1551                    if ratio >= 1.0 {
1552                        (ratio.min(4.0) - 1.0) / 3.0 * 0.10 // 0.0 at exact fit, 0.10 at 4x
1553                    } else {
1554                        -0.15 // Penalty for models that require compaction
1555                    }
1556                } else {
1557                    0.0
1558                };
1559                (m.id.clone(), base_score + headroom_bonus)
1560            })
1561            .collect();
1562
1563        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1564        scored
1565    }
1566
1567    /// Score a single model. All sub-scores are in [0.0, 1.0].
1568    /// Final score = weighted sum + local_bonus, so range is [0.0, ~1.15].
1569    /// Scoring weights `(quality, latency, cost)`, adjusted for the task.
1570    ///
1571    /// [`RoutingWorkload::weights`] is workload-shaped only — it has no
1572    /// notion that the request is code generation. The default
1573    /// interactive profile is `(0.45 quality, 0.40 latency, 0.15 cost)`,
1574    /// where quality barely edges out latency, so a fast cheap general
1575    /// model can out-score a code-tuned one on a `task=code` request
1576    /// (car-releases#52). Code correctness is worth waiting a beat for:
1577    /// under an interactive-class workload `task=code` is reweighted to
1578    /// prioritise quality > speed > cost explicitly. `Fastest` (the
1579    /// voice fast-track) is an explicit hard-latency demand and is left
1580    /// untouched; `Batch`/`Background` already encode a deliberate
1581    /// non-interactive caller choice and stay as-is.
1582    fn task_aware_weights(task: InferenceTask, workload: RoutingWorkload) -> (f64, f64, f64) {
1583        match (task, workload) {
1584            (
1585                InferenceTask::Code,
1586                RoutingWorkload::Interactive | RoutingWorkload::LocalPreferred,
1587            ) => (0.62, 0.28, 0.10),
1588            _ => workload.weights(),
1589        }
1590    }
1591
1592    /// Pure reliability estimate for a model on a task — how good its answers
1593    /// are ([`Self::answer_quality`]) times the share of its calls that
1594    /// produce one — with no cost, latency, or platform bonuses. This is the
1595    /// band key for outcome-first routing: cost may only break ties WITHIN a
1596    /// reliability band, never form it (neo's design — banding on the blended
1597    /// score would mean you never stopped blending).
1598    ///
1599    /// Failed calls say nothing about answer quality, so they never move the
1600    /// quality EMA (`outcome::QUALITY_EVIDENCE_VERSION`); they enter here, as
1601    /// availability. The rate is `(completed + 1) / (resolved + 1)`: an
1602    /// unobserved model keeps its full estimate, and three failures with no
1603    /// success quarter it, which is what moves a deterministic prefer_quality
1604    /// pick off a model that is not answering.
1605    fn reliability(
1606        &self,
1607        model: &ModelSchema,
1608        task: InferenceTask,
1609        tracker: &OutcomeTracker,
1610    ) -> f64 {
1611        let completion = tracker.profile(&model.id).map_or(1.0, |p| {
1612            (p.success_count as f64 + 1.0) / ((p.success_count + p.fail_count) as f64 + 1.0)
1613        });
1614        self.answer_quality(model, task, tracker) * completion
1615    }
1616
1617    /// Answer quality alone: the observed task EMA blended with the cold-start
1618    /// schema prior.
1619    fn answer_quality(
1620        &self,
1621        model: &ModelSchema,
1622        task: InferenceTask,
1623        tracker: &OutcomeTracker,
1624    ) -> f64 {
1625        let schema_quality = self.schema_quality_estimate(model);
1626        let Some(p) = tracker.profile(&model.id) else {
1627            // No profile at all — sit at the schema estimate.
1628            return schema_quality;
1629        };
1630
1631        // Read the quality EMA together with the two evidence sources behind
1632        // it, from the SAME stats unit (task-specific if present, else overall)
1633        // — never pair a task-level score with an overall count.
1634        //   - `n`: benchmark cases that seeded the EMA (cold-start prior).
1635        //   - `g`: *graded* live observations that have moved the EMA — accept/
1636        //     edit/reject signals, NOT mechanical successes or failures (those
1637        //     are availability, read by `reliability` and the circuit breaker; see
1638        //     `outcome::QUALITY_EVIDENCE_VERSION`).
1639        //
1640        // A task entry holding only external grades has no served evidence of
1641        // its own (its `ema_quality` is the `Default` 0.0, not a measurement),
1642        // so served evidence then comes from the overall profile.
1643        let task_stats = p.task_stats(task);
1644        let (ema, n, g) = task_stats
1645            .filter(|ts| ts.has_served_evidence())
1646            .map(|ts| {
1647                (
1648                    ts.ema_quality,
1649                    ts.prior_sample_size,
1650                    ts.quality_observations,
1651                )
1652            })
1653            .unwrap_or((p.ema_quality, p.prior_sample_size, p.quality_observations));
1654        // A `benchmark_priors.json` seed is a few raw cases on its own 0..1
1655        // scale. A catalog benchmark is the calibrated measurement the schema
1656        // estimate already stands on, so where one exists the seed adds
1657        // nothing but scale error: an April seed of four trivial cases lifted
1658        // a 0.6B model from its 0.30 schema estimate to about 0.65.
1659        let n = if model.public_benchmarks.is_empty() {
1660            n
1661        } else {
1662            0
1663        };
1664        // External grades (`outcomes.record_external_grade`): graded results
1665        // for this model and task that did not come from a call CAR served.
1666        // Pooled with per-source, per-reporter and total caps
1667        // (`TaskStats::external_evidence_at`), at
1668        // benchmark weight (1 per case, not W) — they are offline evidence.
1669        let (external, external_mean) = task_stats
1670            .map(|ts| ts.external_evidence())
1671            .unwrap_or((0.0, 0.0));
1672
1673        // A single Bayesian shrinkage of the EMA toward the schema estimate,
1674        // weighted by the total quality evidence behind it. Evidence pools two
1675        // sources at DIFFERENT unit weights:
1676        //
1677        //     e = n + W*g
1678        //     reliability = schema + e/(e+K) * (ema - schema)
1679        //
1680        // `schema_quality_estimate` is the prior mean (catalog `public_benchmarks`
1681        // when present, else a size/trust heuristic). Keying the weight on
1682        // graded evidence — not `total_calls` — is the load-bearing choice:
1683        //   - A model with many *ungraded* successes has g == 0, so (with n == 0)
1684        //     its EMA carries no weight and reliability sits at the schema
1685        //     estimate. This stops a frontier model whose production traffic is
1686        //     never graded from collapsing to a hollow 0.5 and being buried
1687        //     under smaller models.
1688        //   - A sparse cold-start benchmark (small n, g == 0) barely moves off
1689        //     schema — the 0.6B-shadowing fix. The cold-start path is identical
1690        //     whatever W is, since g == 0 there.
1691        //   - A live grade counts as `W` benchmark cases (W > 1): on-distribution
1692        //     production signal earns and revokes trust faster than a
1693        //     distribution-shifted offline case, so an intermittently-failing
1694        //     model — below the circuit breaker's *consecutive*-failure
1695        //     threshold — is still distrusted at live speed.
1696        // n == g == 0 ⇒ reliability == schema exactly, so a healthy model is
1697        // never demoted toward neutral.
1698        //
1699        // External grades join the pool as a second observed mean: the target
1700        // is the evidence-weighted mean of the served EMA (weight n + W*g) and
1701        // the external mean (weight = capped external cases). With no external
1702        // grades this is exactly the formula above.
1703        let served = n as f64 + Self::LIVE_GRADE_WEIGHT * g as f64;
1704        let evidence = served + external;
1705        if evidence <= 0.0 {
1706            return schema_quality;
1707        }
1708        let observed = (served * ema + external * external_mean) / evidence;
1709        let confidence = evidence / (evidence + Self::PRIOR_SHRINK_PSEUDOCOUNT);
1710        (schema_quality + confidence * (observed - schema_quality)).clamp(0.0, 1.0)
1711    }
1712
1713    /// Quality-tier band that normalized published benchmarks map into. A hard
1714    /// agentic benchmark's raw score (e.g. tau-bench-airline ~0.65 for a
1715    /// frontier model) is remapped `[0, frontier_ref] → [FLOOR, CEIL]` so the
1716    /// measured cluster lands on the same scale as — and the frontier above —
1717    /// the size/trust heuristics. FLOOR is the bottom of the measured band;
1718    /// local size-heuristics are capped below it, so a benchmarked frontier
1719    /// model always wins the quality term over an unmeasured local.
1720    const BENCH_TIER_FLOOR: f64 = 0.40;
1721    /// Below this fraction of a benchmark's frontier score, the mapping runs
1722    /// straight to 0 instead of stopping at `BENCH_TIER_FLOOR`. The floor is
1723    /// what a weak but working model earns; a model that fails most of a suite
1724    /// is not one. Without the knee, measuring Qwen3.5-0.8B at 0.054 on
1725    /// car-judged raised it from its 0.30 size proxy to 0.43, and its 364 tok/s
1726    /// then won every `prefer_fast` route.
1727    const BENCH_TIER_KNEE: f64 = 0.5;
1728    const BENCH_TIER_CEIL: f64 = 0.92;
1729
1730    /// Quality-tier band for an *uncalibrated* (frontier-less) benchmark carried
1731    /// by a LOCAL model — a self-measured score on a suite no frontier model
1732    /// anchors (e.g. `car-bench-run`'s agentic suite, #368). Such a score is NOT
1733    /// commensurable with a frontier-calibrated score (the monoculture trap: 0.5
1734    /// on an easy local suite ≠ 0.5 on tau-bench-airline), so it must NOT land in
1735    /// the measured-frontier band `[BENCH_TIER_FLOOR, BENCH_TIER_CEIL]`. Instead
1736    /// it maps into this LOCAL sub-band, deliberately aligned with the local
1737    /// size-heuristic span (`schema_quality_estimate`: 0.6B→0.30 … 30B→0.60) so
1738    /// measured capability *replaces the size proxy* for ordering locals against
1739    /// each other, while the "a benchmarked frontier model out-ranks any local on
1740    /// the quality term" invariant (`local_size_heuristic_stays_below_measured_frontier_floor`)
1741    /// is preserved. A frontier-anchored (calibrated) benchmark a local happens
1742    /// to carry still maps to the full band — the anchor makes it comparable.
1743    const LOCAL_BENCH_TIER_FLOOR: f64 = 0.30;
1744    const LOCAL_BENCH_TIER_CEIL: f64 = 0.60;
1745
1746    /// Cold-start quality for an unmeasured remote (no `public_benchmarks`).
1747    /// Conservative-unknown: below the measured-frontier band, above small
1748    /// locals. Curated (configured/signed) sits above Community (auto-discovered,
1749    /// unvetted).
1750    const UNMEASURED_REMOTE_CURATED: f64 = 0.60;
1751    const UNMEASURED_REMOTE_COMMUNITY: f64 = 0.48;
1752    /// A reviewed `frontier` tag on a Curated row is a coarse quality-class
1753    /// claim, not a benchmark score. It gives newly-curated frontier models a
1754    /// useful prior, for every provider (native OpenAI/Anthropic/Google rows,
1755    /// subscription rows, OpenRouter rows alike), while remaining below the
1756    /// measured frontier ceiling (0.92); live graded outcomes can still move
1757    /// it in either direction. `balanced` is the same claim one class down.
1758    const CURATED_FRONTIER_TAG_PRIOR: f64 = 0.85;
1759    const CURATED_BALANCED_TAG_PRIOR: f64 = 0.72;
1760
1761    /// Evidence weight of one live graded outcome relative to one offline
1762    /// benchmark case, in the `e = n + W*g` pool. Live grades are sparse but
1763    /// on-distribution ground truth about *this* deployment; benchmark cases
1764    /// are plentiful but distribution-shifted. Weighting live signal higher
1765    /// restores failure responsiveness — an intermittently-failing model, below
1766    /// the circuit breaker's consecutive-failure threshold, is distrusted at
1767    /// live speed — without disturbing the cold-start path, where `g == 0`
1768    /// makes the weight irrelevant. Deliberate, revisitable asymmetry; 2.0 is a
1769    /// conservative default (a live grade ≈ two benchmark cases). Not tuned
1770    /// against production data — a follow-up may calibrate `W` and `K` jointly.
1771    const LIVE_GRADE_WEIGHT: f64 = 2.0;
1772
1773    /// Pseudo-observations of the schema prior mean blended into a model's
1774    /// quality EMA before it is trusted. The EMA is weighted `e / (e + K)`
1775    /// against the schema estimate, where `e = n + g` is the total quality
1776    /// evidence behind it (`n` benchmark cases + `g` graded live observations):
1777    /// one unit barely moves reliability off the schema prior, ~20 land near
1778    /// the raw EMA. K = 4 ≈ "trust the EMA once ~4+ units of evidence back it";
1779    /// small enough that a genuinely dense signal dominates, large enough that
1780    /// a 1–2 observation run can't pin an extreme. Mirrors the Beta-prior
1781    /// strength the Thompson layer already uses, applied one level up.
1782    const PRIOR_SHRINK_PSEUDOCOUNT: f64 = 4.0;
1783
1784    /// Quality-band width for outcome-first routing (absolute reliability).
1785    /// Narrow on purpose: only models within this reliability of the best are
1786    /// eligible, so a real quality gap can never be flipped by a cheaper/faster
1787    /// model. ~0.02 keeps the benchmarked/frontier clusters distinct while
1788    /// letting genuinely near-equal models compete on cost/latency.
1789    ///
1790    /// COUPLING WARNING: the `Quality` lane consumes this band with NO Thompson
1791    /// exploration (deterministic argmax) — its in-band tie-break is purely the
1792    /// blended score, where local/MLX bonuses (~0.25) dominate the ~0.02
1793    /// reliability spread. On the normal lanes the band also bounds exploration
1794    /// breadth, so it's tempting to widen it for learning behavior — but
1795    /// widening it would silently enlarge the Quality lane's bonus-flippable
1796    /// zone, letting a meaningfully-worse-but-bonused model win the high-stakes
1797    /// lane. Do not widen this casually for normal-lane tuning; split the
1798    /// constant per-lane first if Quality ever needs a different width.
1799    const OUTCOME_FIRST_BAND: f64 = 0.02;
1800
1801    /// The answer quality below which a model cannot answer well enough to be
1802    /// chosen by any lane while another candidate can. Lanes that skip the
1803    /// band (`Fastest`, `Background`, `LocalPreferred`) still honor speed,
1804    /// cost and locality, but among models that answer: without this,
1805    /// `prefer_fast` picked Qwen3.5-0.8B (car-judged 0.054) for its 364 tok/s.
1806    /// Every unmeasured size proxy (0.30 and up) clears it.
1807    ///
1808    /// It reads [`Self::answer_quality`], not [`Self::reliability`]: failed
1809    /// calls never decay, so three transient failures before a first success
1810    /// would put even a frontier model under it in every lane for good.
1811    /// Availability is the completion rate's and the circuit breaker's job.
1812    const MIN_ANSWER_QUALITY: f64 = 0.25;
1813
1814    /// Resolve the effective routing workload from the caller's intent, falling
1815    /// back to the request's base workload. Precedence: `high_stakes` (a
1816    /// consequential/irreversible operation — best model regardless of cost or
1817    /// latency) wins outright, then `prefer_fast`, then `prefer_quality`, then
1818    /// `prefer_local`.
1819    fn resolve_workload(
1820        intent: Option<&crate::intent::IntentHint>,
1821        base: RoutingWorkload,
1822    ) -> RoutingWorkload {
1823        match intent {
1824            Some(h) if h.high_stakes => RoutingWorkload::Quality,
1825            Some(h) if h.prefer_fast => RoutingWorkload::Fastest,
1826            Some(h) if h.prefer_quality => RoutingWorkload::Quality,
1827            Some(h) if h.prefer_local => RoutingWorkload::LocalPreferred,
1828            _ => base,
1829        }
1830    }
1831
1832    /// Whether selection is restricted to the reliability band — the models
1833    /// within `OUTCOME_FIRST_BAND` of the best reliability. Within the band,
1834    /// cost/latency/bonuses break ties; across it, a real quality gap holds.
1835    ///
1836    /// - `Quality` (prefer_quality / high_stakes): ALWAYS bands. The lane's
1837    ///   deterministic argmax runs over the *blended* score, so without the band
1838    ///   a lower-quality model could win on additive local/MLX bonuses — exactly
1839    ///   the "prefer_quality doesn't prefer quality" bug. Banding first makes the
1840    ///   argmax choose among the top-reliability models only.
1841    /// - `Fastest` / `Background` / `LocalPreferred`: never band. These are
1842    ///   explicit cost/latency/local preferences — honor them (subsumption). In
1843    ///   particular `LocalPreferred` MUST skip the band, or an explicit "prefer
1844    ///   local" request gets overridden by a benchmarked frontier remote.
1845    /// - Normal lanes (`Interactive` / `Batch`): band substantive tasks
1846    ///   (Code/Reasoning), where quality matters most.
1847    fn applies_reliability_band(task: InferenceTask, workload: RoutingWorkload) -> bool {
1848        match workload {
1849            RoutingWorkload::Quality => true,
1850            RoutingWorkload::Fastest
1851            | RoutingWorkload::Background
1852            | RoutingWorkload::LocalPreferred => false,
1853            _ => matches!(task, InferenceTask::Code | InferenceTask::Reasoning),
1854        }
1855    }
1856
1857    /// The candidates that clear [`Self::MIN_ANSWER_QUALITY`], or `None` when
1858    /// every candidate does, or none does (then nothing is excluded).
1859    fn competent_candidates(
1860        &self,
1861        candidates: &[ModelSchema],
1862        task: InferenceTask,
1863        tracker: &OutcomeTracker,
1864    ) -> Option<std::collections::HashSet<String>> {
1865        let competent: std::collections::HashSet<String> = candidates
1866            .iter()
1867            .filter(|m| self.answer_quality(m, task, tracker) >= Self::MIN_ANSWER_QUALITY)
1868            .map(|m| m.id.clone())
1869            .collect();
1870        (!competent.is_empty() && competent.len() < candidates.len()).then_some(competent)
1871    }
1872
1873    /// Keep only candidates within [`Self::OUTCOME_FIRST_BAND`] reliability of
1874    /// the best. The best always survives (gap 0), so this never empties the
1875    /// set. Cost/latency and Thompson exploration then operate downstream only
1876    /// within the surviving band.
1877    fn outcome_first_band(
1878        &self,
1879        candidates: Vec<ModelSchema>,
1880        task: InferenceTask,
1881        tracker: &OutcomeTracker,
1882    ) -> Vec<ModelSchema> {
1883        if candidates.len() <= 1 {
1884            return candidates;
1885        }
1886        let best = candidates
1887            .iter()
1888            .map(|m| self.reliability(m, task, tracker))
1889            .fold(f64::MIN, f64::max);
1890        candidates
1891            .into_iter()
1892            // +epsilon so an exact-boundary gap (e.g. 0.70-0.68 = 0.0200000…2 in
1893            // f64) isn't excluded by floating-point error.
1894            .filter(|m| {
1895                best - self.reliability(m, task, tracker) <= Self::OUTCOME_FIRST_BAND + 1e-9
1896            })
1897            .collect()
1898    }
1899
1900    fn score_model(
1901        &self,
1902        model: &ModelSchema,
1903        task: InferenceTask,
1904        tracker: &OutcomeTracker,
1905        workload: RoutingWorkload,
1906        estimated_input_tokens: usize,
1907        estimated_output_tokens: usize,
1908        estimated_cache_read_tokens: usize,
1909        estimated_cache_write_tokens: usize,
1910    ) -> f64 {
1911        let profile = tracker.profile(&model.id);
1912        let schema_latency = self.schema_latency_estimate(model);
1913        let (quality_weight, latency_weight, cost_weight) =
1914            Self::task_aware_weights(task, workload);
1915
1916        // Quality (reliability) — factored into `reliability()` so the
1917        // outcome-first band keys on it alone; here it's the quality term of
1918        // the within-band blend.
1919        let quality = self.reliability(model, task, tracker);
1920
1921        // Latency: same blending as quality — don't trust a single observation more
1922        // than schema estimates. This prevents routing oscillation on first few calls.
1923        let latency = match profile {
1924            Some(p) if p.total_calls >= self.config.min_observations => {
1925                let avg = p
1926                    .task_stats(task)
1927                    .filter(|ts| ts.calls > 0 || ts.avg_latency_ms > 0.0)
1928                    .map(|ts| ts.avg_latency_ms)
1929                    .unwrap_or_else(|| p.avg_latency_ms());
1930                self.latency_ms_to_score(avg)
1931            }
1932            Some(p) if p.total_calls == 0 => p
1933                .task_stats(task)
1934                .filter(|ts| ts.avg_latency_ms > 0.0)
1935                .map(|ts| self.latency_ms_to_score(ts.avg_latency_ms))
1936                .unwrap_or(schema_latency),
1937            Some(p) if p.total_calls > 0 => {
1938                let observed = self.latency_ms_to_score(
1939                    p.task_stats(task)
1940                        .filter(|ts| ts.calls > 0 || ts.avg_latency_ms > 0.0)
1941                        .map(|ts| ts.avg_latency_ms)
1942                        .unwrap_or_else(|| p.avg_latency_ms()),
1943                );
1944                let w = p.total_calls as f64 / self.config.min_observations as f64;
1945                schema_latency * (1.0 - w) + observed * w
1946            }
1947            _ => schema_latency,
1948        };
1949
1950        // Cost score (lower is better → invert)
1951        let cost = if model.is_local() {
1952            1.0
1953        } else if model.cost.output_per_mtok.is_none() {
1954            // Unknown remote pricing (e.g. an auto-discovered Community model
1955            // whose cost is cleared until curated/learned). Do NOT score it as
1956            // free — `cost_per_1k_output()` returns 0.0 for unknown cost, which
1957            // would yield a perfect 1.0 cost score and let an unvetted model
1958            // out-rank curated ones with known nonzero pricing. Use a neutral
1959            // mid value until the price is known.
1960            0.5
1961        } else {
1962            let normalized_cost_per_1k = if estimated_input_tokens
1963                + estimated_output_tokens
1964                + estimated_cache_read_tokens
1965                + estimated_cache_write_tokens
1966                > 0
1967            {
1968                let estimated_cost = model.cost.estimated_usd(
1969                    estimated_input_tokens,
1970                    estimated_output_tokens,
1971                    estimated_cache_read_tokens,
1972                    estimated_cache_write_tokens,
1973                );
1974                estimated_cost * 1000.0
1975                    / (estimated_input_tokens + estimated_output_tokens).max(1) as f64
1976            } else {
1977                model.cost_per_1k_output()
1978            };
1979            (1.0 - (normalized_cost_per_1k / Self::COST_CEILING_PER_1K)).clamp(0.0, 1.0)
1980        };
1981
1982        // Suppress LOCAL_BONUS on hosts without a GPU/NPU backend.
1983        // Without it, an Intel Mac or a `car_skip_mlx` build would
1984        // pick a CPU-bound local 4B over a comparable cloud model
1985        // every time, then surprise the user with 30s+ first-token
1986        // latency. Cloud loses on cost/privacy in normal scoring;
1987        // dropping the local bonus lets it win on the latency that
1988        // actually matters when the GPU isn't there. Tracing emits
1989        // when this fires so the silent degradation becomes visible.
1990        //
1991        // BUT that rationale is purely about latency: it only holds for
1992        // latency-sensitive workloads. `Background`/`Batch` callers have
1993        // explicitly accepted a latency hit, so a CPU-bound local model
1994        // is a fine choice there — and its cost/privacy win should
1995        // stand. Keeping the bonus for non-latency-sensitive workloads
1996        // also stops an unmeasured remote (now scored a conservative 0.60,
1997        // not the old flat 0.5) from out-ranking a proven local on a GPU-less
1998        // host for background work, which is the wrong call for cheap
1999        // offline jobs.
2000        let gpu_backend_or_latency_tolerant =
2001            Self::HAS_GPU_BACKEND || !workload.is_latency_sensitive();
2002        let local_bonus = if self.config.prefer_local
2003            && model.is_local()
2004            && gpu_backend_or_latency_tolerant
2005        {
2006            Self::LOCAL_BONUS
2007        } else {
2008            if self.config.prefer_local && model.is_local() && !Self::HAS_GPU_BACKEND {
2009                tracing::debug!(
2010                    model = %model.id,
2011                    "LOCAL_BONUS suppressed: no GPU backend on this host (Intel Mac or car_skip_mlx) for a latency-sensitive workload; cloud models will rank higher"
2012                );
2013            }
2014            0.0
2015        };
2016        let workload_local_bonus = if model.is_local() {
2017            workload.local_bonus()
2018        } else {
2019            0.0
2020        };
2021
2022        // On Apple Silicon, prefer MLX models over Candle equivalents
2023        #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2024        let mlx_bonus = if model.is_mlx() { Self::MLX_BONUS } else { 0.0 };
2025        #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2026        let mlx_bonus = 0.0;
2027
2028        // vLLM-MLX bonus: continuous batching gives better multi-agent throughput
2029        let vllm_mlx_bonus = if model.is_vllm_mlx() {
2030            Self::LOCAL_BONUS + 0.05
2031        } else {
2032            0.0
2033        };
2034
2035        // System-LLM bonus: catalog tag-driven so it generalizes beyond
2036        // FoundationModels. Models tagged `low_latency` AND `private`
2037        // are zero-cost system-owned LLMs (apple/foundation:default
2038        // today; AICore-on-Android etc. in the future). They don't
2039        // appear in `is_mlx()` but deserve to compete with MLX 4B on
2040        // routing — the catalog's tags carry that intent and the
2041        // router now honors it.
2042        let system_llm_bonus = if model.tags.iter().any(|t| t == "low_latency")
2043            && model.tags.iter().any(|t| t == "private")
2044        {
2045            Self::SYSTEM_LLM_BONUS
2046        } else {
2047            0.0
2048        };
2049
2050        // Under a Quality workload (prefer_quality / high_stakes) the objective
2051        // is answer quality — not locality or hardware affinity. Every bonus
2052        // below exists for latency / cost / privacy, exactly what the caller
2053        // deprioritized by asking for quality. Left in, they stack to +0.25 on
2054        // Apple Silicon (LOCAL_BONUS 0.15 + MLX_BONUS 0.10) and let a small
2055        // local MLX model out-*bonus* a more capable remote whose quality lead
2056        // is under 0.25 — which then OOMs on a large prompt it can't fit.
2057        // Suppress them here so quality (and the reliability band) decides.
2058        // Models still compete on quality * quality_weight, so a genuinely
2059        // better local model still wins; it just no longer gets a free thumb.
2060        let (local_bonus, workload_local_bonus, mlx_bonus, vllm_mlx_bonus, system_llm_bonus) =
2061            if workload == RoutingWorkload::Quality {
2062                (0.0, 0.0, 0.0, 0.0, 0.0)
2063            } else {
2064                (
2065                    local_bonus,
2066                    workload_local_bonus,
2067                    mlx_bonus,
2068                    vllm_mlx_bonus,
2069                    system_llm_bonus,
2070                )
2071            };
2072
2073        quality_weight * quality
2074            + latency_weight * latency
2075            + cost_weight * cost
2076            + local_bonus
2077            + workload_local_bonus
2078            + mlx_bonus
2079            + vllm_mlx_bonus
2080            + system_llm_bonus
2081    }
2082
2083    /// Convert latency in ms to a [0, 1] score. Used by both schema and observed paths
2084    /// so the scales are consistent (fixes Linus review issue #2).
2085    fn latency_ms_to_score(&self, ms: f64) -> f64 {
2086        (1.0 - (ms / Self::LATENCY_CEILING_MS)).clamp(0.0, 1.0)
2087    }
2088
2089    /// Convert TPS to estimated latency in ms (for a typical 200-token response).
2090    fn tps_to_latency_ms(tps: f64) -> f64 {
2091        if tps <= 0.0 {
2092            return Self::LATENCY_CEILING_MS;
2093        }
2094        // Assume ~200 tokens per response as baseline
2095        (200.0 / tps) * 1000.0
2096    }
2097
2098    /// Detect whether a prompt likely needs multiple tool calls in a single response.
2099    /// Looks for numbered lists, multiple explicit instructions, multi-edit patterns.
2100    fn needs_multi_tool_call(prompt: &str) -> bool {
2101        let lower = prompt.to_lowercase();
2102
2103        // Numbered list patterns: "1) ... 2) ..." or "1. ... 2. ..."
2104        let has_numbered_list = {
2105            let mut count = 0u32;
2106            for i in 1..=5u32 {
2107                if lower.contains(&format!("{}) ", i)) || lower.contains(&format!("{}. ", i)) {
2108                    count += 1;
2109                }
2110            }
2111            count >= 2
2112        };
2113
2114        // Explicit multi-action keywords
2115        let multi_keywords = [
2116            "multiple edits",
2117            "several changes",
2118            "three changes",
2119            "two changes",
2120            "all of the following",
2121            "each of these",
2122            "do both",
2123            "do all",
2124            "and also",
2125            "additionally",
2126            "as well as",
2127            "then also",
2128        ];
2129        let has_multi_keywords = multi_keywords.iter().any(|kw| lower.contains(kw));
2130
2131        // Bullet point lists with action verbs
2132        let bullet_actions = lower.matches("- add ").count()
2133            + lower.matches("- update ").count()
2134            + lower.matches("- change ").count()
2135            + lower.matches("- remove ").count()
2136            + lower.matches("- fix ").count()
2137            + lower.matches("- edit ").count()
2138            + lower.matches("- implement ").count()
2139            + lower.matches("- create ").count();
2140        let has_bullet_list = bullet_actions >= 2;
2141
2142        has_numbered_list || has_multi_keywords || has_bullet_list
2143    }
2144
2145    /// Schema-based quality estimate (cold start), on a single commensurable
2146    /// 0..1 "quality tier" scale, in priority order:
2147    ///
2148    /// 1. **Published benchmarks**, NORMALIZED onto the tier scale. A raw
2149    ///    benchmark score is NOT directly comparable to the size/trust
2150    ///    heuristics: hard agentic benchmarks (tau-bench, SWE-bench) report low
2151    ///    absolute pass-rates even for frontier models (tau-bench-airline tops
2152    ///    out ~0.65), so taking the raw average ranked every *measured* model
2153    ///    below every *unmeasured* local/remote — the exact inversion this
2154    ///    function exists to prevent. `normalized_benchmark_quality` maps each
2155    ///    calibrated benchmark's raw score through its frontier reference so
2156    ///    best-in-class lands near the top of the tier band.
2157    /// 2. **Unmeasured remotes**: on a Curated row, a reviewed `frontier` /
2158    ///    `balanced` quality-class tag provides a coarse prior (0.85 / 0.72)
2159    ///    below the measured-frontier ceiling, whatever the provider;
2160    ///    otherwise conservative-unknown (0.60), below measured frontier and
2161    ///    above small locals. Community rows take no tag prior (0.48).
2162    /// 3. **Local models**: a size proxy, CAPPED below the measured-frontier
2163    ///    floor. We have no evidence a local matches a benchmarked frontier
2164    ///    model on quality, so an unmeasured local must not out-rank one on the
2165    ///    quality term — local-first survives via the cost/privacy bonuses in
2166    ///    `score_model`, not by an inflated quality claim.
2167    fn schema_quality_estimate(&self, model: &ModelSchema) -> f64 {
2168        if !model.public_benchmarks.is_empty() {
2169            return self.normalized_benchmark_quality(model).clamp(0.0, 1.0);
2170        }
2171        if model.is_remote() {
2172            // An UNMEASURED remote's quality is unknown until it carries real
2173            // benchmarks or accumulates live outcomes. A curated row's reviewed
2174            // `frontier` / `balanced` tag is a coarse quality-class claim, so
2175            // it earns that tier's prior — whichever provider serves it. This
2176            // used to apply to OpenRouter rows only, which left every native
2177            // curated frontier row (gpt-5.6-sol, claude-opus-5, the
2178            // openai-codex subscription rows) at the untagged 0.60 below
2179            // `gpt-5.4-mini` and a 4B local on a prefer_quality code route
2180            // (car-releases#105). Both priors stay under the measured ceiling
2181            // (0.92), so a benchmarked frontier model still outranks them on
2182            // evidence, and live graded outcomes move them either way. An
2183            // untagged curated remote keeps the conservative-unknown 0.60, and
2184            // Community (auto-discovered, unvetted) rows never take a tag
2185            // prior: their tags are inherited from a template, not reviewed.
2186            if model.trust_tier == crate::schema::TrustTier::Curated {
2187                if model_is_frontier_class(model) {
2188                    return Self::CURATED_FRONTIER_TAG_PRIOR;
2189                }
2190                if model.tags.iter().any(|tag| tag == "balanced") {
2191                    return Self::CURATED_BALANCED_TAG_PRIOR;
2192                }
2193            }
2194            return match model.trust_tier {
2195                crate::schema::TrustTier::Community => Self::UNMEASURED_REMOTE_COMMUNITY,
2196                _ => Self::UNMEASURED_REMOTE_CURATED,
2197            };
2198        }
2199        // Local size proxy, capped below `BENCH_TIER_FLOOR` (the bottom of the
2200        // measured band) so a benchmarked frontier model always wins the
2201        // quality term. Ordering by size is preserved.
2202        match model.size_mb() {
2203            0 => 0.35,             // unknown local — conservative
2204            s if s < 1000 => 0.30, // 0.6B
2205            s if s < 2000 => 0.38, // 1.7B
2206            s if s < 3000 => 0.45, // 4B
2207            s if s < 6000 => 0.52, // 8B
2208            _ => 0.60,             // 30B+: diminishing returns, still < frontier
2209        }
2210    }
2211
2212    /// Average of each published benchmark's score mapped onto the quality-tier
2213    /// scale. A *calibrated* benchmark (one with a known `frontier_ref`) is
2214    /// linearly remapped `[0, frontier_ref] → [FLOOR, CEIL]` so best-in-class
2215    /// lands near CEIL. An *uncalibrated* benchmark (no frontier anchor) is
2216    /// clamped into a band by model tier: a **remote** model's score clamps into
2217    /// the measured band `[FLOOR, CEIL]` (frontier-pending — a new benchmark a
2218    /// frontier model carries before its reference lands); a **local** model's
2219    /// score clamps into the LOCAL sub-band `[LOCAL_BENCH_TIER_FLOOR,
2220    /// LOCAL_BENCH_TIER_CEIL]` (a self-measured local suite isn't commensurable
2221    /// with the frontier scale — see `LOCAL_BENCH_TIER_FLOOR`).
2222    fn normalized_benchmark_quality(&self, model: &ModelSchema) -> f64 {
2223        let is_local = model.is_local();
2224        let n = model.public_benchmarks.len() as f64;
2225        let sum: f64 = model
2226            .public_benchmarks
2227            .iter()
2228            .map(|b| match Self::benchmark_frontier_ref(&b.name) {
2229                Some(frontier_ref) => {
2230                    // #370: a score ABOVE the derived frontier_ref clamps to CEIL
2231                    // and silently collapses into the top band — two above-frontier
2232                    // models become one bucket where cost breaks the tie, and the
2233                    // catalog's champion anchor is now stale. Surface it as a
2234                    // re-anchor candidate instead of swallowing it. (frontier_ref is
2235                    // the builtin-catalog max, so this fires only for a model NOT in
2236                    // that catalog — e.g. a freshly registered model — which is
2237                    // exactly the case that should prompt a catalog update.)
2238                    if b.score > frontier_ref {
2239                        tracing::warn!(
2240                            model = %model.id,
2241                            benchmark = %b.name,
2242                            score = b.score,
2243                            frontier_ref,
2244                            "model scores ABOVE the catalog frontier_ref — re-anchor \
2245                             candidate: update builtin_catalog.json so this benchmark's \
2246                             champion calibrates the quality tier (#370)"
2247                        );
2248                    }
2249                    let frac = (b.score / frontier_ref).clamp(0.0, 1.0);
2250                    let tier = |f: f64| {
2251                        Self::BENCH_TIER_FLOOR
2252                            + f * (Self::BENCH_TIER_CEIL - Self::BENCH_TIER_FLOOR)
2253                    };
2254                    if frac >= Self::BENCH_TIER_KNEE {
2255                        tier(frac)
2256                    } else {
2257                        tier(Self::BENCH_TIER_KNEE) * frac / Self::BENCH_TIER_KNEE
2258                    }
2259                }
2260                // Uncalibrated benchmark (no frontier_ref): we don't know its
2261                // scale, so CLAMP into a band rather than pass through raw (a raw
2262                // hard-benchmark score like SWE-bench ~0.35 could land BELOW the
2263                // floor and re-invert the scale bug this function fixes). The band
2264                // depends on the model tier:
2265                None if is_local => {
2266                    // LOCAL self-measured (frontier-less) benchmark → the LOCAL
2267                    // sub-band, NOT the measured-frontier band. A local model's
2268                    // raw score on an unanchored suite isn't comparable to a
2269                    // frontier-calibrated score, so it must not be lifted into the
2270                    // frontier band; instead measured capability replaces the size
2271                    // proxy WITHIN the local tier (#368). See `LOCAL_BENCH_TIER_FLOOR`.
2272                    b.score
2273                        .clamp(Self::LOCAL_BENCH_TIER_FLOOR, Self::LOCAL_BENCH_TIER_CEIL)
2274                }
2275                // REMOTE, frontier-pending: a new benchmark a frontier model
2276                // carries before its catalog reference is added. Clamp into the
2277                // measured band (the catalog-coverage test guards against a real
2278                // benchmark silently staying uncalibrated for long).
2279                None => b.score.clamp(Self::BENCH_TIER_FLOOR, Self::BENCH_TIER_CEIL),
2280            })
2281            .sum();
2282        // NOTE: for a LOCAL model carrying BOTH a calibrated (frontier-anchored,
2283        // → [0.40,0.92]) and an uncalibrated (→ [0.30,0.60]) benchmark, this mean
2284        // straddles the two bands — the result is not on a single scale. That's a
2285        // conservative pull (the local arm only lowers the mean, never inflates
2286        // it) and doesn't break the frontier-wins invariant; today #368's locals
2287        // carry only the self-measured suite, so the mean stays single-band.
2288        sum / n
2289    }
2290
2291    /// Raw score a frontier-class model reaches on a known published benchmark
2292    /// — the reference that calibrates that benchmark's hard absolute scale onto
2293    /// the quality tier (`[0, frontier_ref] → [FLOOR, CEIL]`, so best-in-class
2294    /// lands at CEIL). `None` ⇒ the benchmark isn't carried by any catalog model
2295    /// (or its best score is non-positive) → uncalibrated clamp path.
2296    ///
2297    /// DERIVED from the catalog (see `BENCHMARK_FRONTIER_REFS`) rather than
2298    /// hardcoded: it can't drift out of sync, it auto-anchors when a stronger
2299    /// model ships, and it auto-calibrates any new benchmark a catalog model
2300    /// carries — removing the "add a benchmark, forget its reference, silently
2301    /// mis-rank" footgun the old hardcoded `match` invited.
2302    fn benchmark_frontier_ref(name: &str) -> Option<f64> {
2303        BENCHMARK_FRONTIER_REFS
2304            .get(name)
2305            .copied()
2306            .filter(|r| *r > 0.0)
2307    }
2308
2309    /// Schema-based latency estimate (cold start).
2310    ///
2311    /// Converts declared TPS/p50 to the same ms-based score used by observed data,
2312    /// so there's no discontinuity when the first observation arrives.
2313    fn schema_latency_estimate(&self, model: &ModelSchema) -> f64 {
2314        let is_moe = model.tags.contains(&"moe".to_string());
2315
2316        if model.is_local() {
2317            if let Some(tps) = model.performance.tokens_per_second {
2318                // The MoE discount corrects a *declared* rate — a hand-seeded
2319                // estimate written as if the model were dense. It must not be
2320                // applied to a rate that was measured, which already reflects
2321                // however fast expert routing actually decodes; doing so scores
2322                // the model at half or a tenth of its observed throughput.
2323                //
2324                // `latency_p50_ms` is the marker, and not incidentally:
2325                // `scripts/bench-consolidate.py` writes a p50 only alongside a
2326                // real measured `tokens_per_second`, precisely so a throttled
2327                // run's bogus whole-generation "TTFT" cannot be seeded. A
2328                // present p50 on a local row therefore means the decode rate
2329                // beside it came from a benchmark, not from someone's estimate.
2330                let measured = model.performance.latency_p50_ms.is_some();
2331                // The discounts describe two in-process backends' expert
2332                // routing. A managed vllm-mlx row has neither: its rate comes
2333                // from car-bench, and vllm-mlx reports no TTFT, so it never
2334                // carries the p50 marker. Discounting it scored Qwen3.6-35B-A3B
2335                // (measured 88.9 tok/s) at 8.9 and zeroed its latency term.
2336                let multiplier = match &model.source {
2337                    _ if !is_moe || measured => 1.0,
2338                    crate::schema::ModelSource::Mlx { .. } => Self::MLX_MOE_TPS_MULTIPLIER,
2339                    crate::schema::ModelSource::Local { .. } => Self::MOE_TPS_MULTIPLIER,
2340                    _ => 1.0,
2341                };
2342                let effective_tps = tps * multiplier;
2343                // A local row's p50 is a measured time to first token, paid
2344                // before the first decoded token.
2345                let first_token_ms = model.performance.latency_p50_ms.unwrap_or(0) as f64;
2346                let estimated_ms = first_token_ms + Self::tps_to_latency_ms(effective_tps);
2347                return self.latency_ms_to_score(estimated_ms);
2348            }
2349            return 0.5; // local, no declared TPS
2350        }
2351
2352        // Remote, on the local scale: the time to a whole answer. A measured
2353        // remote rate comes from car-bench's non-streaming calls (output
2354        // tokens over the whole call), so it already includes the time to
2355        // the first token. A declared p50 is time to first token only; before
2356        // this, it was compared as-is against a local model's full answer
2357        // time, so a 600 ms Haiku p50 read four times faster than the ~2 s
2358        // its answers take.
2359        if let Some(tps) = model.performance.tokens_per_second {
2360            return self.latency_ms_to_score(Self::tps_to_latency_ms(tps));
2361        }
2362        if let Some(p50) = model.performance.latency_p50_ms {
2363            return self.latency_ms_to_score(
2364                p50 as f64 + Self::tps_to_latency_ms(Self::UNMEASURED_REMOTE_TPS),
2365            );
2366        }
2367        0.3 // remote, no declared latency
2368    }
2369
2370    fn is_quality_critical_bootstrap_task(
2371        &self,
2372        task: InferenceTask,
2373        has_vision: bool,
2374        has_tools: bool,
2375    ) -> bool {
2376        has_vision
2377            || has_tools
2378            || matches!(
2379                task,
2380                InferenceTask::Generate | InferenceTask::Code | InferenceTask::Reasoning
2381            )
2382    }
2383
2384    fn is_trusted_quality_remote(&self, model: &ModelSchema) -> bool {
2385        let trusted_provider = matches!(
2386            model.provider.as_str(),
2387            "openai" | "anthropic" | "google" | "openrouter"
2388        );
2389        model.is_remote()
2390            && model.trust_tier == crate::schema::TrustTier::Curated
2391            && (trusted_provider || crate::openrouter::is_managed_gateway_schema(model))
2392            && !model.has_capability(ModelCapability::SpeechToText)
2393            && !model.has_capability(ModelCapability::TextToSpeech)
2394    }
2395
2396    fn is_local_model_proven_for_task(
2397        &self,
2398        model: &ModelSchema,
2399        task: InferenceTask,
2400        tracker: &OutcomeTracker,
2401    ) -> bool {
2402        let Some(profile) = tracker.profile(&model.id) else {
2403            return false;
2404        };
2405        if let Some(task_stats) = profile.task_stats(task) {
2406            if task_stats.calls >= self.config.bootstrap_min_task_observations
2407                && task_stats.ema_quality >= self.config.bootstrap_quality_floor
2408            {
2409                return true;
2410            }
2411        }
2412
2413        profile.total_calls >= self.config.bootstrap_min_task_observations
2414            && profile.ema_quality >= self.config.bootstrap_quality_floor
2415    }
2416
2417    /// Highest Beta mean a Thompson draw is centered on. The pool's best
2418    /// score maps here rather than to 1.0, where a Beta draw has no spread.
2419    const THOMPSON_TOP_MEAN: f64 = 0.95;
2420
2421    /// Phase 3: Thompson Sampling selection.
2422    ///
2423    /// Each model draws from a Beta distribution centered on its Phase 2
2424    /// score relative to the pool's best. The concentration is `prior_strength` plus the model's
2425    /// observed calls, so a model with few observations draws widely
2426    /// (exploration) and a well-observed one draws close to its score
2427    /// (exploitation).
2428    fn select_with_thompson_sampling(
2429        &self,
2430        scored: &[(String, f64)],
2431        tracker: &OutcomeTracker,
2432    ) -> (String, RoutingStrategy) {
2433        if scored.is_empty() {
2434            return (String::new(), RoutingStrategy::SchemaBased);
2435        }
2436
2437        let mut rng = rand::rng();
2438        let mut best_sample = f64::NEG_INFINITY;
2439        let mut best_id = scored[0].0.clone();
2440        let mut best_strategy = RoutingStrategy::SchemaBased;
2441
2442        // Scores run past 1.0 once locality bonuses stack, so they are taken
2443        // relative to the pool's best. Clamping them to 1.0 instead gave every
2444        // bonus-heavy candidate the same draw.
2445        let top_score = scored
2446            .iter()
2447            .map(|(_, score)| *score)
2448            .fold(f64::MIN_POSITIVE, f64::max);
2449
2450        for (id, phase2_score) in scored {
2451            let profile = tracker.profile(id);
2452
2453            // Outcomes reach the draw once, through the Phase 2 score, which
2454            // already carries graded quality and observed latency. Counted
2455            // again as Beta successes, 900 completed calls of a mediocre model
2456            // outweighed any score a better one could carry.
2457            let calls = profile.map_or(0, |p| p.total_calls);
2458            let mean = (Self::THOMPSON_TOP_MEAN * (phase2_score / top_score))
2459                .clamp(0.01, Self::THOMPSON_TOP_MEAN);
2460
2461            // The spread narrows as the model is observed: an unseen model is
2462            // explored around its score, a well-observed one exploited at it.
2463            let concentration = self.config.prior_strength + calls as f64;
2464            let alpha = (concentration * mean).max(0.01);
2465            let beta = (concentration * (1.0 - mean)).max(0.01);
2466
2467            // Sample from Beta(alpha, beta) using the Jöhnk algorithm
2468            let sample = sample_beta(&mut rng, alpha, beta);
2469
2470            if sample > best_sample {
2471                best_sample = sample;
2472                best_id = id.clone();
2473                best_strategy = match profile {
2474                    Some(p) if p.total_calls >= self.config.min_observations => {
2475                        RoutingStrategy::ProfileBased
2476                    }
2477                    Some(p) if p.total_calls > 0 => {
2478                        // Under-tested but has some data — exploration
2479                        RoutingStrategy::Exploration
2480                    }
2481                    _ => RoutingStrategy::SchemaBased,
2482                };
2483            }
2484        }
2485
2486        (best_id, best_strategy)
2487    }
2488
2489    /// Fallback decision when no candidates pass filtering.
2490    /// Schema-based decision when Phase-1 filtering left no candidate (cold
2491    /// start, or every capable model gated out by memory/availability).
2492    ///
2493    /// `required_caps` is the caller's FULL hard requirement set (prompt
2494    /// complexity + `intent.require` + tool/vision needs) — the same set
2495    /// `filter_candidates` enforced. Honoring it here is load-bearing: the
2496    /// hardcoded complexity→model defaults at the tail can name a model that
2497    /// lacks a required capability (e.g. `Simple`→`Qwen3-0.6B`, which has no
2498    /// `Code`), so a `require: [Code]` request that fell through to cold start
2499    /// used to resolve to a Code-less model — silently violating the hard
2500    /// filter. We now (1) apply `required_caps` to the trusted-remote branch,
2501    /// and (2) prefer the best **local** model that both fits memory and
2502    /// satisfies every required cap before the defaults — which also keeps
2503    /// work on an eligible local model instead of falling through to a
2504    /// (possibly unauthenticated) remote default.
2505    fn cold_start_decision(
2506        &self,
2507        complexity: TaskComplexity,
2508        task: InferenceTask,
2509        required_caps: &[ModelCapability],
2510        registry: &UnifiedRegistry,
2511        has_vision: bool,
2512        exclude: &std::collections::HashSet<String>,
2513        strict_exclusions: bool,
2514        availability: AvailabilitySnapshot,
2515    ) -> AdaptiveRoutingDecision {
2516        // car#358: prefer a non-excluded model in every tier. Soft — the tier
2517        // cascade naturally falls through to the next tier (and ultimately the
2518        // hardcoded default floor) if exclusion empties a tier, so a reviewer
2519        // still gets *a* model rather than an error.
2520        if has_vision {
2521            if let Some(model) = registry
2522                .query_by_capability(ModelCapability::Vision)
2523                .into_iter()
2524                .filter(|model| !exclude.contains(&model.id))
2525                .find(|model| {
2526                    availability.is_available(model) && self.is_trusted_quality_remote(model)
2527                })
2528                .or_else(|| {
2529                    registry
2530                        .query_by_capability(ModelCapability::Vision)
2531                        .into_iter()
2532                        .find(|model| !exclude.contains(&model.id))
2533                })
2534            {
2535                return AdaptiveRoutingDecision {
2536                    model_id: model.id.clone(),
2537                    model_name: model.name.clone(),
2538                    task,
2539                    complexity,
2540                    reason: format!(
2541                        "{:?} task → {} (cold start, vision fallback)",
2542                        complexity, model.name
2543                    ),
2544                    strategy: RoutingStrategy::SchemaBased,
2545                    predicted_quality: 0.5,
2546                    fallbacks: vec![],
2547                    context_length: model.context_length,
2548                    needs_compaction: false,
2549                    candidates: vec![],
2550                };
2551            }
2552        }
2553
2554        if self.config.quality_first_cold_start {
2555            if let Some(model) = registry
2556                .list()
2557                .into_iter()
2558                .filter(|model| {
2559                    availability.is_available(model)
2560                        && !exclude.contains(&model.id)
2561                        && required_caps.iter().all(|cap| model.has_capability(*cap))
2562                        && self.is_trusted_quality_remote(model)
2563                })
2564                .max_by(|a, b| {
2565                    self.schema_quality_estimate(a)
2566                        .partial_cmp(&self.schema_quality_estimate(b))
2567                        .unwrap_or(std::cmp::Ordering::Equal)
2568                })
2569            {
2570                return AdaptiveRoutingDecision {
2571                    model_id: model.id.clone(),
2572                    model_name: model.name.clone(),
2573                    task,
2574                    complexity,
2575                    reason: format!(
2576                        "{:?} task → {} (quality-first cold start)",
2577                        complexity, model.name
2578                    ),
2579                    strategy: RoutingStrategy::SchemaBased,
2580                    predicted_quality: self.schema_quality_estimate(model),
2581                    fallbacks: vec![],
2582                    context_length: model.context_length,
2583                    needs_compaction: false,
2584                    candidates: vec![],
2585                };
2586            }
2587        }
2588
2589        // Prefer the best LOCAL model that fits memory AND satisfies every
2590        // required capability, before the hardcoded defaults. This is what
2591        // makes cold start honor a hard `require: [Code, ToolUse]` instead of
2592        // naming a capability-lacking complexity default — and keeps work on
2593        // an eligible local model rather than falling through to a remote that
2594        // may be unauthenticated. `<` mirrors `filter_candidates`' strict
2595        // memory gate so the two agree on what "fits".
2596        if let Some(model) = registry
2597            .list()
2598            .into_iter()
2599            .filter(|model| {
2600                availability.is_available(model)
2601                    && !exclude.contains(&model.id)
2602                    && model.is_local()
2603                    && model.size_mb() < self.hw.max_model_mb
2604                    && required_caps.iter().all(|cap| model.has_capability(*cap))
2605            })
2606            .max_by(|a, b| {
2607                self.schema_quality_estimate(a)
2608                    .partial_cmp(&self.schema_quality_estimate(b))
2609                    .unwrap_or(std::cmp::Ordering::Equal)
2610            })
2611        {
2612            return AdaptiveRoutingDecision {
2613                model_id: model.id.clone(),
2614                model_name: model.name.clone(),
2615                task,
2616                complexity,
2617                reason: format!(
2618                    "{:?} task → {} (capability-satisfying local cold start)",
2619                    complexity, model.name
2620                ),
2621                strategy: RoutingStrategy::SchemaBased,
2622                predicted_quality: self.schema_quality_estimate(model),
2623                fallbacks: vec![],
2624                context_length: model.context_length,
2625                needs_compaction: false,
2626                candidates: vec![],
2627            };
2628        }
2629
2630        // No eligible LOCAL model satisfied the requirement. Before falling to
2631        // a complexity default that may LACK a required capability, prefer any
2632        // available model that satisfies every required cap — typically a
2633        // remote tool-capable model when ToolUse is required but no in-process
2634        // local backend can execute tool calls (the catalog no longer lets a
2635        // local model claim ToolUse it can't honor). Trusted-quality remotes
2636        // sort first, then higher schema quality. This keeps the contract this
2637        // function's name promises — never name a capability-lacking model when
2638        // an available satisfying one exists — instead of silently handing back
2639        // a default the request can't actually use.
2640        if let Some(model) = registry
2641            .list()
2642            .into_iter()
2643            .filter(|model| {
2644                availability.is_available(model)
2645                    && !exclude.contains(&model.id)
2646                    && required_caps.iter().all(|cap| model.has_capability(*cap))
2647            })
2648            .max_by(|a, b| {
2649                let qa = (
2650                    self.is_trusted_quality_remote(a),
2651                    self.schema_quality_estimate(a),
2652                );
2653                let qb = (
2654                    self.is_trusted_quality_remote(b),
2655                    self.schema_quality_estimate(b),
2656                );
2657                qa.partial_cmp(&qb).unwrap_or(std::cmp::Ordering::Equal)
2658            })
2659        {
2660            return AdaptiveRoutingDecision {
2661                model_id: model.id.clone(),
2662                model_name: model.name.clone(),
2663                task,
2664                complexity,
2665                reason: format!(
2666                    "{:?} task → {} (capability-satisfying cold start)",
2667                    complexity, model.name
2668                ),
2669                strategy: RoutingStrategy::SchemaBased,
2670                predicted_quality: self.schema_quality_estimate(model),
2671                fallbacks: vec![],
2672                context_length: model.context_length,
2673                needs_compaction: false,
2674                candidates: vec![],
2675            };
2676        }
2677
2678        // A separation boundary cannot fall through to an excluded default.
2679        // Return an explicit no-route decision; generation turns this into a
2680        // typed `NoEligibleModel` error before any backend is invoked.
2681        if strict_exclusions && !exclude.is_empty() {
2682            return Self::no_eligible_model_decision(complexity, task, exclude.len());
2683        }
2684
2685        // Last resort: the old complexity-based defaults. Reached only when NO
2686        // available model anywhere satisfies `required_caps` — at which point
2687        // the named default may not satisfy them either, but nothing eligible
2688        // does, so this is a best-effort handoff (the inference layer surfaces
2689        // the unmet requirement — e.g. the generate path's tool-capability
2690        // guard returns UnsupportedMode — rather than the router guessing).
2691        let default_name = match complexity {
2692            TaskComplexity::Simple => "Qwen3-0.6B",
2693            TaskComplexity::Medium => "Qwen3-1.7B",
2694            TaskComplexity::Code => "Qwen3-4B",
2695            TaskComplexity::Complex => &self.hw.recommended_model,
2696        };
2697        // A default the request's registry does not list yields to the first
2698        // tier it does, walking from the default's tier upward, then
2699        // downward; with none listed the default stands, for the inference
2700        // layer to refuse.
2701        let model_name = if registry.find_by_name(default_name).is_some() {
2702            default_name
2703        } else {
2704            crate::router::tier_fallback_order(default_name)
2705                .find(|name| registry.find_by_name(name).is_some())
2706                .unwrap_or(default_name)
2707        };
2708
2709        let model_id = registry
2710            .find_by_name(model_name)
2711            .map(|m| m.id.clone())
2712            .unwrap_or_else(|| model_name.to_string());
2713
2714        let context_length = registry
2715            .find_by_name(model_name)
2716            .map(|m| m.context_length)
2717            .unwrap_or(0);
2718
2719        AdaptiveRoutingDecision {
2720            model_id,
2721            model_name: model_name.to_string(),
2722            task,
2723            complexity,
2724            reason: format!(
2725                "{:?} task → {} (cold start, no candidates)",
2726                complexity, model_name
2727            ),
2728            strategy: RoutingStrategy::SchemaBased,
2729            predicted_quality: 0.5,
2730            fallbacks: vec![],
2731            context_length,
2732            needs_compaction: false,
2733            candidates: vec![],
2734        }
2735    }
2736
2737    /// No model on this machine is left for an `on_device_only` request.
2738    fn no_on_device_model_decision(
2739        complexity: TaskComplexity,
2740        task: InferenceTask,
2741    ) -> AdaptiveRoutingDecision {
2742        AdaptiveRoutingDecision {
2743            reason: "on_device_only left no model on this device".into(),
2744            ..Self::no_eligible_model_decision(complexity, task, 0)
2745        }
2746    }
2747
2748    fn no_eligible_model_decision(
2749        complexity: TaskComplexity,
2750        task: InferenceTask,
2751        excluded_count: usize,
2752    ) -> AdaptiveRoutingDecision {
2753        AdaptiveRoutingDecision {
2754            model_id: String::new(),
2755            model_name: String::new(),
2756            task,
2757            complexity,
2758            reason: format!(
2759                "strict model exclusions left no eligible model ({excluded_count} catalog ids excluded)"
2760            ),
2761            strategy: RoutingStrategy::SchemaBased,
2762            predicted_quality: 0.0,
2763            fallbacks: vec![],
2764            context_length: 0,
2765            needs_compaction: false,
2766            candidates: vec![],
2767        }
2768    }
2769}
2770
2771/// Map a caller-supplied [`crate::TaskHint`] to the engine's
2772/// [`InferenceTask`] enum. The intent surface uses the higher-level
2773/// hint vocabulary; the router operates on InferenceTask. Every
2774/// TaskHint variant maps to a distinct InferenceTask — variants that
2775/// would have silently collapsed to `Generate` were cut from the MVP.
2776fn task_hint_to_inference_task(hint: crate::intent::TaskHint) -> InferenceTask {
2777    use crate::intent::TaskHint;
2778    match hint {
2779        TaskHint::Chat => InferenceTask::Generate,
2780        TaskHint::Classify => InferenceTask::Classify,
2781        TaskHint::Reasoning => InferenceTask::Reasoning,
2782        TaskHint::Code => InferenceTask::Code,
2783    }
2784}
2785
2786/// Hard capability a model must have to serve a resolved [`InferenceTask`].
2787///
2788/// Mirrors `TaskComplexity::required_capabilities` but keyed on the *task* so a
2789/// caller's explicit `task` intent can gate the candidate filter the same way
2790/// prompt complexity does. Code/Reasoning gate the specialized tasks; Embed and
2791/// Classify have dedicated capabilities; Generate is the generative baseline.
2792/// The capability filter before tools, vision and `require` are added: the
2793/// named task's when the caller names one, else the prompt's keyword guess.
2794fn base_required_capabilities(
2795    complexity: TaskComplexity,
2796    intent: Option<&crate::intent::IntentHint>,
2797    task: InferenceTask,
2798) -> Vec<ModelCapability> {
2799    match intent.and_then(|h| h.task) {
2800        Some(_) => vec![inference_task_required_capability(task)],
2801        None => complexity.required_capabilities(),
2802    }
2803}
2804
2805fn inference_task_required_capability(task: InferenceTask) -> ModelCapability {
2806    match task {
2807        InferenceTask::Generate => ModelCapability::Generate,
2808        InferenceTask::Code => ModelCapability::Code,
2809        InferenceTask::Reasoning => ModelCapability::Reasoning,
2810        InferenceTask::Classify => ModelCapability::Classify,
2811        InferenceTask::Embed => ModelCapability::Embed,
2812    }
2813}
2814
2815/// Sample from a Beta(alpha, beta) distribution.
2816///
2817/// Uses the Gamma distribution method: if X ~ Gamma(alpha, 1) and Y ~ Gamma(beta, 1),
2818/// then X / (X + Y) ~ Beta(alpha, beta).
2819///
2820/// For Gamma sampling, uses Marsaglia and Tsang's method for alpha >= 1,
2821/// and Ahrens-Dieter for alpha < 1.
2822fn sample_beta(rng: &mut impl Rng, alpha: f64, beta: f64) -> f64 {
2823    let x = sample_gamma(rng, alpha);
2824    let y = sample_gamma(rng, beta);
2825    if x + y == 0.0 {
2826        0.5 // degenerate case
2827    } else {
2828        x / (x + y)
2829    }
2830}
2831
2832/// Sample from Gamma(shape, 1) using Marsaglia-Tsang for shape >= 1,
2833/// with Ahrens-Dieter boost for shape < 1.
2834fn sample_gamma(rng: &mut impl Rng, shape: f64) -> f64 {
2835    if shape < 1.0 {
2836        // Ahrens-Dieter: Gamma(a) = Gamma(a+1) * U^(1/a)
2837        let u: f64 = rng.random();
2838        return sample_gamma(rng, shape + 1.0) * u.powf(1.0 / shape);
2839    }
2840
2841    // Marsaglia-Tsang method for shape >= 1
2842    let d = shape - 1.0 / 3.0;
2843    let c = 1.0 / (9.0 * d).sqrt();
2844
2845    loop {
2846        let x: f64 = loop {
2847            let n = sample_standard_normal(rng);
2848            if 1.0 + c * n > 0.0 {
2849                break n;
2850            }
2851        };
2852
2853        let v = (1.0 + c * x).powi(3);
2854        let u: f64 = rng.random();
2855
2856        if u < 1.0 - 0.0331 * x.powi(4) {
2857            return d * v;
2858        }
2859        if u.ln() < 0.5 * x * x + d * (1.0 - v + v.ln()) {
2860            return d * v;
2861        }
2862    }
2863}
2864
2865/// Sample from standard normal N(0,1) using Box-Muller transform.
2866fn sample_standard_normal(rng: &mut impl Rng) -> f64 {
2867    let u1: f64 = rng.random();
2868    let u2: f64 = rng.random();
2869    (-2.0 * u1.max(1e-300).ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
2870}
2871
2872#[cfg(test)]
2873mod tests {
2874    use super::*;
2875    use crate::outcome::InferredOutcome;
2876    use std::ffi::OsString;
2877
2878    fn route_model_ids(decision: &AdaptiveRoutingDecision) -> impl Iterator<Item = &str> {
2879        std::iter::once(decision.model_id.as_str())
2880            .chain(
2881                decision
2882                    .candidates
2883                    .iter()
2884                    .map(|candidate| candidate.model_id.as_str()),
2885            )
2886            .chain(decision.fallbacks.iter().map(String::as_str))
2887    }
2888
2889    struct RestoredEnvironment(Vec<(&'static str, Option<OsString>)>);
2890
2891    impl RestoredEnvironment {
2892        fn capture(names: &[&'static str]) -> Self {
2893            Self(
2894                names
2895                    .iter()
2896                    .map(|name| (*name, std::env::var_os(name)))
2897                    .collect(),
2898            )
2899        }
2900    }
2901
2902    impl Drop for RestoredEnvironment {
2903        fn drop(&mut self) {
2904            for (name, value) in &self.0 {
2905                unsafe {
2906                    match value {
2907                        Some(value) => std::env::set_var(name, value),
2908                        None => std::env::remove_var(name),
2909                    }
2910                }
2911            }
2912        }
2913    }
2914
2915    struct TestRegistry {
2916        registry: UnifiedRegistry,
2917        // Field drop order is intentional: restore the process environment
2918        // before releasing the shared mutator lock.
2919        _restore: RestoredEnvironment,
2920        _environment: tokio::sync::MutexGuard<'static, ()>,
2921    }
2922
2923    impl std::ops::Deref for TestRegistry {
2924        type Target = UnifiedRegistry;
2925
2926        fn deref(&self) -> &Self::Target {
2927            &self.registry
2928        }
2929    }
2930
2931    impl std::ops::DerefMut for TestRegistry {
2932        fn deref_mut(&mut self) -> &mut Self::Target {
2933            &mut self.registry
2934        }
2935    }
2936
2937    // The canonical id the router actually proposes/learns for a Qwen3 generate
2938    // model. On Apple Silicon the GGUF candidate is substituted with its MLX
2939    // equivalent BEFORE scoring (#333), so outcomes must be seeded against the
2940    // MLX id the router reads; elsewhere MLX isn't compiled/available so the
2941    // GGUF id stands. Keeps these routing tests valid on every target.
2942    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2943    const QWEN3_8B_ROUTED_ID: &str = "mlx/qwen3-8b:4bit";
2944    #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2945    const QWEN3_8B_ROUTED_ID: &str = "qwen/qwen3-8b:q4_k_m";
2946    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
2947    const QWEN3_4B_ROUTED_ID: &str = "mlx/qwen3-4b:4bit";
2948    #[cfg(not(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx))))]
2949    const QWEN3_4B_ROUTED_ID: &str = "qwen/qwen3-4b:q4_k_m";
2950
2951    fn test_hw() -> HardwareInfo {
2952        HardwareInfo {
2953            os: "macos".into(),
2954            arch: "aarch64".into(),
2955            cpu_cores: 10,
2956            total_ram_mb: 32768,
2957            gpu_backend: crate::hardware::GpuBackend::Metal,
2958            gpu_memory_mb: Some(28672),
2959            gpu_devices: Vec::new(),
2960            recommended_model: "Qwen3-8B".into(),
2961            recommended_context: 8192,
2962            max_model_mb: 18000, // headroom above 30B-A3B's 17000MB
2963        }
2964    }
2965
2966    fn test_registry() -> TestRegistry {
2967        let environment = crate::openrouter::test_environment_scope();
2968        let restore = RestoredEnvironment::capture(&["OPENAI_API_KEY", "CAR_SECRETS_FILE_DIR"]);
2969        let tmp = std::path::PathBuf::from("/tmp/car-test-adaptive-router");
2970        unsafe {
2971            std::env::set_var("OPENAI_API_KEY", "test-openai-key");
2972            // Isolate model availability from the dev machine's real OS keychain:
2973            // now that availability resolves env-OR-keychain, an empty file
2974            // backend (debug-only redirect) means only env-set keys resolve, so
2975            // routing tests don't flip on whatever cloud keys the developer has
2976            // stored (e.g. a real ANTHROPIC_API_KEY making claude-sonnet win).
2977            std::env::set_var(
2978                "CAR_SECRETS_FILE_DIR",
2979                std::env::temp_dir().join("car-test-adaptive-router-empty-secrets"),
2980            );
2981        }
2982        // Create fake model dirs so the registry marks them as available
2983        for name in &[
2984            "Qwen3-0.6B",
2985            "Qwen3-1.7B",
2986            "Qwen3-4B",
2987            "Qwen3-8B",
2988            "Qwen3-Embedding-0.6B",
2989        ] {
2990            let dir = tmp.join(name);
2991            let _ = std::fs::create_dir_all(&dir);
2992            let _ = std::fs::write(dir.join("model.gguf"), b"fake");
2993            let _ = std::fs::write(dir.join("tokenizer.json"), b"{}");
2994        }
2995        let mut reg = UnifiedRegistry::new_with_state_root(tmp.clone(), tmp);
2996        reg.register_project_model(ModelSchema {
2997            id: "openai/gpt-5.4-mini:latest".into(),
2998            name: "gpt-5.4-mini".into(),
2999            provider: "openai".into(),
3000            family: "gpt-5.4".into(),
3001            version: "latest".into(),
3002            capabilities: vec![
3003                ModelCapability::Generate,
3004                ModelCapability::Code,
3005                ModelCapability::Reasoning,
3006                ModelCapability::ToolUse,
3007                ModelCapability::MultiToolCall,
3008                ModelCapability::Vision,
3009            ],
3010            context_length: 128_000,
3011            max_output_tokens: None,
3012            param_count: "api".into(),
3013            quantization: None,
3014            performance: Default::default(),
3015            cost: Default::default(),
3016            source: crate::schema::ModelSource::RemoteApi {
3017                endpoint: "https://api.openai.com/v1".into(),
3018                api_key_env: "OPENAI_API_KEY".into(),
3019                api_key_envs: vec![],
3020                api_version: None,
3021                protocol: crate::schema::ApiProtocol::OpenAiCompat,
3022            },
3023            tags: vec!["trusted-remote".into()],
3024            supported_params: vec![],
3025            public_benchmarks: vec![],
3026            trust_tier: crate::schema::TrustTier::Curated,
3027            deprecated: false,
3028            available: true,
3029            weights_ready: true,
3030        });
3031        TestRegistry {
3032            registry: reg,
3033            _restore: restore,
3034            _environment: environment,
3035        }
3036    }
3037
3038    #[test]
3039    fn route_observer_reports_a_429_exclusion_before_dispatch() {
3040        let registry = test_registry();
3041        let router = AdaptiveRouter::with_default_config(test_hw());
3042        let mut tracker = OutcomeTracker::new();
3043        let lane = "openai/gpt-5.4-mini:latest";
3044        let trace = tracker.record_start(lane, InferenceTask::Generate, "test 429");
3045        tracker.record_failure(&trace, "API returned 429 Too Many Requests");
3046        assert!(
3047            tracker.is_excluded(lane),
3048            "429 must seed the router exclusion"
3049        );
3050
3051        let mut exclusions = Vec::new();
3052        let mut observe = |exclusion| exclusions.push(exclusion);
3053        let decision = router.route_with_exclusion_observer(
3054            RouteRequest::new("Say hello.", &registry, &tracker),
3055            &mut observe,
3056        );
3057
3058        assert_ne!(decision.model_id, lane);
3059        assert!(exclusions.contains(&RouterExclusion {
3060            lane: lane.into(),
3061            reason: RouterExclusionReason::RateLimited,
3062            until: None,
3063        }));
3064    }
3065
3066    #[test]
3067    fn route_observer_reports_an_open_circuit_with_its_cooldown() {
3068        let registry = test_registry();
3069        let router = AdaptiveRouter::with_default_config(test_hw());
3070        let tracker = OutcomeTracker::new();
3071        let lane = "openai/gpt-5.4-mini:latest";
3072        {
3073            let mut breakers = router.circuit_breakers.lock().unwrap();
3074            for _ in 0..3 {
3075                breakers.record_failure(lane);
3076            }
3077        }
3078
3079        let mut exclusions = Vec::new();
3080        let mut observe = |exclusion| exclusions.push(exclusion);
3081        let decision = router.route_with_exclusion_observer(
3082            RouteRequest::new("Say hello.", &registry, &tracker),
3083            &mut observe,
3084        );
3085
3086        assert_ne!(decision.model_id, lane);
3087        let exclusion = exclusions
3088            .iter()
3089            .find(|exclusion| exclusion.lane == lane)
3090            .expect("open lane must be reported");
3091        assert_eq!(exclusion.reason, RouterExclusionReason::CircuitOpen);
3092        assert!(exclusion.until.is_some(), "open circuit has a cooldown end");
3093    }
3094
3095    #[test]
3096    fn openrouter_tags_drive_cost_quality_routing_and_live_availability() {
3097        let tmp = tempfile::tempdir().unwrap();
3098        let _credential_scope = crate::openrouter::test_credential_scope();
3099        crate::openrouter::set_test_credential(Some("test-openrouter-key"));
3100        let mut registry = UnifiedRegistry::new_empty(tmp.path().to_path_buf());
3101        for schema in crate::openrouter::curated_schemas()
3102            .into_iter()
3103            .filter(|schema| schema.provider == "openrouter")
3104        {
3105            registry.register_project_model(schema);
3106        }
3107        let router = AdaptiveRouter::new(
3108            test_hw(),
3109            RoutingConfig {
3110                prefer_local: false,
3111                // Make this contract check effectively deterministic: the
3112                // Thompson posterior is concentrated around the declared
3113                // cost/quality score while still exercising the real selector.
3114                prior_strength: 1_000_000.0,
3115                ..RoutingConfig::default()
3116            },
3117        );
3118        let tracker = OutcomeTracker::new();
3119        let mut simple_tracker = OutcomeTracker::new();
3120        let evidenced_cheap = "openrouter/qwen/qwen3-coder-next";
3121        for _ in 0..8 {
3122            let trace = simple_tracker.record_start(
3123                evidenced_cheap,
3124                InferenceTask::Generate,
3125                "OpenRouter cheap-model calibration",
3126            );
3127            simple_tracker.record_complete(&trace, 100, 10, 10);
3128            simple_tracker
3129                .record_inferred_outcome(&trace, InferredOutcome::Accepted { confidence: 1.0 });
3130        }
3131
3132        crate::openrouter::reset_credential_source_call_count();
3133        let simple = router.route_with(RouteRequest {
3134            workload: RoutingWorkload::Background,
3135            ..RouteRequest::new("Say hello.", &registry, &simple_tracker)
3136        });
3137        assert_eq!(crate::openrouter::credential_source_call_count(), 1);
3138        let simple_schema = registry.get(&simple.model_id).unwrap();
3139        assert!(simple_schema.tags.iter().any(|tag| tag == "cheap"));
3140        assert!(simple.reason.contains("low-cost tier"), "{}", simple.reason);
3141        assert!(simple
3142            .candidates
3143            .iter()
3144            .any(|candidate| candidate.model_id.starts_with("openrouter/")));
3145
3146        let quality_intent = crate::intent::IntentHint {
3147            task: Some(crate::intent::TaskHint::Reasoning),
3148            prefer_quality: true,
3149            ..Default::default()
3150        };
3151        let complex = router.route_with(RouteRequest {
3152            intent: Some(&quality_intent),
3153            ..RouteRequest::new(
3154                "Analyze this multi-step architecture tradeoff and prove the failure modes.",
3155                &registry,
3156                &tracker,
3157            )
3158        });
3159        let complex_schema = registry.get(&complex.model_id).unwrap();
3160        assert!(complex_schema.tags.iter().any(|tag| tag == "frontier"));
3161        assert!(
3162            complex.reason.contains("frontier quality tier"),
3163            "{}",
3164            complex.reason
3165        );
3166
3167        let vision_intent = crate::intent::IntentHint {
3168            require: vec![ModelCapability::Vision],
3169            ..Default::default()
3170        };
3171        let vision = router.route_with(RouteRequest {
3172            intent: Some(&vision_intent),
3173            ..RouteRequest::new("Inspect this image", &registry, &tracker)
3174        });
3175        assert!(vision.candidates.iter().all(|candidate| registry
3176            .get(&candidate.model_id)
3177            .is_some_and(|schema| schema.has_capability(ModelCapability::Vision))));
3178
3179        let failed_model = simple.model_id.clone();
3180        {
3181            let mut breakers = router.circuit_breakers.lock().unwrap();
3182            for _ in 0..3 {
3183                breakers.record_failure(&failed_model);
3184            }
3185        }
3186        let after_failures = router.route_with(RouteRequest {
3187            workload: RoutingWorkload::Background,
3188            ..RouteRequest::new("Say hello.", &registry, &simple_tracker)
3189        });
3190        assert_ne!(after_failures.model_id, failed_model);
3191        assert!(!after_failures
3192            .candidates
3193            .iter()
3194            .any(|candidate| candidate.model_id == failed_model));
3195
3196        crate::openrouter::set_test_credential(None);
3197        crate::openrouter::reset_credential_source_call_count();
3198        let without_key = router.route("Say hello.", &registry, &tracker);
3199        assert_eq!(crate::openrouter::credential_source_call_count(), 1);
3200        assert!(!without_key.model_id.starts_with("openrouter/"));
3201        assert!(!without_key
3202            .fallbacks
3203            .iter()
3204            .any(|model| model.starts_with("openrouter/")));
3205        assert!(!without_key
3206            .candidates
3207            .iter()
3208            .any(|candidate| candidate.model_id.starts_with("openrouter/")));
3209    }
3210
3211    #[tokio::test(flavor = "current_thread")]
3212    async fn managed_openrouter_aliases_survive_quality_bootstrap_without_a_personal_key() {
3213        let tmp = tempfile::tempdir().unwrap();
3214        let (_credential_scope, _environment) =
3215            crate::openrouter::test_credential_and_environment_scope_async().await;
3216        let _restore = RestoredEnvironment::capture(&[
3217            "CAR_SECRETS_FILE_DIR",
3218            "CAR_MANAGED_ROUTING_TEST_KEY",
3219            car_auth::PARSLEE_ACCESS_TOKEN_KEY,
3220        ]);
3221        // The assertions below are that the managed `parslee/openrouter/*`
3222        // aliases stay routable. Both durable session verdicts —
3223        // `gateway-state.json` (Parslee-ai/car#786) and
3224        // `parslee-credential-state.json` (Parslee-ai/car#887) — suppress
3225        // exactly those rows, and they live in the CAR state root, so a live
3226        // one left there by any other process makes this test fail on a claim
3227        // it never set. Pin the root and forget the in-process copies
3228        // (Parslee-ai/car#986).
3229        let _home = crate::openrouter::StateRootScope::new();
3230        crate::openrouter::clear_gateway_unconfigured();
3231        crate::parslee_credential::clear_credential_rejected();
3232        let secrets_dir = tmp.path().join("secrets");
3233        unsafe {
3234            std::env::set_var("CAR_SECRETS_FILE_DIR", &secrets_dir);
3235            std::env::set_var("CAR_MANAGED_ROUTING_TEST_KEY", "test-peer-key");
3236            std::env::remove_var(car_auth::PARSLEE_ACCESS_TOKEN_KEY);
3237        }
3238        crate::openrouter::set_test_credential(None);
3239
3240        let mut registry = UnifiedRegistry::new_empty(tmp.path().join("models"));
3241        for schema in crate::openrouter::curated_schemas() {
3242            registry.register_project_model(schema);
3243        }
3244        let mut bootstrap_peer = remote_model(
3245            "openai/bootstrap-peer",
3246            "bootstrap-peer",
3247            vec![
3248                ModelCapability::Generate,
3249                ModelCapability::Code,
3250                ModelCapability::Reasoning,
3251            ],
3252        );
3253        if let crate::schema::ModelSource::RemoteApi {
3254            ref mut api_key_env,
3255            ..
3256        } = bootstrap_peer.source
3257        {
3258            *api_key_env = "CAR_MANAGED_ROUTING_TEST_KEY".into();
3259        }
3260        registry.register_project_model(bootstrap_peer);
3261        let mut unrelated = remote_model(
3262            "other/unrelated-curated",
3263            "unrelated-curated",
3264            vec![
3265                ModelCapability::Generate,
3266                ModelCapability::Code,
3267                ModelCapability::Reasoning,
3268            ],
3269        );
3270        unrelated.provider = "other".into();
3271        if let crate::schema::ModelSource::RemoteApi {
3272            ref mut api_key_env,
3273            ..
3274        } = unrelated.source
3275        {
3276            *api_key_env = "CAR_MANAGED_ROUTING_TEST_KEY".into();
3277        }
3278        registry.register_project_model(unrelated);
3279        // Routing receives the coordinator-owned credential snapshot. Writing
3280        // a secret-store fixture directly does not publish the passive
3281        // authority hint and would make this test depend on the developer's
3282        // ambient sign-in state.
3283        registry.refresh_routing_availability(Some("https://api.parslee.ai"), false);
3284
3285        let managed: Vec<_> = registry
3286            .list()
3287            .into_iter()
3288            .filter(|schema| schema.id.starts_with("parslee/openrouter/"))
3289            .collect();
3290        assert_eq!(managed.len(), crate::openrouter::managed_alias_count());
3291        assert!(managed.iter().all(|schema| {
3292            schema.available_now()
3293                && schema.tags.iter().any(|tag| tag == "openrouter")
3294                && schema.tags.iter().any(|tag| tag == "managed")
3295                && schema.cost.input_per_mtok.is_some_and(|price| price > 0.0)
3296                && schema.cost.output_per_mtok.is_some_and(|price| price > 0.0)
3297                && schema.has_capability(ModelCapability::Generate)
3298        }));
3299        assert!(registry
3300            .list()
3301            .into_iter()
3302            .filter(|schema| schema.id.starts_with("openrouter/"))
3303            .all(|schema| !schema.available_now()));
3304
3305        let router = AdaptiveRouter::new(
3306            test_hw(),
3307            RoutingConfig {
3308                prefer_local: false,
3309                prior_strength: 1_000_000.0,
3310                ..RoutingConfig::default()
3311            },
3312        );
3313        let tracker = OutcomeTracker::new();
3314
3315        let interactive = router.route("Say hello.", &registry, &tracker);
3316        assert!(
3317            interactive
3318                .candidates
3319                .iter()
3320                .any(|candidate| candidate.model_id.starts_with("parslee/openrouter/")),
3321            "signed-in managed aliases must survive quality-first bootstrap: {interactive:?}"
3322        );
3323        assert!(
3324            interactive
3325                .fallbacks
3326                .iter()
3327                .any(|id| id.starts_with("parslee/openrouter/")),
3328            "signed-in managed aliases must remain fallback-eligible: {interactive:?}"
3329        );
3330        assert!(
3331            interactive
3332                .candidates
3333                .iter()
3334                .any(|candidate| candidate.model_id == "openai/bootstrap-peer"),
3335            "adding managed aliases must not remove existing trusted providers: {interactive:?}"
3336        );
3337        assert!(
3338            !interactive
3339                .candidates
3340                .iter()
3341                .any(|candidate| candidate.model_id == "other/unrelated-curated"),
3342            "managed-alias trust must not broaden the bootstrap to unrelated providers"
3343        );
3344
3345        let mut simple_tracker = OutcomeTracker::new();
3346        let evidenced_cheap = "parslee/openrouter/open-fast";
3347        for _ in 0..8 {
3348            let trace = simple_tracker.record_start(
3349                evidenced_cheap,
3350                InferenceTask::Generate,
3351                "managed OpenRouter cheap-model calibration",
3352            );
3353            simple_tracker.record_complete(&trace, 100, 10, 10);
3354            simple_tracker
3355                .record_inferred_outcome(&trace, InferredOutcome::Accepted { confidence: 1.0 });
3356        }
3357        let simple = router.route_with(RouteRequest {
3358            workload: RoutingWorkload::Background,
3359            ..RouteRequest::new("Say hello.", &registry, &simple_tracker)
3360        });
3361        let simple_schema = registry.get(&simple.model_id).unwrap();
3362        assert!(
3363            simple.model_id.starts_with("parslee/openrouter/")
3364                && simple_schema.tags.iter().any(|tag| tag == "cheap"),
3365            "simple work should prefer an evidenced cheap managed alias: {simple:?}"
3366        );
3367        assert!(simple.reason.contains("low-cost tier"), "{}", simple.reason);
3368
3369        let quality_intent = crate::intent::IntentHint {
3370            task: Some(crate::intent::TaskHint::Reasoning),
3371            prefer_quality: true,
3372            ..Default::default()
3373        };
3374        let complex = router.route_with(RouteRequest {
3375            intent: Some(&quality_intent),
3376            ..RouteRequest::new(
3377                "Analyze this multi-step architecture tradeoff and prove the failure modes.",
3378                &registry,
3379                &tracker,
3380            )
3381        });
3382        let complex_schema = registry.get(&complex.model_id).unwrap();
3383        assert!(
3384            complex.model_id.starts_with("parslee/openrouter/")
3385                && complex_schema.tags.iter().any(|tag| tag == "frontier"),
3386            "complex quality work should escalate to a managed frontier alias: {complex:?}"
3387        );
3388        assert!(
3389            complex.reason.contains("frontier quality tier"),
3390            "{}",
3391            complex.reason
3392        );
3393
3394        for decision in [&interactive, &simple, &complex] {
3395            assert!(
3396                !route_model_ids(decision).any(|id| id.starts_with("openrouter/")),
3397                "personal OpenRouter rows must stay excluded without a personal key: {decision:?}"
3398            );
3399        }
3400
3401        let spoofed_id = "parslee/openrouter/frontier-general";
3402        let spoofed_endpoint = "https://attacker.invalid/v1/chat/completions";
3403        let spoofed_value = serde_json::json!({
3404            "id": spoofed_id,
3405            "name": spoofed_id,
3406            "provider": "parslee",
3407            "family": "spoof",
3408            "capabilities": ["generate", "code", "reasoning"],
3409            "context_length": 1_000_000,
3410            "performance": {"latency_p50_ms": 1},
3411            "cost": {"input_per_mtok": 0.0, "output_per_mtok": 0.0},
3412            "source": {
3413                "type": "remote_api",
3414                "endpoint": spoofed_endpoint,
3415                "api_key_env": "CAR_MANAGED_ROUTING_TEST_KEY",
3416                "protocol": "open_ai_compat"
3417            },
3418            "tags": ["openrouter", "managed", "frontier"],
3419            "public_benchmarks": [{"name": "attacker-unanchored", "score": 1.0}]
3420        });
3421        let omitted_tier: ModelSchema = serde_json::from_value(spoofed_value.clone()).unwrap();
3422        assert_eq!(
3423            omitted_tier.trust_tier,
3424            crate::schema::TrustTier::Curated,
3425            "the regression must exercise the legacy omitted-tier default"
3426        );
3427        let mut direct_spoof_registry =
3428            UnifiedRegistry::new_empty(tmp.path().join("direct-spoof-models"));
3429        direct_spoof_registry.register(omitted_tier);
3430        assert!(
3431            direct_spoof_registry.get(spoofed_id).is_none(),
3432            "public registration must reject the reserved managed-alias namespace"
3433        );
3434        direct_spoof_registry
3435            .register_project_model(registry.get("openai/bootstrap-peer").unwrap().clone());
3436        let direct_spoof_route = router.route(
3437            "Analyze this architecture and prove the failure modes.",
3438            &direct_spoof_registry,
3439            &OutcomeTracker::new(),
3440        );
3441        assert!(
3442            !route_model_ids(&direct_spoof_route).any(|id| id == spoofed_id),
3443            "a rejected in-memory lookalike must not be routable: {direct_spoof_route:?}"
3444        );
3445        std::fs::write(
3446            tmp.path().join("models.json"),
3447            serde_json::to_vec_pretty(&vec![spoofed_value]).unwrap(),
3448        )
3449        .unwrap();
3450
3451        let mut reloaded = UnifiedRegistry::new_empty(tmp.path().join("reloaded-models"));
3452        reloaded.load_user_config().unwrap();
3453        assert!(
3454            reloaded.get(spoofed_id).is_none(),
3455            "persisted user config must reject the reserved managed-alias namespace"
3456        );
3457        reloaded.register_project_model(registry.get("openai/bootstrap-peer").unwrap().clone());
3458        let spoofed_route = router.route(
3459            "Analyze this architecture and prove the failure modes.",
3460            &reloaded,
3461            &OutcomeTracker::new(),
3462        );
3463        assert!(
3464            !route_model_ids(&spoofed_route).any(|id| id == spoofed_id),
3465            "a rejected persisted lookalike must not be routable: {spoofed_route:?}"
3466        );
3467
3468        let discovery_state_root = tmp.path().join("discovery-spoof-root");
3469        let discovery_models_dir = discovery_state_root.join("models");
3470        std::fs::create_dir_all(&discovery_models_dir).unwrap();
3471        let discovery_spoof_id = "openrouter/forged-discovery-cache";
3472        let mut discovery_spoof = remote_model(
3473            discovery_spoof_id,
3474            "forged-discovery-cache",
3475            vec![
3476                ModelCapability::Generate,
3477                ModelCapability::Code,
3478                ModelCapability::Reasoning,
3479            ],
3480        );
3481        discovery_spoof.provider = "openrouter".into();
3482        discovery_spoof.tags = vec!["openrouter".into(), "frontier".into()];
3483        discovery_spoof.trust_tier = crate::schema::TrustTier::Curated;
3484        discovery_spoof.performance.latency_p50_ms = Some(1);
3485        discovery_spoof.cost = crate::schema::CostModel::default();
3486        if let crate::schema::ModelSource::RemoteApi {
3487            ref mut api_key_env,
3488            ..
3489        } = discovery_spoof.source
3490        {
3491            *api_key_env = "CAR_MANAGED_ROUTING_TEST_KEY".into();
3492        }
3493        crate::discovery::save_cache(
3494            &crate::discovery::cache_path(&discovery_models_dir),
3495            &[discovery_spoof],
3496        )
3497        .unwrap();
3498
3499        let mut discovery_registry =
3500            UnifiedRegistry::new_with_state_root(discovery_state_root, discovery_models_dir);
3501        discovery_registry
3502            .register_project_model(registry.get("openai/bootstrap-peer").unwrap().clone());
3503        assert_eq!(
3504            discovery_registry
3505                .get(discovery_spoof_id)
3506                .unwrap()
3507                .trust_tier,
3508            crate::schema::TrustTier::Community,
3509            "unsigned discovery cache rows must be demoted before registration"
3510        );
3511        let discovery_spoof_route = router.route(
3512            "Analyze this architecture and prove the failure modes.",
3513            &discovery_registry,
3514            &OutcomeTracker::new(),
3515        );
3516        assert!(
3517            !discovery_spoof_route
3518                .candidates
3519                .iter()
3520                .any(|candidate| candidate.model_id == discovery_spoof_id),
3521            "a forged direct-provider discovery row must not enter trusted candidates: \
3522             {discovery_spoof_route:?}"
3523        );
3524        assert!(
3525            !discovery_spoof_route
3526                .fallbacks
3527                .iter()
3528                .any(|id| id == discovery_spoof_id),
3529            "a forged direct-provider discovery row must not enter trusted fallbacks: \
3530             {discovery_spoof_route:?}"
3531        );
3532    }
3533
3534    #[test]
3535    fn openrouter_becomes_require_ready_eligible_after_live_key_activation() {
3536        let tmp = tempfile::tempdir().unwrap();
3537        let _credential_scope = crate::openrouter::test_credential_scope();
3538        let _provider_env = crate::openrouter::test_environment_scope();
3539        crate::openrouter::set_test_credential(None);
3540        let mut registry = UnifiedRegistry::new_empty(tmp.path().to_path_buf());
3541        let personal = crate::openrouter::curated_schemas()
3542            .into_iter()
3543            .find(|schema| schema.id == "openrouter/openai/gpt-5.4")
3544            .unwrap();
3545        registry.register_project_model(personal);
3546
3547        unsafe {
3548            std::env::set_var("OPENAI_API_KEY", "ready-peer-key");
3549        }
3550        registry.register_project_model(remote_model(
3551            "ready-peer",
3552            "ready-peer",
3553            vec![ModelCapability::Generate, ModelCapability::Reasoning],
3554        ));
3555
3556        crate::openrouter::set_test_credential(Some("activated-after-register"));
3557        let hint = crate::intent::IntentHint {
3558            require_ready: true,
3559            prefer_quality: true,
3560            ..Default::default()
3561        };
3562        let router = AdaptiveRouter::new(
3563            test_hw(),
3564            RoutingConfig {
3565                prefer_local: false,
3566                prior_strength: 1_000_000.0,
3567                ..RoutingConfig::default()
3568            },
3569        );
3570        let tracker = OutcomeTracker::new();
3571        let decision = router.route_with(RouteRequest {
3572            intent: Some(&hint),
3573            ..RouteRequest::new("Prove the architecture tradeoff.", &registry, &tracker)
3574        });
3575        assert!(
3576            decision
3577                .candidates
3578                .iter()
3579                .any(|candidate| candidate.model_id == "openrouter/openai/gpt-5.4"),
3580            "post-login OpenRouter model must remain require_ready-eligible: {:?}",
3581            decision.candidates
3582        );
3583        unsafe {
3584            std::env::remove_var("OPENAI_API_KEY");
3585        }
3586    }
3587
3588    #[test]
3589    fn route_cost_score_uses_prompt_tiers_and_model_cache_prices() {
3590        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
3591        let tracker = OutcomeTracker::new();
3592        let mut tiered = crate::openrouter::curated_schemas()
3593            .into_iter()
3594            .find(|schema| schema.id == "openrouter/openai/gpt-5.4")
3595            .unwrap();
3596        let mut flat = tiered.clone();
3597        tiered.id = "tiered".into();
3598        flat.id = "flat".into();
3599        flat.cost = crate::schema::CostModel {
3600            input_per_mtok: Some(4.0),
3601            output_per_mtok: Some(22.5),
3602            cache_read_input_per_mtok: Some(4.0),
3603            ..Default::default()
3604        };
3605
3606        let score = |model: &ModelSchema, prompt, cache_read| {
3607            router.score_model(
3608                model,
3609                InferenceTask::Generate,
3610                &tracker,
3611                RoutingWorkload::Background,
3612                prompt,
3613                0,
3614                cache_read,
3615                0,
3616            )
3617        };
3618        assert!(score(&tiered, 100_000, 0) > score(&flat, 100_000, 0));
3619        assert!(score(&tiered, 300_000, 0) < score(&flat, 300_000, 0));
3620        assert!(score(&tiered, 300_000, 300_000) > score(&flat, 300_000, 300_000));
3621    }
3622
3623    /// The cold-start rule: a request goes to a trusted remote, or to a local
3624    /// model whose *measured* quality is within the reliability band of the
3625    /// best trusted remote available. A weak or unmeasured local never wins.
3626    fn assert_cold_start_pick_is_frontier_grade(
3627        router: &AdaptiveRouter,
3628        registry: &UnifiedRegistry,
3629        decision: &AdaptiveRoutingDecision,
3630    ) {
3631        let pick = registry
3632            .get(&decision.model_id)
3633            .expect("selected model should exist");
3634        if !pick.is_local() {
3635            assert!(
3636                router.is_trusted_quality_remote(pick),
3637                "cold start picked an untrusted remote: {decision:?}"
3638            );
3639            return;
3640        }
3641        let best_remote = registry
3642            .list()
3643            .into_iter()
3644            .filter(|model| model.available && router.is_trusted_quality_remote(model))
3645            .map(|model| router.schema_quality_estimate(model))
3646            .fold(0.0_f64, f64::max);
3647        assert!(
3648            !pick.public_benchmarks.is_empty()
3649                && router.schema_quality_estimate(pick)
3650                    >= best_remote - AdaptiveRouter::OUTCOME_FIRST_BAND,
3651            "cold start picked a local below frontier grade: {decision:?}"
3652        );
3653    }
3654
3655    #[test]
3656    fn routes_simple_to_trusted_remote_during_cold_start() {
3657        let router = AdaptiveRouter::new(
3658            test_hw(),
3659            RoutingConfig {
3660                prior_strength: 100.0, // strong prior = exploit Phase 2 scores (deterministic-ish)
3661                ..Default::default()
3662            },
3663        );
3664        let reg = test_registry();
3665        let tracker = OutcomeTracker::new();
3666
3667        let decision = router.route("What is 2+2?", &reg, &tracker);
3668        assert_eq!(decision.complexity, TaskComplexity::Simple);
3669        assert_eq!(decision.strategy, RoutingStrategy::SchemaBased);
3670        // On cold start, quality-critical tasks stay on trusted remotes unless
3671        // a local model measures as well.
3672        assert_cold_start_pick_is_frontier_grade(&router, &reg, &decision);
3673    }
3674
3675    #[test]
3676    fn routes_code_to_code_capable_remote_during_cold_start() {
3677        let router = AdaptiveRouter::new(
3678            test_hw(),
3679            RoutingConfig {
3680                prior_strength: 100.0, // strong prior = exploit Phase 2 scores (deterministic-ish)
3681                ..Default::default()
3682            },
3683        );
3684        let reg = test_registry();
3685        let tracker = OutcomeTracker::new();
3686
3687        let decision = router.route(
3688            "Fix this function:\n```rust\nfn main() {}\n```",
3689            &reg,
3690            &tracker,
3691        );
3692        assert_eq!(decision.complexity, TaskComplexity::Code);
3693        assert_eq!(decision.task, InferenceTask::Code);
3694        // Must select a code-capable local model (not 0.6B which lacks Code)
3695        let schema = reg
3696            .find_by_name(&decision.model_name)
3697            .expect("model should exist");
3698        assert!(
3699            schema.has_capability(ModelCapability::Code),
3700            "selected model must support Code"
3701        );
3702        assert_cold_start_pick_is_frontier_grade(&router, &reg, &decision);
3703    }
3704
3705    #[test]
3706    fn routes_images_to_vision_capable_model() {
3707        let router = AdaptiveRouter::new(
3708            test_hw(),
3709            RoutingConfig {
3710                // Concentrate the posterior so this contract test proves the
3711                // prior wiring rather than occasionally sampling a weaker arm.
3712                prior_strength: 1_000_000.0,
3713                ..Default::default()
3714            },
3715        );
3716        let mut reg = test_registry();
3717        let tracker = OutcomeTracker::new();
3718
3719        reg.register_project_model(ModelSchema {
3720            id: "mlx-vlm/qwen3-vl-2b:bf16".into(),
3721            name: "Qwen3-VL-2B-mlx-vlm".into(),
3722            provider: "qwen".into(),
3723            family: "qwen3-vl".into(),
3724            version: "bf16".into(),
3725            capabilities: vec![
3726                ModelCapability::Generate,
3727                ModelCapability::Vision,
3728                ModelCapability::Grounding,
3729            ],
3730            context_length: 262_144,
3731            max_output_tokens: None,
3732            param_count: "2B".into(),
3733            quantization: None,
3734            performance: Default::default(),
3735            cost: Default::default(),
3736            source: crate::schema::ModelSource::Mlx {
3737                hf_repo: "Qwen/Qwen3-VL-2B-Instruct".into(),
3738                hf_weight_file: None,
3739            },
3740            tags: vec!["vision".into(), "mlx-vlm-cli".into()],
3741            supported_params: vec![],
3742            public_benchmarks: vec![],
3743            trust_tier: crate::schema::TrustTier::Curated,
3744            deprecated: false,
3745            available: true,
3746            weights_ready: true,
3747        });
3748
3749        let decision = router.route_with_vision("What is in this image?", &reg, &tracker, false);
3750        let schema = reg
3751            .find_by_name(&decision.model_name)
3752            .expect("model should exist");
3753        assert!(
3754            schema.has_capability(ModelCapability::Vision),
3755            "selected model must support Vision"
3756        );
3757    }
3758
3759    #[test]
3760    fn profile_based_routing_favors_proven_model() {
3761        let router = AdaptiveRouter::new(
3762            test_hw(),
3763            // The default prior: observed outcomes reach the draw through the
3764            // Phase 2 score, and a weak prior only widens the unobserved
3765            // candidates' draws.
3766            RoutingConfig {
3767                min_observations: 3,
3768                ..Default::default()
3769            },
3770        );
3771        let reg = test_registry();
3772        let qwen_8b_id = QWEN3_8B_ROUTED_ID;
3773        // Thompson sampling is stochastic: count over 200 routes.
3774        let wins = |tracker: &OutcomeTracker| {
3775            (0..200)
3776                .filter(|_| {
3777                    let decision = router.route("Fix this bug in the parser", &reg, tracker);
3778                    assert_eq!(decision.complexity, TaskComplexity::Code);
3779                    decision.model_id == qwen_8b_id
3780                })
3781                .count()
3782        };
3783        let unproven = wins(&OutcomeTracker::new());
3784
3785        // Build a strong profile for Qwen3-8B on code tasks (fast + high quality)
3786        let mut tracker = OutcomeTracker::new();
3787        for _ in 0..20 {
3788            let trace = tracker.record_start(qwen_8b_id, InferenceTask::Code, "test");
3789            tracker.record_complete(&trace, 500, 100, 50);
3790            tracker.record_inferred_outcome(&trace, InferredOutcome::Accepted { confidence: 0.95 });
3791        }
3792        // Measured 128-143/200 under car_skip_mlx, 198-200 on Apple Silicon.
3793        let proven = wins(&tracker);
3794        assert!(
3795            proven >= 100 && proven >= unproven + 60,
3796            "proven model won {proven}/200 (without its outcomes: {unproven}/200)"
3797        );
3798    }
3799
3800    #[test]
3801    fn proven_local_model_can_displace_bootstrap_remote() {
3802        let router = AdaptiveRouter::new(
3803            test_hw(),
3804            RoutingConfig {
3805                prior_strength: 100.0,
3806                bootstrap_min_task_observations: 6,
3807                bootstrap_quality_floor: 0.8,
3808                ..Default::default()
3809            },
3810        );
3811        let reg = test_registry();
3812        let mut tracker = OutcomeTracker::new();
3813
3814        let qwen_8b_id = QWEN3_8B_ROUTED_ID;
3815        for _ in 0..12 {
3816            let trace = tracker.record_start(qwen_8b_id, InferenceTask::Generate, "test");
3817            tracker.record_complete(&trace, 300, 50, 20);
3818            tracker.record_inferred_outcome(&trace, InferredOutcome::Accepted { confidence: 0.95 });
3819        }
3820
3821        // A Generate prompt, matching the task the outcomes were recorded
3822        // under. "Summarize this design decision." classifies as Reasoning,
3823        // where the reliability band rightly keeps a model measured at 0.45 on
3824        // car-judged away from a 1.0 frontier peer; that is the band's job, not
3825        // the bootstrap gate this test is about.
3826        let local_wins = |tracker: &OutcomeTracker| {
3827            (0..20)
3828                .filter(|_| {
3829                    let decision = router.route("Summarize this paragraph.", &reg, tracker);
3830                    reg.get(&decision.model_id)
3831                        .expect("selected model should exist")
3832                        .is_local()
3833                })
3834                .count()
3835        };
3836
3837        let proven = local_wins(&tracker);
3838        assert!(
3839            proven >= 12,
3840            "proven local model won only {proven}/20 times (expected >= 12)"
3841        );
3842        // Control: without the observations the bootstrap gate holds the same
3843        // prompt on a remote model, so the outcomes are what moved it.
3844        let unproven = local_wins(&OutcomeTracker::new());
3845        assert!(
3846            unproven < 12,
3847            "an unobserved local model won {unproven}/20 times; the gate is not what this measures"
3848        );
3849    }
3850
3851    /// Background routing keeps work on a local model.
3852    ///
3853    /// **Renamed from `benchmark_prior_informs_background_routing`, which is not
3854    /// what it tested** (car#751). That version seeded an outcome profile on
3855    /// qwen3-8b and asserted a local model won — but local wins here whether or
3856    /// not the profile is seeded, so the assertion never observed the prior.
3857    /// Removing the seeding entirely left the test passing. Measured over 2000
3858    /// draws per arm:
3859    ///
3860    /// ```text
3861    /// unseeded: gemma-4-12b 1578, qwen3-8b 348, qwen3-30b-a3b 55, qwen3-4b 15
3862    /// seeded:   gemma-4-12b 1576, qwen3-8b 325, qwen3-30b-a3b 84, qwen3-4b 11
3863    /// ```
3864    ///
3865    /// The seeded model does not gain; if anything it loses slightly, within
3866    /// noise. `prior_strength: 1_000_000.0` — set by the original test to
3867    /// "concentrate the posterior" — is precisely what makes a seeded outcome
3868    /// profile irrelevant, so the test defeated its own premise.
3869    ///
3870    /// What every draw *does* show is that background work stays local, which
3871    /// is a real contract worth holding, and is what this now asserts. Whether
3872    /// a benchmark prior can steer background routing is untested and, on this
3873    /// evidence, may not hold — tracked separately rather than asserted here.
3874
3875    #[test]
3876    fn background_routing_prefers_local_models() {
3877        let router = AdaptiveRouter::new(
3878            test_hw(),
3879            RoutingConfig {
3880                prior_strength: 1_000_000.0,
3881                ..Default::default()
3882            },
3883        );
3884        let reg = test_registry();
3885        let tracker = OutcomeTracker::new();
3886
3887        // Routing is Thompson sampling, so one draw is a sample, not a
3888        // contract — the single-draw form is what failed the 2026-07-31
3889        // nightly.
3890        //
3891        // The threshold is sized from the measured rate ON BOTH TARGETS, which
3892        // is what the first attempt at this fix got wrong: it required 38/40
3893        // after calibrating on Apple Silicon alone, where local wins 5000/5000.
3894        // Off Apple Silicon there is no MLX substitution and the rate is
3895        // 4812/5000 = 96.24%, so 38/40 allows at most 2 remote wins against a
3896        // mean of 1.5 — a **19% false-failure rate**, which duly failed CI.
3897        //
3898        // 170/200 instead: false failure 3e-11 at the measured rate, while a
3899        // regression to 80% local still fails 96% of the time. More draws buy
3900        // the discrimination that a loose threshold on few draws throws away.
3901        let draws = 200;
3902        let mut local_wins = 0;
3903        for _ in 0..draws {
3904            let decision = router.route_context_aware(
3905                "Write a Python fibonacci function.",
3906                128,
3907                &reg,
3908                &tracker,
3909                false,
3910                false,
3911                RoutingWorkload::Background,
3912            );
3913            let schema = reg
3914                .get(&decision.model_id)
3915                .expect("selected model should exist");
3916            if schema.is_local() {
3917                local_wins += 1;
3918            }
3919        }
3920
3921        assert!(
3922            local_wins >= 170,
3923            "background routing should keep work local; local won only \
3924             {local_wins}/{draws} (measured baseline: 96.2% off Apple Silicon, \
3925             100% on it)"
3926        );
3927    }
3928
3929    /// A benchmark/outcome prior **with its evidence** steers background
3930    /// routing — asserted differentially, so it cannot go vacuous (car#753).
3931    ///
3932    /// The predecessors of this test set `ema_quality` directly and left
3933    /// `prior_sample_size` / `quality_observations` at zero. The router reads
3934    /// an EMA together with the evidence behind it and, correctly, refuses to
3935    /// trust a number nothing supports — so those fixtures moved no score at
3936    /// all, and their `is_local()` assertions passed on models that were
3937    /// already winning. Measured, same prompt, `prior_strength: 100.0`:
3938    ///
3939    /// ```text
3940    /// no profile             qwen3-8b 1.32393
3941    /// ema 0.99, no evidence  qwen3-8b 1.32393   <- ignored, by design
3942    /// ema 0.99 + evidence    qwen3-8b 1.44354
3943    /// ema 0.05 + evidence    qwen3-8b 0.85854
3944    /// ```
3945    ///
3946    /// Asserted on the **score of the model the prior names**, not on which
3947    /// model ranks first: whether the prior changes the winner depends on the
3948    /// target — off Apple Silicon there is no MLX substitution and qwen3-8b
3949    /// already ranks first, so a "the top model changed" assertion fails there
3950    /// while the prior is working perfectly well. The score delta is the
3951    /// property; the ranking is a consequence of it.
3952    ///
3953    /// Scored from `candidates` — the deterministic surface — because the
3954    /// winner is Thompson-sampled and explores.
3955    #[test]
3956    fn benchmark_prior_with_evidence_steers_background_routing() {
3957        fn target_score(profile: Option<crate::outcome::ModelProfile>) -> f64 {
3958            let router = AdaptiveRouter::new(
3959                test_hw(),
3960                RoutingConfig {
3961                    prior_strength: 100.0,
3962                    ..Default::default()
3963                },
3964            );
3965            let reg = test_registry();
3966            let mut tracker = OutcomeTracker::new();
3967            if let Some(p) = profile {
3968                tracker.import_profiles(vec![p]);
3969            }
3970            let decision = router.route_context_aware(
3971                "Write a Python fibonacci function.",
3972                128,
3973                &reg,
3974                &tracker,
3975                false,
3976                false,
3977                RoutingWorkload::Background,
3978            );
3979            decision
3980                .candidates
3981                .iter()
3982                .find(|c| c.model_id == QWEN3_8B_ROUTED_ID)
3983                .unwrap_or_else(|| panic!("{QWEN3_8B_ROUTED_ID} should be a candidate"))
3984                .score
3985        }
3986
3987        fn profile_with(ema: f64) -> crate::outcome::ModelProfile {
3988            let mut p = crate::outcome::ModelProfile::new(QWEN3_8B_ROUTED_ID.into());
3989            p.ema_quality = ema;
3990            // The evidence is what makes the EMA admissible. Without it the
3991            // router ignores the number — which is the whole reason the
3992            // predecessors tested nothing.
3993            p.prior_sample_size = 30;
3994            p.quality_observations = 30;
3995            p
3996        }
3997
3998        let control = target_score(None);
3999        let good = target_score(Some(profile_with(0.99)));
4000        let bad = target_score(Some(profile_with(0.05)));
4001
4002        assert!(
4003            good > control,
4004            "an evidence-backed good prior must raise the score it vouches for \
4005             ({good} vs control {control}); equal means this test would pass with \
4006             the prior wiring removed"
4007        );
4008        assert!(
4009            bad < control,
4010            "an evidence-backed poor prior must lower the score ({bad} vs \
4011             control {control})"
4012        );
4013
4014        // An evidence-free EMA carries no information and must be ignored —
4015        // the behaviour that made the predecessors vacuous, pinned so a future
4016        // change cannot start trusting unsupported numbers silently.
4017        let mut unsupported = crate::outcome::ModelProfile::new(QWEN3_8B_ROUTED_ID.into());
4018        unsupported.ema_quality = 0.99;
4019        assert_eq!(
4020            target_score(Some(unsupported)),
4021            control,
4022            "an EMA with no observations behind it must not move the score"
4023        );
4024    }
4025
4026    #[test]
4027    fn task_specific_benchmark_prior_informs_cold_start_routing() {
4028        let router = AdaptiveRouter::new(
4029            test_hw(),
4030            RoutingConfig {
4031                prior_strength: 100.0,
4032                ..Default::default()
4033            },
4034        );
4035        let reg = test_registry();
4036        let mut tracker = OutcomeTracker::new();
4037        // Seeded WITH its evidence counts, and against the id the router
4038        // actually reads on this target. Without the counts the router
4039        // correctly ignores the EMA, which left this test asserting an
4040        // outcome that was already true (car#753).
4041        let mut profile = crate::outcome::ModelProfile::new(QWEN3_8B_ROUTED_ID.into());
4042        profile.task_stats.insert(
4043            crate::outcome::InferenceTask::Code.to_string(),
4044            crate::outcome::TaskStats {
4045                ema_quality: 0.95,
4046                prior_sample_size: 30,
4047                quality_observations: 30,
4048                ..Default::default()
4049            },
4050        );
4051        tracker.import_profiles(vec![profile]);
4052
4053        let decision = router.route_context_aware(
4054            "Write a Python fibonacci function.",
4055            128,
4056            &reg,
4057            &tracker,
4058            false,
4059            false,
4060            RoutingWorkload::Background,
4061        );
4062
4063        // Selection uses Thompson sampling for Background work, so the winner
4064        // intentionally explores. The ranked candidates are the deterministic
4065        // score surface that proves the task-specific prior wiring.
4066        let top_scored = decision
4067            .candidates
4068            .first()
4069            .expect("routing should expose ranked candidates");
4070        let schema = reg
4071            .get(&top_scored.model_id)
4072            .expect("top-scored model should exist");
4073        assert!(
4074            schema.is_local(),
4075            "background routing should score the task-specific local code prior highest"
4076        );
4077    }
4078
4079    #[test]
4080    fn task_specific_latency_prior_affects_cold_start_score() {
4081        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
4082        let reg = test_registry();
4083        let model = reg
4084            .get("qwen/qwen3-8b:q4_k_m")
4085            .expect("local test model should exist");
4086
4087        let mut fast_tracker = OutcomeTracker::new();
4088        let mut fast_profile = crate::outcome::ModelProfile::new(model.id.clone());
4089        fast_profile.task_stats.insert(
4090            crate::outcome::InferenceTask::Generate.to_string(),
4091            crate::outcome::TaskStats {
4092                ema_quality: 0.95,
4093                avg_latency_ms: 1200.0,
4094                ..Default::default()
4095            },
4096        );
4097        fast_tracker.import_profiles(vec![fast_profile]);
4098
4099        let mut slow_tracker = OutcomeTracker::new();
4100        let mut slow_profile = crate::outcome::ModelProfile::new(model.id.clone());
4101        slow_profile.task_stats.insert(
4102            crate::outcome::InferenceTask::Generate.to_string(),
4103            crate::outcome::TaskStats {
4104                ema_quality: 0.95,
4105                avg_latency_ms: 120_000.0,
4106                ..Default::default()
4107            },
4108        );
4109        slow_tracker.import_profiles(vec![slow_profile]);
4110
4111        let fast_score = router.score_model(
4112            model,
4113            InferenceTask::Generate,
4114            &fast_tracker,
4115            RoutingWorkload::Interactive,
4116            0,
4117            0,
4118            0,
4119            0,
4120        );
4121        let slow_score = router.score_model(
4122            model,
4123            InferenceTask::Generate,
4124            &slow_tracker,
4125            RoutingWorkload::Interactive,
4126            0,
4127            0,
4128            0,
4129            0,
4130        );
4131
4132        assert!(
4133            fast_score > slow_score,
4134            "faster task latency prior should improve cold-start score ({fast_score} <= {slow_score})"
4135        );
4136    }
4137
4138    #[test]
4139    fn interactive_workload_keeps_remote_bootstrap_bias() {
4140        let router = AdaptiveRouter::new(
4141            test_hw(),
4142            RoutingConfig {
4143                prior_strength: 100.0,
4144                ..Default::default()
4145            },
4146        );
4147        let reg = test_registry();
4148        let mut tracker = OutcomeTracker::new();
4149        let mut profile = crate::outcome::ModelProfile::new("qwen/qwen3-8b:q4_k_m".into());
4150        profile.ema_quality = 0.95;
4151        tracker.import_profiles(vec![profile]);
4152
4153        let decision = router.route_context_aware(
4154            "Write a Python fibonacci function.",
4155            128,
4156            &reg,
4157            &tracker,
4158            false,
4159            false,
4160            RoutingWorkload::Interactive,
4161        );
4162
4163        // A high EMA with no calls behind it proves nothing: the 8B (measured
4164        // well below the trusted remotes) must not win.
4165        assert_ne!(decision.model_id, "qwen/qwen3-8b:q4_k_m", "{decision:?}");
4166        assert_cold_start_pick_is_frontier_grade(&router, &reg, &decision);
4167    }
4168
4169    #[test]
4170    fn code_interactive_weights_prioritise_quality_over_speed_over_cost() {
4171        // car-releases#52: task=code under the default interactive
4172        // workload must rank quality > speed > cost, with quality
4173        // clearly dominant — not the near-tie (0.45 q, 0.40 lat) the
4174        // generic interactive profile produces, which let a fast cheap
4175        // general model out-score a code-tuned one.
4176        let (q, lat, cost) =
4177            AdaptiveRouter::task_aware_weights(InferenceTask::Code, RoutingWorkload::Interactive);
4178        assert!(
4179            q > lat && lat > cost,
4180            "expected quality > speed > cost, got ({q}, {lat}, {cost})"
4181        );
4182        assert!(
4183            (q + lat + cost - 1.0).abs() < 1e-9,
4184            "weights must sum to 1.0"
4185        );
4186        let (default_q, default_lat, default_cost) = RoutingWorkload::Interactive.weights();
4187        assert!(
4188            q > default_q && lat < default_lat && cost < default_cost,
4189            "code weighting must lift quality and cut latency/cost vs the generic interactive profile"
4190        );
4191
4192        // LocalPreferred is interactive-class — same code reweighting.
4193        assert_eq!(
4194            AdaptiveRouter::task_aware_weights(
4195                InferenceTask::Code,
4196                RoutingWorkload::LocalPreferred,
4197            ),
4198            (q, lat, cost),
4199        );
4200
4201        // Fastest (voice fast-track) is an explicit hard-latency demand
4202        // — never overridden, even for code.
4203        assert_eq!(
4204            AdaptiveRouter::task_aware_weights(InferenceTask::Code, RoutingWorkload::Fastest),
4205            RoutingWorkload::Fastest.weights(),
4206        );
4207        // Batch/Background encode a deliberate non-interactive choice.
4208        assert_eq!(
4209            AdaptiveRouter::task_aware_weights(InferenceTask::Code, RoutingWorkload::Background),
4210            RoutingWorkload::Background.weights(),
4211        );
4212        // Non-code tasks are untouched under every workload.
4213        assert_eq!(
4214            AdaptiveRouter::task_aware_weights(
4215                InferenceTask::Generate,
4216                RoutingWorkload::Interactive,
4217            ),
4218            RoutingWorkload::Interactive.weights(),
4219        );
4220    }
4221
4222    #[test]
4223    fn fallback_chain_has_alternatives() {
4224        let router = AdaptiveRouter::new(
4225            test_hw(),
4226            RoutingConfig {
4227                prior_strength: 100.0, // strong prior = exploit Phase 2 scores (deterministic-ish)
4228                ..Default::default()
4229            },
4230        );
4231        let reg = test_registry();
4232        let tracker = OutcomeTracker::new();
4233
4234        let decision = router.route("Analyze the architecture trade-offs", &reg, &tracker);
4235        assert!(!decision.fallbacks.is_empty());
4236        // Primary should not appear in fallbacks
4237        assert!(!decision.fallbacks.contains(&decision.model_id));
4238    }
4239
4240    #[test]
4241    fn latency_scoring_is_consistent() {
4242        // Verify that schema and observed latency produce comparable scores
4243        let router = AdaptiveRouter::with_default_config(test_hw());
4244
4245        // A model at 25 TPS → ~200/25*1000 = 8000ms → score = 1 - 8000/10000 = 0.2
4246        let schema_score = router.latency_ms_to_score(AdaptiveRouter::tps_to_latency_ms(25.0));
4247        // Same model observed at 8000ms → same formula
4248        let observed_score = router.latency_ms_to_score(8000.0);
4249        assert!(
4250            (schema_score - observed_score).abs() < 0.01,
4251            "schema ({schema_score}) and observed ({observed_score}) should match"
4252        );
4253    }
4254
4255    #[test]
4256    fn complexity_assessment() {
4257        assert_eq!(
4258            TaskComplexity::assess("What is the capital of France?"),
4259            TaskComplexity::Simple
4260        );
4261        assert_eq!(
4262            TaskComplexity::assess("Fix this broken test"),
4263            TaskComplexity::Code
4264        );
4265        assert_eq!(
4266            TaskComplexity::assess("Analyze the trade-offs between A and B"),
4267            TaskComplexity::Complex
4268        );
4269    }
4270
4271    #[test]
4272    fn beta_sampling_produces_valid_values() {
4273        let mut rng = rand::rng();
4274        // Sample 100 times from Beta(2, 5) — should be in [0, 1]
4275        for _ in 0..100 {
4276            let s = sample_beta(&mut rng, 2.0, 5.0);
4277            assert!((0.0..=1.0).contains(&s), "sample {s} out of [0,1] range");
4278        }
4279        // Beta(1, 1) = Uniform(0, 1) — mean should be ~0.5
4280        let samples: Vec<f64> = (0..1000).map(|_| sample_beta(&mut rng, 1.0, 1.0)).collect();
4281        let mean = samples.iter().sum::<f64>() / samples.len() as f64;
4282        assert!(
4283            (mean - 0.5).abs() < 0.05,
4284            "Beta(1,1) mean {mean} should be ~0.5"
4285        );
4286    }
4287
4288    /// Draws `scored` 200 times under the production config and returns how
4289    /// often `id` won.
4290    fn thompson_wins(scored: &[(String, f64)], tracker: &OutcomeTracker, id: &str) -> usize {
4291        let router = AdaptiveRouter::with_default_config(test_hw());
4292        (0..200)
4293            .filter(|_| router.select_with_thompson_sampling(scored, tracker).0 == id)
4294            .count()
4295    }
4296
4297    fn record_calls(tracker: &mut OutcomeTracker, id: &str, completed: usize) {
4298        for _ in 0..completed {
4299            let trace = tracker.record_start(id, InferenceTask::Code, "test");
4300            tracker.record_complete(&trace, 500, 100, 50);
4301        }
4302    }
4303
4304    /// Completed calls are not quality evidence. Counted as Beta successes,
4305    /// 900 of them made a 0.80-scored model win nearly every draw against an
4306    /// unobserved 0.92 one.
4307    #[test]
4308    fn many_completed_calls_do_not_outvote_a_better_score() {
4309        let mut tracker = OutcomeTracker::new();
4310        record_calls(&mut tracker, "busy", 900);
4311        let scored = vec![("better".to_string(), 0.92), ("busy".to_string(), 0.80)];
4312        let wins = thompson_wins(&scored, &tracker, "better");
4313        assert!(wins >= 160, "the better-scored model won only {wins}/200");
4314    }
4315
4316    /// Scores above 1.0 (stacked locality bonuses) still rank: the higher one
4317    /// wins most draws instead of tying at a clamp.
4318    #[test]
4319    fn scores_above_one_still_rank() {
4320        let tracker = OutcomeTracker::new();
4321        // Listed second: a tie goes to the first entry.
4322        let scored = vec![("plain".to_string(), 1.0), ("bonused".to_string(), 1.15)];
4323        let wins = thompson_wins(&scored, &tracker, "bonused");
4324        assert!(wins >= 130, "the higher-scored model won only {wins}/200");
4325    }
4326
4327    /// Unobserved near-ties are still explored: each wins some draws.
4328    #[test]
4329    fn unobserved_near_ties_are_both_explored() {
4330        let tracker = OutcomeTracker::new();
4331        let scored = vec![("a".to_string(), 0.90), ("b".to_string(), 0.88)];
4332        let wins = thompson_wins(&scored, &tracker, "a");
4333        assert!((40..=160).contains(&wins), "a won {wins}/200 of a near-tie");
4334    }
4335
4336    #[test]
4337    fn thompson_sampling_converges_to_best() {
4338        // A model with strong observed success should win most of the time,
4339        // and far more often than it does without that evidence.
4340        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
4341        let reg = test_registry();
4342        let qwen_4b_id = QWEN3_4B_ROUTED_ID;
4343        let wins = |tracker: &OutcomeTracker| {
4344            (0..200)
4345                .filter(|_| {
4346                    router.route("Fix this parser bug", &reg, tracker).model_id == qwen_4b_id
4347                })
4348                .count()
4349        };
4350        let unproven = wins(&OutcomeTracker::new());
4351
4352        // Give Qwen3-4B 20 successes (strong signal)
4353        let mut tracker = OutcomeTracker::new();
4354        for _ in 0..20 {
4355            let trace = tracker.record_start(qwen_4b_id, InferenceTask::Code, "test");
4356            tracker.record_complete(&trace, 500, 100, 50);
4357            tracker.record_inferred_outcome(&trace, InferredOutcome::Accepted { confidence: 0.95 });
4358        }
4359        // The catalog's measured frontier rows (car-judged 1.0) still draw
4360        // some routes: measured 128-131/200 without the MLX bonus
4361        // (car_skip_mlx), 198-200 with it.
4362        let proven = wins(&tracker);
4363        assert!(
4364            proven >= 100 && proven >= unproven + 60,
4365            "strong model won {proven}/200 (without its outcomes: {unproven}/200)"
4366        );
4367    }
4368
4369    // ----- Intent surface (parslee-ai/car-releases#18) -----
4370
4371    #[test]
4372    fn cold_start_never_returns_a_model_lacking_a_required_capability() {
4373        // The regression: a `require: [Code]` request whose hard filter left
4374        // no candidate used to fall to the complexity→model default
4375        // (Simple → Qwen3-0.6B), which has NO Code capability — silently
4376        // violating the hard filter. Drive cold_start directly with
4377        // quality-first disabled (so the trusted-remote branch is skipped and
4378        // the local/default path is exercised) and assert the chosen model
4379        // actually satisfies the requirement.
4380        let router = AdaptiveRouter::new(
4381            test_hw(),
4382            RoutingConfig {
4383                quality_first_cold_start: false,
4384                ..Default::default()
4385            },
4386        );
4387        let reg = test_registry();
4388
4389        for caps in [
4390            vec![ModelCapability::Code],
4391            vec![ModelCapability::Code, ModelCapability::ToolUse],
4392        ] {
4393            // Simple complexity is the worst case — its default (Qwen3-0.6B)
4394            // lacks Code and ToolUse, so a pre-fix cold start would return it.
4395            let decision = router.cold_start_decision(
4396                TaskComplexity::Simple,
4397                InferenceTask::Code,
4398                &caps,
4399                &reg,
4400                false,
4401                &std::collections::HashSet::new(),
4402                false,
4403                AvailabilitySnapshot::capture(&reg),
4404            );
4405            let chosen = reg
4406                .find_by_name(&decision.model_name)
4407                .or_else(|| reg.list().into_iter().find(|m| m.id == decision.model_id))
4408                .unwrap_or_else(|| panic!("cold start named an unknown model: {decision:?}"));
4409            for cap in &caps {
4410                assert!(
4411                    chosen.has_capability(*cap),
4412                    "cold start chose {} which lacks required {:?} (caps {:?})",
4413                    chosen.name,
4414                    cap,
4415                    caps
4416                );
4417            }
4418            // It resolved via a capability-satisfying cold-start branch, not the
4419            // hardcoded complexity default. [Code] is satisfied locally; [Code,
4420            // ToolUse] is no longer locally satisfiable (in-process backends
4421            // can't execute tool calls) so it resolves to the available remote
4422            // tool-capable model — either way a capability-satisfying branch,
4423            // never a cap-lacking default.
4424            assert_eq!(
4425                decision.strategy,
4426                RoutingStrategy::SchemaBased,
4427                "expected schema-based cold start"
4428            );
4429            assert!(
4430                decision.reason.contains("capability-satisfying"),
4431                "expected a capability-satisfying cold-start branch, got: {}",
4432                decision.reason
4433            );
4434        }
4435    }
4436
4437    #[test]
4438    fn cold_start_skips_a_capable_local_that_exceeds_the_memory_budget() {
4439        // A capability-satisfying local that doesn't FIT must not be chosen by
4440        // the local branch — the strict `< max_model_mb` gate mirrors
4441        // filter_candidates. With a tiny budget no local fits, so cold start
4442        // falls through to the hardcoded default (best-effort handoff).
4443        let mut hw = test_hw();
4444        hw.max_model_mb = 300; // below every Qwen3 local
4445        let router = AdaptiveRouter::new(
4446            hw,
4447            RoutingConfig {
4448                quality_first_cold_start: false,
4449                ..Default::default()
4450            },
4451        );
4452        let reg = test_registry();
4453        let decision = router.cold_start_decision(
4454            TaskComplexity::Code,
4455            InferenceTask::Code,
4456            &[ModelCapability::Code],
4457            &reg,
4458            false,
4459            &std::collections::HashSet::new(),
4460            false,
4461            AvailabilitySnapshot::capture(&reg),
4462        );
4463        assert!(
4464            !decision.reason.contains("local cold start"),
4465            "no local fits the 300MB budget, so the local branch must not fire: {}",
4466            decision.reason
4467        );
4468    }
4469
4470    #[test]
4471    fn intent_require_filters_out_models_lacking_capability() {
4472        // Asking for vision when no candidate has it should fall to
4473        // the cold-start decision rather than scoring incompatible
4474        // candidates. The fixture registry has no vision-capable
4475        // local models.
4476        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
4477        let reg = test_registry();
4478        let tracker = OutcomeTracker::new();
4479
4480        let intent = crate::intent::IntentHint {
4481            require: vec![ModelCapability::Vision],
4482            ..Default::default()
4483        };
4484        let decision = router.route_with_intent("hello", &reg, &tracker, &intent);
4485
4486        // When require filters out every candidate, the router falls
4487        // to the schema-based cold-start path. Asserting the strategy
4488        // (not just non-empty model_id) catches a regression where
4489        // future code might silently include filtered candidates.
4490        assert_eq!(
4491            decision.strategy,
4492            RoutingStrategy::SchemaBased,
4493            "require=[vision] with no vision-capable candidates must drop to schema cold-start"
4494        );
4495    }
4496
4497    #[test]
4498    fn unbenchmarked_remote_is_conservative_not_assumed_frontier() {
4499        // Conservative-unknown design: an UNMEASURED curated remote is no
4500        // longer assumed frontier-class. It scores a conservative 0.60 —
4501        // routable, above the capped local size-heuristics, but below the
4502        // measured-frontier band — so a model carrying REAL benchmarks ranks
4503        // above it on evidence.
4504        // (Replaces the old "benchmark-less remote == 0.85 frontier" rule,
4505        // under which honest hard-benchmark data demoted strong models below
4506        // unmeasured ones — see the doc note.) A curated row's reviewed
4507        // `frontier`/`balanced` tag still earns that tier's prior, so this
4508        // picks a row with neither tag.
4509        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
4510        let reg = test_registry();
4511        let remote = reg
4512            .list()
4513            .into_iter()
4514            .find(|m| {
4515                m.is_remote()
4516                    && m.public_benchmarks.is_empty()
4517                    && !m.tags.iter().any(|t| t == "frontier" || t == "balanced")
4518            })
4519            .expect("a benchmark-less, tier-untagged remote in the fixture")
4520            .clone();
4521        let q = router.schema_quality_estimate(&remote);
4522        assert!(
4523            (q - 0.60).abs() < 1e-9,
4524            "unmeasured curated remote should be a conservative 0.60, got {q}"
4525        );
4526
4527        // A model carrying published benchmarks (uncalibrated names → raw
4528        // passthrough) uses their average, which for a strong model lands above
4529        // the unmeasured default.
4530        let mut benched = remote.clone();
4531        benched.public_benchmarks = vec![
4532            crate::schema::BenchmarkScore {
4533                name: "A".into(),
4534                score: 0.9,
4535                harness: None,
4536                source_url: None,
4537                measured_at: None,
4538                runs: None,
4539                spread: None,
4540            },
4541            crate::schema::BenchmarkScore {
4542                name: "B".into(),
4543                score: 0.7,
4544                harness: None,
4545                source_url: None,
4546                measured_at: None,
4547                runs: None,
4548                spread: None,
4549            },
4550        ];
4551        let qb = router.schema_quality_estimate(&benched);
4552        assert!((qb - 0.8).abs() < 1e-9);
4553        assert!(
4554            qb > q,
4555            "real benchmarks must lift a model above the unmeasured default"
4556        );
4557    }
4558
4559    #[test]
4560    fn calibrated_benchmark_is_normalized_onto_the_tier_scale() {
4561        // A hard agentic benchmark's raw score is remapped onto the quality
4562        // tier so frontier-class lands near the top — NOT taken raw (which
4563        // would rank a measured frontier model below an unmeasured local).
4564        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
4565        let bench = |raw: f64| {
4566            let mut m = outcome_model("m", None);
4567            m.public_benchmarks = vec![crate::schema::BenchmarkScore {
4568                name: "car-judged".into(),
4569                score: raw,
4570                harness: None,
4571                source_url: None,
4572                measured_at: None,
4573                runs: None,
4574                spread: None,
4575            }];
4576            router.schema_quality_estimate(&m)
4577        };
4578        // frontier_ref 1.0 → FLOOR 0.40, CEIL 0.92.
4579        let frontier = bench(1.0); // → ~0.92
4580        let strong = bench(0.54); // gpt-5.4-mini-class → ~0.68
4581        assert!(
4582            (frontier - 0.92).abs() < 0.01,
4583            "best-in-class → ~CEIL, got {frontier}"
4584        );
4585        assert!(
4586            (strong - 0.68).abs() < 0.02,
4587            "0.54 raw → ~0.68 tier, got {strong}"
4588        );
4589        assert!(
4590            strong > router.schema_quality_estimate(&local_model("local-30b", 18_000)),
4591            "a measured frontier model must out-rank an unmeasured 30B local"
4592        );
4593    }
4594
4595    /// Measuring a model that fails most of a suite must not raise it above
4596    /// what its size alone would earn. The real Qwen3.5 rows: 0.054 and 0.179
4597    /// on car-judged against a frontier of 1.0.
4598    #[test]
4599    fn a_measured_failing_score_ranks_below_its_unmeasured_size_proxy() {
4600        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
4601        let catalog = crate::registry::builtin_catalog();
4602        for id in ["vllm-mlx/qwen3.5-0.8b:4bit", "vllm-mlx/qwen3.5-2b:4bit"] {
4603            let measured = catalog.iter().find(|m| m.id == id).unwrap();
4604            assert!(measured.public_benchmarks[0].score < 0.2, "{id}");
4605            let mut unmeasured = measured.clone();
4606            unmeasured.public_benchmarks.clear();
4607            assert!(
4608                router.schema_quality_estimate(measured)
4609                    < router.schema_quality_estimate(&unmeasured),
4610                "{id}: measured {} vs size proxy {}",
4611                router.schema_quality_estimate(measured),
4612                router.schema_quality_estimate(&unmeasured)
4613            );
4614        }
4615        // At and above the knee the mapping is unchanged, and it is continuous.
4616        let knee = AdaptiveRouter::BENCH_TIER_KNEE;
4617        let mut m = outcome_model("m", None);
4618        m.public_benchmarks = vec![crate::schema::BenchmarkScore {
4619            name: "car-judged".into(),
4620            score: knee,
4621            harness: None,
4622            source_url: None,
4623            measured_at: None,
4624            runs: None,
4625            spread: None,
4626        }];
4627        let at_knee = router.schema_quality_estimate(&m);
4628        m.public_benchmarks[0].score = knee - 1e-6;
4629        assert!((router.schema_quality_estimate(&m) - at_knee).abs() < 1e-5);
4630        assert!(
4631            (at_knee
4632                - (AdaptiveRouter::BENCH_TIER_FLOOR
4633                    + knee * (AdaptiveRouter::BENCH_TIER_CEIL - AdaptiveRouter::BENCH_TIER_FLOOR)))
4634                .abs()
4635                < 1e-9
4636        );
4637    }
4638
4639    #[test]
4640    fn uncalibrated_benchmark_is_clamped_into_the_tier_band() {
4641        // A benchmark with no frontier reference can't be scaled, so it's
4642        // clamped into [FLOOR, CEIL] rather than passed through raw — a raw
4643        // hard-benchmark score below FLOOR would re-invert the scale.
4644        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
4645        let q = |raw: f64| {
4646            let mut m = outcome_model("m", None);
4647            m.public_benchmarks = vec![crate::schema::BenchmarkScore {
4648                name: "some-new-unknown-bench".into(),
4649                score: raw,
4650                harness: None,
4651                source_url: None,
4652                measured_at: None,
4653                runs: None,
4654                spread: None,
4655            }];
4656            router.schema_quality_estimate(&m)
4657        };
4658        // In-band value untouched; a sub-floor raw is lifted to FLOOR, not left
4659        // below the measured band where it would undercut a 30B local.
4660        assert!((q(0.73) - 0.73).abs() < 1e-9);
4661        assert!((q(0.10) - AdaptiveRouter::BENCH_TIER_FLOOR).abs() < 1e-9);
4662        assert!((q(0.99) - AdaptiveRouter::BENCH_TIER_CEIL).abs() < 1e-9);
4663    }
4664
4665    #[test]
4666    fn local_uncalibrated_benchmark_maps_into_the_local_tier_band() {
4667        // A LOCAL model's self-measured (frontier-less) benchmark must map into
4668        // the LOCAL sub-band [0.30, 0.60], NOT the measured-frontier band — its
4669        // raw score on an unanchored suite isn't comparable to a frontier-
4670        // calibrated score (the monoculture trap). It replaces the size proxy
4671        // for ordering locals, but stays below the frontier band (#368).
4672        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
4673        let q = |raw: f64| {
4674            let mut m = local_model("local-bench", 2500); // a 4B-class local
4675            m.public_benchmarks = vec![crate::schema::BenchmarkScore {
4676                name: "car-agentic-suite".into(), // not in catalog → uncalibrated
4677                score: raw,
4678                harness: None,
4679                source_url: None,
4680                measured_at: None,
4681                runs: None,
4682                spread: None,
4683            }];
4684            router.schema_quality_estimate(&m)
4685        };
4686        // In-band raw passes through; extremes clamp to the LOCAL band, never the
4687        // frontier band. A perfect local-suite score tops out at LOCAL_CEIL (0.60),
4688        // strictly below frontier best-in-class (CEIL 0.92).
4689        assert!(
4690            (q(0.45) - 0.45).abs() < 1e-9,
4691            "in-band local score passes through"
4692        );
4693        assert!(
4694            (q(0.05) - AdaptiveRouter::LOCAL_BENCH_TIER_FLOOR).abs() < 1e-9,
4695            "sub-floor clamps to LOCAL floor 0.30, not frontier FLOOR 0.40"
4696        );
4697        assert!(
4698            (q(0.95) - AdaptiveRouter::LOCAL_BENCH_TIER_CEIL).abs() < 1e-9,
4699            "a perfect local-suite score tops at LOCAL ceil 0.60, not frontier CEIL 0.92"
4700        );
4701        // Discrimination: a higher measured score ranks higher within the tier.
4702        assert!(q(0.55) > q(0.35));
4703        // Invariant: a measured local never reaches the frontier band's reach —
4704        // a benchmarked frontier model (car-judged champion → ~CEIL) still wins.
4705        let mut frontier = outcome_model("frontier", None);
4706        frontier.public_benchmarks = vec![crate::schema::BenchmarkScore {
4707            name: "car-judged".into(),
4708            score: 1.0,
4709            harness: None,
4710            source_url: None,
4711            measured_at: None,
4712            runs: None,
4713            spread: None,
4714        }];
4715        assert!(
4716            router.schema_quality_estimate(&frontier) > q(0.95),
4717            "a benchmarked frontier model out-ranks even a perfect-scoring local"
4718        );
4719    }
4720
4721    #[test]
4722    fn frontier_ref_is_derived_from_catalog_best_in_class() {
4723        // The frontier reference is the best score any catalog model reaches on
4724        // a benchmark — derived, not hardcoded. Every catalog benchmark thus has
4725        // a reference automatically (no "forgot to register it" footgun), and it
4726        // equals the observed max.
4727        use std::collections::HashMap;
4728        let mut max_by_bench: HashMap<String, f64> = HashMap::new();
4729        for model in crate::registry::builtin_catalog() {
4730            for b in &model.public_benchmarks {
4731                let e = max_by_bench.entry(b.name.clone()).or_insert(0.0);
4732                *e = e.max(b.score);
4733            }
4734        }
4735        assert!(!max_by_bench.is_empty(), "catalog should carry benchmarks");
4736        for (name, max) in max_by_bench {
4737            let derived = AdaptiveRouter::benchmark_frontier_ref(&name)
4738                .unwrap_or_else(|| panic!("benchmark '{name}' must derive a frontier_ref"));
4739            assert!(
4740                (derived - max).abs() < 1e-9,
4741                "frontier_ref for '{name}' should be the catalog max {max}, got {derived}"
4742            );
4743        }
4744        // Concretely, today: car-judged tops out at 1.0 (gpt-6.1-sol).
4745        assert_eq!(
4746            AdaptiveRouter::benchmark_frontier_ref("car-judged"),
4747            Some(1.0)
4748        );
4749        // A benchmark no catalog model carries → no reference → clamp path.
4750        assert_eq!(
4751            AdaptiveRouter::benchmark_frontier_ref("nonexistent-bench"),
4752            None
4753        );
4754    }
4755
4756    // ---- #371: frontier-less-population guard --------------------------------
4757
4758    /// Build a benchmarked model fixture with an explicit frontier-class flag.
4759    fn benchmarked_model(id: &str, bench: &str, score: f64, frontier: bool) -> ModelSchema {
4760        let mut m = outcome_model(id, None);
4761        if frontier {
4762            m.tags.push("frontier".into());
4763        }
4764        m.public_benchmarks = vec![crate::schema::BenchmarkScore {
4765            name: bench.into(),
4766            score,
4767            harness: None,
4768            source_url: None,
4769            measured_at: None,
4770            runs: None,
4771            spread: None,
4772        }];
4773        m
4774    }
4775
4776    #[test]
4777    fn frontier_ref_guard_drops_frontier_less_benchmark() {
4778        // A benchmark carried ONLY by non-frontier models must NOT self-anchor:
4779        // its best score is not a trustworthy frontier, so it gets no reference
4780        // (→ uncalibrated clamp) rather than inflating the mid-tier champion to
4781        // CEIL. (#371)
4782        let models = [
4783            benchmarked_model("mid-a", "midtier-only-bench", 0.50, false),
4784            benchmarked_model("mid-b", "midtier-only-bench", 0.40, false),
4785        ];
4786        let refs = compute_frontier_refs(models.iter());
4787        assert!(
4788            !refs.contains_key("midtier-only-bench"),
4789            "a benchmark with no frontier-class carrier must not self-anchor, got {refs:?}"
4790        );
4791    }
4792
4793    #[test]
4794    fn frontier_ref_guard_anchors_when_a_frontier_model_carries_the_benchmark() {
4795        // Add one frontier-class carrier and the same benchmark anchors — at the
4796        // true catalog max (the champion score), NOT a percentile. The non-
4797        // frontier model scoring higher than the frontier one still sets the
4798        // anchor (max), so the ordering signal is preserved (#371).
4799        let models = [
4800            benchmarked_model("frontier-x", "shared-bench", 0.60, true),
4801            benchmarked_model("mid-y", "shared-bench", 0.70, false),
4802        ];
4803        let refs = compute_frontier_refs(models.iter());
4804        assert_eq!(
4805            refs.get("shared-bench").copied(),
4806            Some(0.70),
4807            "anchor is the catalog max across all carriers once a frontier carrier exists"
4808        );
4809    }
4810
4811    #[test]
4812    fn frontier_ref_guard_is_noop_on_todays_catalog() {
4813        // The guard must not change today's behavior: every benchmark the real
4814        // catalog carries has a frontier-class carrier, so the guarded table
4815        // equals the raw max-per-benchmark table. This pins the "holds by
4816        // accident of composition" property as an explicit invariant (#371).
4817        use std::collections::HashMap;
4818        let catalog = crate::registry::builtin_catalog();
4819        let mut raw_max: HashMap<String, f64> = HashMap::new();
4820        for m in &catalog {
4821            for b in &m.public_benchmarks {
4822                let e = raw_max.entry(b.name.clone()).or_insert(0.0);
4823                *e = e.max(b.score);
4824            }
4825        }
4826        let guarded = compute_frontier_refs(catalog.iter());
4827        assert_eq!(
4828            raw_max, guarded,
4829            "guard dropped a benchmark — a catalog benchmark lost its frontier-class carrier"
4830        );
4831        assert!(
4832            model_is_frontier_class(
4833                catalog
4834                    .iter()
4835                    .find(|m| m.id == "openai/gpt-6.1-sol:latest")
4836                    .expect("gpt-6.1-sol in catalog")
4837            ),
4838            "the car-judged champion must be frontier-tagged for the anchor to be valid"
4839        );
4840    }
4841
4842    #[test]
4843    fn score_above_the_frontier_clamps_to_ceil() {
4844        // A model scoring above the current best-in-class (the frontier moved
4845        // but the catalog hasn't caught up) maps to CEIL — it ranks at the top,
4846        // it just can't out-scale the champion until the catalog re-anchors.
4847        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
4848        let mut m = outcome_model("future-sota", None);
4849        m.public_benchmarks = vec![crate::schema::BenchmarkScore {
4850            name: "car-judged".into(),
4851            // The catalog max is a perfect 1.0, so only an out-of-range score
4852            // can exceed it; the clamp under test is the same either way.
4853            score: 1.05,
4854            harness: None,
4855            source_url: None,
4856            measured_at: None,
4857            runs: None,
4858            spread: None,
4859        }];
4860        assert!(
4861            (router.schema_quality_estimate(&m) - AdaptiveRouter::BENCH_TIER_CEIL).abs() < 1e-9,
4862            "above-frontier score should clamp to CEIL"
4863        );
4864    }
4865
4866    /// car-releases#105: the frontier-tag prior used to apply to OpenRouter
4867    /// rows only, so a native curated frontier row with no published benchmark
4868    /// (`openai/gpt-5.6-sol:latest`) sat at the untagged 0.60 and ranked below
4869    /// a car-judged 4B local (0.679 → ~0.75 on the tier scale) and a
4870    /// measured mini (`gpt-5.4-mini`) on a prefer_quality code
4871    /// route. Uses the real builtin-catalog rows so a catalog edit that
4872    /// changes a tag or benchmark is caught here, not in production.
4873    #[test]
4874    fn curated_native_frontier_row_outranks_measured_small_models_on_quality_code() {
4875        let _environment = crate::openrouter::test_environment_scope();
4876        let _restore = RestoredEnvironment::capture(&["OPENAI_API_KEY", "CAR_SECRETS_FILE_DIR"]);
4877        unsafe {
4878            std::env::set_var("OPENAI_API_KEY", "test-openai-key");
4879            std::env::set_var(
4880                "CAR_SECRETS_FILE_DIR",
4881                std::env::temp_dir().join("car-test-frontier-prior-empty-secrets"),
4882            );
4883        }
4884        let catalog: Vec<ModelSchema> =
4885            serde_json::from_str(include_str!("builtin_catalog.json")).unwrap();
4886        let row = |id: &str| {
4887            let mut m = catalog
4888                .iter()
4889                .find(|m| m.id == id)
4890                .unwrap_or_else(|| panic!("{id} missing from builtin catalog"))
4891                .clone();
4892            m.available = true;
4893            m.weights_ready = true;
4894            m
4895        };
4896        // The row is measured now (car-judged); the prior under test is the
4897        // one an unmeasured curated frontier row gets, so drop its benchmark.
4898        let mut frontier = row("openai/gpt-5.6-sol:latest");
4899        frontier.public_benchmarks.clear();
4900        let mini = row("openai/gpt-5.4-mini:latest");
4901        let local = row(QWEN3_4B_ROUTED_ID);
4902        // Pin the premise, so a catalog change cannot turn this into a test of
4903        // something else.
4904        assert_eq!(frontier.trust_tier, crate::schema::TrustTier::Curated);
4905        assert!(model_is_frontier_class(&frontier));
4906        assert!(!frontier.tags.iter().any(|t| t == "openrouter"));
4907        assert!(frontier.public_benchmarks.is_empty(), "must be unmeasured");
4908        assert!(matches!(
4909            frontier.source,
4910            crate::schema::ModelSource::RemoteApi { .. }
4911        ));
4912        assert_eq!(mini.public_benchmarks[0].name, "car-judged");
4913        assert_eq!(local.public_benchmarks[0].name, "car-judged");
4914        assert!(local.is_local());
4915
4916        let tmp = tempfile::tempdir().unwrap();
4917        let mut registry = UnifiedRegistry::new_empty(tmp.path().join("models"));
4918        for m in [frontier.clone(), mini.clone(), local.clone()] {
4919            assert!(registry.register_project_model(m));
4920        }
4921        // On Apple Silicon with MLX not skipped the local row is the MLX twin,
4922        // and admission marks it unavailable when this build did not link the
4923        // Swift loader that runs it (no Metal Toolchain, CAR_BUILD_SWIFT_LM=0).
4924        // Everywhere else it is the GGUF row, which admission keeps.
4925        let local_routable = !cfg!(all(
4926            target_os = "macos",
4927            target_arch = "aarch64",
4928            not(car_skip_mlx)
4929        )) || crate::backend::local::in_process_loader_available();
4930        assert_eq!(
4931            registry.get(&local.id).map(|m| m.available),
4932            Some(local_routable),
4933            "admission must agree with whether this build can run {}",
4934            local.id
4935        );
4936
4937        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
4938        let tracker = OutcomeTracker::new();
4939        let intent = crate::intent::IntentHint {
4940            task: Some(crate::intent::TaskHint::Code),
4941            require: vec![ModelCapability::Code],
4942            prefer_quality: true,
4943            ..Default::default()
4944        };
4945        let decision = router.route_with_intent(
4946            "Refactor this module and explain the failure modes.",
4947            &registry,
4948            &tracker,
4949            &intent,
4950        );
4951        let reliability = |id: &str| {
4952            decision
4953                .candidates
4954                .iter()
4955                .find(|c| c.model_id == id)
4956                .unwrap_or_else(|| panic!("{id} not a candidate: {:?}", decision.candidates))
4957                .reliability
4958        };
4959        let (f, m) = (reliability(&frontier.id), reliability(&mini.id));
4960        assert!(
4961            (f - AdaptiveRouter::CURATED_FRONTIER_TAG_PRIOR).abs() < 1e-9,
4962            "unmeasured curated frontier row must take the tier prior, got {f}"
4963        );
4964        if local_routable {
4965            let l = reliability(&local.id);
4966            assert!(
4967                f > l,
4968                "frontier {f} must outrank the car-judged 4B local {l}"
4969            );
4970        } else {
4971            assert!(
4972                !decision.candidates.iter().any(|c| c.model_id == local.id),
4973                "{} cannot run in this build, so it must not be a candidate: {:?}",
4974                local.id,
4975                decision.candidates
4976            );
4977        }
4978        assert!(f > m, "frontier {f} must outrank the measured mini {m}");
4979        assert_eq!(
4980            decision.model_id, frontier.id,
4981            "prefer_quality code must pick the frontier row: {:?}",
4982            decision.candidates
4983        );
4984    }
4985
4986    #[test]
4987    fn local_size_heuristic_stays_below_measured_frontier_floor() {
4988        // The cap invariant: every local size bracket sits at/below the bottom
4989        // of the measured-benchmark band, so no unmeasured local can out-rank a
4990        // benchmarked frontier model on the quality term.
4991        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
4992        for size_mb in [500u64, 1500, 2500, 4500, 18_000] {
4993            let q = router.schema_quality_estimate(&local_model("local", size_mb));
4994            assert!(
4995                q <= AdaptiveRouter::BENCH_TIER_FLOOR + 0.21, // 30B cap 0.60 = FLOOR+0.20
4996                "local {size_mb}MB quality {q} must stay near/below the measured floor"
4997            );
4998        }
4999    }
5000
5001    #[test]
5002    fn community_remote_quality_is_damped_below_curated() {
5003        // An auto-discovered (Community) benchmark-less remote is damped to
5004        // 0.48 — below the conservative 0.60 an unmeasured CURATED remote gets
5005        // — so it stays routable but can't out-rank vetted models until it has
5006        // real benchmarks or outcomes.
5007        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
5008        let reg = test_registry();
5009        let mut remote = reg
5010            .list()
5011            .into_iter()
5012            .find(|m| {
5013                m.is_remote()
5014                    && m.public_benchmarks.is_empty()
5015                    && !m.tags.iter().any(|t| t == "frontier" || t == "balanced")
5016            })
5017            .expect("a benchmark-less, tier-untagged remote in the fixture")
5018            .clone();
5019
5020        // As curated → conservative 0.60 (unmeasured, not assumed frontier).
5021        remote.trust_tier = crate::schema::TrustTier::Curated;
5022        let qc = router.schema_quality_estimate(&remote);
5023        assert!(
5024            (qc - 0.60).abs() < 1e-9,
5025            "curated unmeasured remote should be 0.60, got {qc}"
5026        );
5027
5028        // As Community (discovered) → damped further, strictly below curated.
5029        remote.trust_tier = crate::schema::TrustTier::Community;
5030        let q = router.schema_quality_estimate(&remote);
5031        assert!(
5032            (q - 0.48).abs() < 1e-9,
5033            "community remote should be damped to 0.48, got {q}"
5034        );
5035        assert!(
5036            q < qc,
5037            "community remote must rank below an unmeasured curated remote"
5038        );
5039
5040        // A discovered row inherits its template's tags, so a `frontier` tag
5041        // on a Community row is not a reviewed claim and earns no tier prior;
5042        // the same tag on a Curated row does.
5043        remote.tags.push("frontier".into());
5044        let q = router.schema_quality_estimate(&remote);
5045        assert!(
5046            (q - 0.48).abs() < 1e-9,
5047            "community row with an inherited frontier tag stays at 0.48, got {q}"
5048        );
5049        remote.trust_tier = crate::schema::TrustTier::Curated;
5050        let q = router.schema_quality_estimate(&remote);
5051        assert!(
5052            (q - AdaptiveRouter::CURATED_FRONTIER_TAG_PRIOR).abs() < 1e-9,
5053            "curated frontier-tagged row takes the tier prior, got {q}"
5054        );
5055    }
5056
5057    #[test]
5058    fn quality_workload_is_quality_dominant() {
5059        // The knob's contract: quality dominates, latency + cost near floor —
5060        // the inverse of Fastest, so the most capable model wins on score.
5061        let (q, lat, cost) = RoutingWorkload::Quality.weights();
5062        assert!(
5063            (q + lat + cost - 1.0).abs() < 1e-9,
5064            "weights must sum to 1.0"
5065        );
5066        assert!(q >= 0.8, "quality must dominate, got {q}");
5067        assert!(
5068            lat <= 0.1 && cost <= 0.15,
5069            "latency/cost near floor: {lat}/{cost}"
5070        );
5071        // Strictly more quality-weighted than every other workload.
5072        for w in [
5073            RoutingWorkload::Interactive,
5074            RoutingWorkload::Batch,
5075            RoutingWorkload::Background,
5076            RoutingWorkload::LocalPreferred,
5077            RoutingWorkload::Fastest,
5078        ] {
5079            assert!(
5080                q > w.weights().0,
5081                "Quality must out-weight {w:?} on quality"
5082            );
5083        }
5084    }
5085
5086    #[test]
5087    fn prefer_quality_scores_the_more_capable_model_higher() {
5088        // Deterministic at the scoring layer (no Thompson noise): under the
5089        // Quality workload a bigger/more-capable code model out-scores a small
5090        // cheap one, so prefer_quality picks it. The shakedown's failure mode
5091        // was the cheap Qwen3-1.7B winning on cost over the capable 4B.
5092        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
5093        let reg = test_registry();
5094        let tracker = OutcomeTracker::new();
5095        let small = reg
5096            .find_by_name("Qwen3-1.7B")
5097            .expect("1.7B in fixture")
5098            .clone();
5099        let big = reg.find_by_name("Qwen3-4B").expect("4B in fixture").clone();
5100        assert!(small.has_capability(ModelCapability::Code));
5101        assert!(big.has_capability(ModelCapability::Code));
5102
5103        let score = |m: &ModelSchema, w: RoutingWorkload| {
5104            router.score_model(m, InferenceTask::Code, &tracker, w, 0, 0, 0, 0)
5105        };
5106        // Under Quality, the capable model wins decisively.
5107        assert!(
5108            score(&big, RoutingWorkload::Quality) > score(&small, RoutingWorkload::Quality),
5109            "prefer_quality must rank the capable 4B above the cheap 1.7B"
5110        );
5111    }
5112
5113    /// A local MLX model (is_mlx() && is_local()) whose cold-start quality is
5114    /// driven by `benchmark` — for exercising the Apple-Silicon locality bonus.
5115    fn local_mlx_model(id: &str, benchmark: Option<f64>) -> ModelSchema {
5116        let mut m = outcome_model(id, benchmark);
5117        m.provider = "local".into();
5118        m.source = crate::schema::ModelSource::Mlx {
5119            hf_repo: "mlx-community/test".into(),
5120            hf_weight_file: None,
5121        };
5122        m
5123    }
5124
5125    #[test]
5126    fn quality_workload_suppresses_local_mlx_bonus_vs_capable_remote() {
5127        // Regression: on Apple Silicon a small local MLX model got +0.25
5128        // unconditionally (LOCAL_BONUS 0.15 + MLX_BONUS 0.10), letting it
5129        // out-*bonus* a more capable remote under prefer_quality — the router
5130        // then picked it and it OOM'd on a large prompt it couldn't fit.
5131        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
5132        let tracker = OutcomeTracker::new();
5133        let score = |m: &ModelSchema, w: RoutingWorkload| {
5134            router.score_model(m, InferenceTask::Code, &tracker, w, 0, 0, 0, 0)
5135        };
5136
5137        let remote_capable = outcome_model("remote/frontier", Some(0.85));
5138        let mlx_tiny = local_mlx_model("mlx/tiny", Some(0.55));
5139        // Same quality as the MLX model, but remote → no locality bonus.
5140        let remote_peer = outcome_model("remote/peer", Some(0.55));
5141        assert!(mlx_tiny.is_mlx() && mlx_tiny.is_local());
5142
5143        // A latency-tolerant workload keeps the bonus: the MLX model out-scores
5144        // an equal-quality remote purely on the locality/MLX bonus. This proves
5145        // the bonus is real and still active where it belongs.
5146        assert!(
5147            score(&mlx_tiny, RoutingWorkload::Background)
5148                > score(&remote_peer, RoutingWorkload::Background),
5149            "the local/MLX bonus should still apply on non-Quality workloads"
5150        );
5151
5152        // Under Quality the bonus is suppressed, so the more capable remote
5153        // wins — the tiny local MLX no longer gets a free +0.25 thumb.
5154        assert!(
5155            score(&remote_capable, RoutingWorkload::Quality)
5156                > score(&mlx_tiny, RoutingWorkload::Quality),
5157            "prefer_quality must suppress the locality bonus so the capable remote wins"
5158        );
5159    }
5160
5161    // --- outcome-first reliability band (Task 5) ---
5162
5163    /// A local (GGUF) model of the given size, no benchmarks — exercises the
5164    /// capped size-heuristic branch of `schema_quality_estimate`.
5165    fn local_model(id: &str, size_mb: u64) -> ModelSchema {
5166        let mut m = outcome_model(id, None);
5167        m.provider = "local".into();
5168        m.source = crate::schema::ModelSource::Local {
5169            hf_repo: "test/repo".into(),
5170            hf_filename: "model.gguf".into(),
5171            tokenizer_repo: "test/repo".into(),
5172        };
5173        m.cost.size_mb = Some(size_mb);
5174        m
5175    }
5176
5177    /// The MoE discount exists for hand-seeded rates. A row carrying a measured
5178    /// decode rate — marked by the `latency_p50_ms` that
5179    /// `scripts/bench-consolidate.py` only writes beside one — must be scored
5180    /// at what was measured, not at a tenth of it.
5181    ///
5182    /// Rates are chosen above `LATENCY_CEILING_MS`'s cutoff (~20 tok/s for the
5183    /// 200-token baseline) because that is where the bug is observable. Below
5184    /// it both scores clamp to 0, which is why the catalog's one currently
5185    /// measured MoE row (`mlx/qwen3-30b-a3b:4bit`, 7.8 tok/s) was mis-scored
5186    /// without anything downstream noticing.
5187    #[test]
5188    fn a_measured_moe_rate_is_not_discounted_again() {
5189        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
5190
5191        let mut measured = local_model("local/moe-measured", 17_000);
5192        measured.tags.push("moe".into());
5193        measured.performance.tokens_per_second = Some(35.0);
5194        measured.performance.latency_p50_ms = Some(1_200);
5195
5196        // Same rate, no measurement provenance: still a declared estimate.
5197        let mut declared = local_model("local/moe-declared", 17_000);
5198        declared.tags.push("moe".into());
5199        declared.performance.tokens_per_second = Some(35.0);
5200
5201        // The yardstick: a dense model at the same rate is never discounted.
5202        let mut dense = local_model("local/dense", 17_000);
5203        dense.performance.tokens_per_second = Some(35.0);
5204        dense.performance.latency_p50_ms = Some(1_200);
5205
5206        let measured_score = router.schema_latency_estimate(&measured);
5207        let declared_score = router.schema_latency_estimate(&declared);
5208        let dense_score = router.schema_latency_estimate(&dense);
5209
5210        assert_eq!(
5211            measured_score, dense_score,
5212            "a measured MoE rate must score as the rate it measured"
5213        );
5214        assert!(
5215            declared_score < measured_score,
5216            "a declared MoE rate still gets the discount: {declared_score} vs {measured_score}"
5217        );
5218    }
5219
5220    #[test]
5221    fn a_named_task_decides_the_capability_filter_over_the_keyword_guess() {
5222        use crate::intent::{IntentHint, TaskHint};
5223        let prompt = "Let me know if there's an error in the prefix list.";
5224        let guess = TaskComplexity::assess(prompt);
5225        assert_eq!(
5226            guess,
5227            TaskComplexity::Code,
5228            "the keyword guess reads this as code"
5229        );
5230        let chat = IntentHint {
5231            task: Some(TaskHint::Chat),
5232            ..Default::default()
5233        };
5234        let chat_task = task_hint_to_inference_task(TaskHint::Chat);
5235        assert_eq!(
5236            base_required_capabilities(guess, Some(&chat), chat_task),
5237            vec![inference_task_required_capability(chat_task)]
5238        );
5239        // car#52: an explicit code task on a simple-reading prompt still needs Code.
5240        let code = IntentHint {
5241            task: Some(TaskHint::Code),
5242            ..Default::default()
5243        };
5244        assert_eq!(
5245            base_required_capabilities(TaskComplexity::Simple, Some(&code), InferenceTask::Code),
5246            vec![ModelCapability::Code]
5247        );
5248        // No named task: the guess stands.
5249        assert_eq!(
5250            base_required_capabilities(guess, None, InferenceTask::Code),
5251            vec![ModelCapability::Code]
5252        );
5253    }
5254
5255    /// A managed vllm-mlx MoE row's rate comes from car-bench, and vllm-mlx
5256    /// reports no TTFT, so it never carries the p50 "measured" marker. It must
5257    /// still score at its rate: the discounts describe the in-process Candle
5258    /// and MLX backends' expert routing, which vllm-mlx does not use.
5259    #[test]
5260    fn a_vllm_mlx_moe_row_scores_at_its_benchmarked_rate() {
5261        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
5262        let catalog = crate::registry::builtin_catalog();
5263        let moe = catalog
5264            .iter()
5265            .find(|m| m.id == "vllm-mlx/qwen3.6-35b-a3b:4bit")
5266            .unwrap();
5267        let rate = moe.performance.tokens_per_second.unwrap();
5268        assert!(moe.tags.iter().any(|t| t == "moe"));
5269        assert!(moe.performance.latency_p50_ms.is_none());
5270
5271        let mut dense = local_model("local/dense", 17_000);
5272        dense.performance.tokens_per_second = Some(rate);
5273        assert_eq!(
5274            router.schema_latency_estimate(moe),
5275            router.schema_latency_estimate(&dense),
5276            "scored as if it decoded at {} tok/s",
5277            rate * AdaptiveRouter::MOE_TPS_MULTIPLIER
5278        );
5279    }
5280
5281    /// A `benchmark_priors.json` seed (four perfect raw cases) moves an
5282    /// unbenchmarked model's reliability, and is ignored for a model whose
5283    /// catalog row carries a calibrated benchmark.
5284    #[test]
5285    fn a_raw_benchmark_seed_never_overrides_a_catalog_benchmark() {
5286        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
5287        let measured = outcome_model("remote/measured", Some(0.70));
5288        let unmeasured = outcome_model("remote/unmeasured", None);
5289        let mut tracker = OutcomeTracker::new();
5290        let seeded = |id: &str| {
5291            let mut p = crate::outcome::ModelProfile::new(id.into());
5292            p.ema_quality = 1.0;
5293            p.prior_sample_size = 4;
5294            p
5295        };
5296        tracker.import_profiles(vec![seeded("remote/measured"), seeded("remote/unmeasured")]);
5297
5298        let task = InferenceTask::Generate;
5299        assert_eq!(
5300            router.reliability(&measured, task, &tracker),
5301            router.schema_quality_estimate(&measured)
5302        );
5303        assert!(
5304            router.reliability(&unmeasured, task, &tracker)
5305                > router.schema_quality_estimate(&unmeasured)
5306        );
5307    }
5308
5309    /// Remote and local latency are one scale: the time to a whole answer.
5310    /// A remote that answers at a local model's rate scores the same, and a
5311    /// remote with only a declared first-token p50 is not scored as if that
5312    /// were its whole answer.
5313    #[test]
5314    fn remote_and_local_latency_are_the_time_to_a_whole_answer() {
5315        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
5316        let mut local = local_model("local/dense", 17_000);
5317        local.performance.tokens_per_second = Some(100.0);
5318        let mut remote = outcome_model("remote/measured", Some(0.8));
5319        remote.performance.tokens_per_second = Some(100.0);
5320        assert_eq!(
5321            router.schema_latency_estimate(&remote),
5322            router.schema_latency_estimate(&local)
5323        );
5324
5325        // A 600 ms first-token p50 alone read as a 600 ms answer.
5326        let mut declared = outcome_model("remote/declared", Some(0.8));
5327        declared.performance.latency_p50_ms = Some(600);
5328        let answer_ms = 600.0 + 200.0 / AdaptiveRouter::UNMEASURED_REMOTE_TPS * 1000.0;
5329        assert!(
5330            (router.schema_latency_estimate(&declared) - router.latency_ms_to_score(answer_ms))
5331                .abs()
5332                < 1e-9
5333        );
5334
5335        // A local row's measured first-token time is paid too.
5336        let mut slow_start = local.clone();
5337        slow_start.performance.latency_p50_ms = Some(1_500);
5338        assert!(
5339            router.schema_latency_estimate(&slow_start) < router.schema_latency_estimate(&local)
5340        );
5341    }
5342
5343    /// A curated remote whose cold-start reliability equals `benchmark`
5344    /// (schema_quality_estimate averages public_benchmarks). Empty `benchmarks`
5345    /// → the conservative 0.60 unmeasured default.
5346    fn outcome_model(id: &str, benchmark: Option<f64>) -> ModelSchema {
5347        ModelSchema {
5348            id: id.into(),
5349            name: id.into(),
5350            provider: "openai".into(),
5351            family: "test".into(),
5352            version: "latest".into(),
5353            capabilities: vec![
5354                ModelCapability::Generate,
5355                ModelCapability::Code,
5356                ModelCapability::Reasoning,
5357            ],
5358            context_length: 128_000,
5359            max_output_tokens: None,
5360            param_count: "api".into(),
5361            quantization: None,
5362            performance: Default::default(),
5363            cost: Default::default(),
5364            source: crate::schema::ModelSource::RemoteApi {
5365                endpoint: "https://api.openai.com/v1".into(),
5366                api_key_env: "OPENAI_API_KEY".into(),
5367                api_key_envs: vec![],
5368                api_version: None,
5369                protocol: crate::schema::ApiProtocol::OpenAiCompat,
5370            },
5371            tags: vec![],
5372            supported_params: vec![],
5373            public_benchmarks: benchmark
5374                .map(|s| {
5375                    vec![crate::schema::BenchmarkScore {
5376                        name: "t".into(),
5377                        score: s,
5378                        harness: None,
5379                        source_url: None,
5380                        measured_at: None,
5381                        runs: None,
5382                        spread: None,
5383                    }]
5384                })
5385                .unwrap_or_default(),
5386            trust_tier: crate::schema::TrustTier::Curated,
5387            deprecated: false,
5388            available: true,
5389            weights_ready: true,
5390        }
5391    }
5392
5393    #[test]
5394    fn reliability_band_applies_to_quality_and_substantive_normal_lanes() {
5395        use RoutingWorkload as W;
5396        // Substantive tasks on normal lanes → band.
5397        assert!(AdaptiveRouter::applies_reliability_band(
5398            InferenceTask::Code,
5399            W::Interactive
5400        ));
5401        assert!(AdaptiveRouter::applies_reliability_band(
5402            InferenceTask::Reasoning,
5403            W::Batch
5404        ));
5405        // Quality lane → ALWAYS bands (so its argmax can't pick a low-quality
5406        // model on bonuses), even on a trivial task.
5407        assert!(AdaptiveRouter::applies_reliability_band(
5408            InferenceTask::Code,
5409            W::Quality
5410        ));
5411        assert!(AdaptiveRouter::applies_reliability_band(
5412            InferenceTask::Generate,
5413            W::Quality
5414        ));
5415        // Trivial task on a normal lane → no band (cheap is fine).
5416        assert!(!AdaptiveRouter::applies_reliability_band(
5417            InferenceTask::Generate,
5418            W::Interactive
5419        ));
5420        // Explicit cost/latency/local preferences → no band (subsumption).
5421        assert!(!AdaptiveRouter::applies_reliability_band(
5422            InferenceTask::Code,
5423            W::Background
5424        ));
5425        assert!(!AdaptiveRouter::applies_reliability_band(
5426            InferenceTask::Code,
5427            W::Fastest
5428        ));
5429        // LocalPreferred MUST skip the band — honor the explicit local request.
5430        assert!(!AdaptiveRouter::applies_reliability_band(
5431            InferenceTask::Code,
5432            W::LocalPreferred
5433        ));
5434    }
5435
5436    #[test]
5437    fn high_stakes_intent_forces_quality_over_everything() {
5438        use crate::intent::IntentHint;
5439        // high_stakes wins even when prefer_fast AND prefer_local are also set —
5440        // never economize on a consequential/irreversible operation.
5441        let hs = IntentHint {
5442            high_stakes: true,
5443            prefer_fast: true,
5444            prefer_local: true,
5445            ..Default::default()
5446        };
5447        assert_eq!(
5448            AdaptiveRouter::resolve_workload(Some(&hs), RoutingWorkload::Interactive),
5449            RoutingWorkload::Quality
5450        );
5451        // Without stakes, the existing precedence stands (prefer_fast wins).
5452        let f = IntentHint {
5453            prefer_fast: true,
5454            ..Default::default()
5455        };
5456        assert_eq!(
5457            AdaptiveRouter::resolve_workload(Some(&f), RoutingWorkload::Interactive),
5458            RoutingWorkload::Fastest
5459        );
5460        // No intent → the request's base workload is used unchanged.
5461        assert_eq!(
5462            AdaptiveRouter::resolve_workload(None, RoutingWorkload::Batch),
5463            RoutingWorkload::Batch
5464        );
5465    }
5466
5467    #[test]
5468    fn band_excludes_a_real_quality_gap_even_if_cheaper() {
5469        // neo property 1: a 0.07 reliability gap must NOT be flippable by cost.
5470        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
5471        let tracker = OutcomeTracker::new();
5472        let strong = outcome_model("strong", Some(0.90));
5473        let weak_cheap = outcome_model("weak-cheap", Some(0.83));
5474        let band =
5475            router.outcome_first_band(vec![strong, weak_cheap], InferenceTask::Code, &tracker);
5476        assert_eq!(
5477            band.len(),
5478            1,
5479            "0.07 gap (> band 0.02) must drop the weaker model"
5480        );
5481        assert_eq!(band[0].id, "strong");
5482    }
5483
5484    #[test]
5485    fn band_keeps_genuinely_near_equal_models() {
5486        // Within ε → both eligible; cost/latency/Thompson decide downstream.
5487        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
5488        let tracker = OutcomeTracker::new();
5489        let band = router.outcome_first_band(
5490            vec![
5491                outcome_model("a", Some(0.90)),
5492                outcome_model("a2", Some(0.89)),
5493            ],
5494            InferenceTask::Code,
5495            &tracker,
5496        );
5497        assert_eq!(
5498            band.len(),
5499            2,
5500            "0.01 gap (<= band 0.02) keeps both for tie-break"
5501        );
5502    }
5503
5504    #[test]
5505    fn band_cold_start_frontier_beats_cheap_unknown() {
5506        // neo property 2: a benchmarked frontier must beat an unmeasured remote
5507        // (conservative 0.60) — the band doesn't collapse to pure cost at cold start.
5508        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
5509        let tracker = OutcomeTracker::new();
5510        let band = router.outcome_first_band(
5511            vec![
5512                outcome_model("frontier", Some(0.85)),
5513                outcome_model("unknown", None),
5514            ],
5515            InferenceTask::Code,
5516            &tracker,
5517        );
5518        assert_eq!(band.len(), 1);
5519        assert_eq!(
5520            band[0].id, "frontier",
5521            "0.85 vs 0.60 unknown → unknown excluded"
5522        );
5523    }
5524
5525    #[test]
5526    fn quality_lane_bands_out_a_weak_local_but_local_preferred_keeps_it() {
5527        // The end-to-end #Q4 fix: on prefer_quality, a benchmarked frontier
5528        // remote must win — a weaker local must be EXCLUDED from the selection
5529        // band so it can't float up on local/MLX bonuses. The explicit
5530        // LocalPreferred lane, by contrast, must NOT band the local out.
5531        // test_registry has available local Qwen models (fake GGUF dirs) plus
5532        // an OpenAI remote; add a benchmarked frontier that tops the tier.
5533        let mut reg = test_registry();
5534        let mut frontier = outcome_model("frontier-remote", None);
5535        frontier.public_benchmarks = vec![crate::schema::BenchmarkScore {
5536            name: "car-judged".into(),
5537            score: 1.0, // → ~0.92 tier, the unique top
5538            harness: None,
5539            source_url: None,
5540            measured_at: None,
5541            runs: None,
5542            spread: None,
5543        }];
5544        reg.register_project_model(frontier);
5545
5546        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
5547        let tracker = OutcomeTracker::new();
5548        let prompt = "Implement a balanced binary search tree with deletion.";
5549
5550        // prefer_quality: the benchmarked frontier wins; the band excludes the
5551        // weaker locals so they can't float up on local/MLX bonuses.
5552        let q = router.route_context_aware(
5553            prompt,
5554            256,
5555            &reg,
5556            &tracker,
5557            false,
5558            false,
5559            RoutingWorkload::Quality,
5560        );
5561        // The catalog's own benchmarked frontier rows (gpt-5.6-terra/sol,
5562        // car-judged 1.0) compete with the synthetic one; any of them is right.
5563        let picked = reg.get(&q.model_id).expect("picked model is registered");
5564        assert!(
5565            !picked.is_local() && !picked.public_benchmarks.is_empty(),
5566            "prefer_quality must pick a benchmarked frontier over locals-on-bonuses, got {}",
5567            q.model_id
5568        );
5569        assert_eq!(
5570            q.strategy,
5571            RoutingStrategy::SchemaBased,
5572            "Quality lane must use deterministic argmax, not Thompson exploration"
5573        );
5574        assert!(
5575            q.candidates.iter().any(|c| !c.in_band),
5576            "Quality lane must apply a reliability band (some candidates excluded)"
5577        );
5578
5579        // LocalPreferred: NO band — every candidate stays eligible, so an
5580        // explicit local request isn't overridden by the frontier.
5581        let lp = router.route_context_aware(
5582            prompt,
5583            256,
5584            &reg,
5585            &tracker,
5586            false,
5587            false,
5588            RoutingWorkload::LocalPreferred,
5589        );
5590        // No band: every candidate that can answer stays eligible (the
5591        // catalog's Qwen3.5 0.8B/2B fall under MIN_ANSWER_QUALITY).
5592        assert!(
5593            lp.candidates
5594                .iter()
5595                .all(|c| c.in_band == (c.reliability >= AdaptiveRouter::MIN_ANSWER_QUALITY)),
5596            "LocalPreferred must NOT band — every answering candidate eligible"
5597        );
5598    }
5599
5600    /// A lane that skips the band still never picks a model that cannot
5601    /// answer while one that can is available: `prefer_fast` used to take the
5602    /// fastest row whatever its score.
5603    #[test]
5604    fn prefer_fast_never_picks_a_model_that_fails_most_tasks() {
5605        // The catalog's Qwen3.5 0.8B and 2B (car-judged 0.054 and 0.179) are
5606        // the fastest rows here.
5607        let reg = test_registry();
5608        let failing = ["vllm-mlx/qwen3.5-0.8b:4bit", "vllm-mlx/qwen3.5-2b:4bit"];
5609        let judged = |score: f64| {
5610            vec![crate::schema::BenchmarkScore {
5611                name: "car-judged".into(),
5612                score,
5613                harness: None,
5614                source_url: None,
5615                measured_at: None,
5616                runs: None,
5617                spread: None,
5618            }]
5619        };
5620        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
5621        let tracker = OutcomeTracker::new();
5622        for _ in 0..50 {
5623            let d = router.route_context_aware(
5624                "Say hello.",
5625                64,
5626                &reg,
5627                &tracker,
5628                false,
5629                false,
5630                RoutingWorkload::Fastest,
5631            );
5632            assert!(!failing.contains(&d.model_id.as_str()), "{}", d.model_id);
5633        }
5634
5635        // Failed calls are availability, not quality: a strong model that
5636        // failed three times with no success stays eligible.
5637        let mut failed = OutcomeTracker::new();
5638        let strong = "vllm-mlx/gpt-oss-20b:mxfp4";
5639        for _ in 0..3 {
5640            let trace = failed.record_start(strong, InferenceTask::Generate, "test");
5641            failed.record_failure(&trace, "connection reset");
5642        }
5643        let d = router.route_context_aware(
5644            "Say hello.",
5645            64,
5646            &reg,
5647            &failed,
5648            false,
5649            false,
5650            RoutingWorkload::Fastest,
5651        );
5652        let found = d.candidates.iter().find(|c| c.model_id == strong);
5653        // vllm-mlx rows are candidates only on Apple Silicon with MLX.
5654        if cfg!(all(
5655            target_os = "macos",
5656            target_arch = "aarch64",
5657            not(car_skip_mlx)
5658        )) {
5659            assert!(found.is_some(), "{strong} is not a candidate here");
5660        }
5661        if let Some(c) = found {
5662            assert!(c.reliability < AdaptiveRouter::MIN_ANSWER_QUALITY, "{c:?}");
5663            assert!(c.in_band, "a model with failed calls was gated out: {c:?}");
5664        }
5665
5666        // Alone, it is still chosen: the gate never empties the set.
5667        let mut alone = UnifiedRegistry::new_empty(tempfile::tempdir().unwrap().path().join("m"));
5668        let mut only = outcome_model("fast-but-failing", None);
5669        only.public_benchmarks = judged(0.054);
5670        alone.register_project_model(only);
5671        let d = router.route_context_aware(
5672            "Say hello.",
5673            64,
5674            &alone,
5675            &tracker,
5676            false,
5677            false,
5678            RoutingWorkload::Fastest,
5679        );
5680        assert_eq!(d.model_id, "fast-but-failing");
5681    }
5682
5683    /// Automatic routing never picks a deprecated row while a current one
5684    /// is available, and still picks it when nothing else is left.
5685    #[test]
5686    fn a_deprecated_row_is_chosen_only_when_nothing_else_is() {
5687        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
5688        let tracker = OutcomeTracker::new();
5689        let route = |reg: &UnifiedRegistry| {
5690            router.route_context_aware(
5691                "Say hello.",
5692                64,
5693                reg,
5694                &tracker,
5695                false,
5696                false,
5697                RoutingWorkload::Fastest,
5698            )
5699        };
5700        let mut reg = test_registry();
5701        let mut retired = outcome_model("retired-but-fast", None);
5702        retired.deprecated = true;
5703        retired.performance.tokens_per_second = Some(2_000.0);
5704        retired.performance.latency_p50_ms = Some(20);
5705        retired.public_benchmarks = vec![crate::schema::BenchmarkScore {
5706            name: "car-judged".into(),
5707            score: 1.0,
5708            harness: None,
5709            source_url: None,
5710            measured_at: None,
5711            runs: None,
5712            spread: None,
5713        }];
5714        reg.register_project_model(retired.clone());
5715        for _ in 0..50 {
5716            let d = route(&reg);
5717            assert_ne!(d.model_id, "retired-but-fast");
5718            assert!(!d
5719                .candidates
5720                .iter()
5721                .any(|c| c.model_id == "retired-but-fast"));
5722        }
5723
5724        let mut alone = UnifiedRegistry::new_empty(tempfile::tempdir().unwrap().path().join("m"));
5725        alone.register_project_model(retired);
5726        assert_eq!(route(&alone).model_id, "retired-but-fast");
5727    }
5728
5729    #[test]
5730    fn band_never_empties() {
5731        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
5732        let tracker = OutcomeTracker::new();
5733        // single candidate
5734        assert_eq!(
5735            router
5736                .outcome_first_band(
5737                    vec![outcome_model("only", Some(0.5))],
5738                    InferenceTask::Code,
5739                    &tracker
5740                )
5741                .len(),
5742            1
5743        );
5744        // the best always survives even when all are low
5745        let band = router.outcome_first_band(
5746            vec![
5747                outcome_model("x", Some(0.40)),
5748                outcome_model("y", Some(0.41)),
5749            ],
5750            InferenceTask::Code,
5751            &tracker,
5752        );
5753        assert!(!band.is_empty() && band.iter().any(|m| m.id == "y"));
5754    }
5755
5756    #[test]
5757    fn outcome_first_band_is_wired_into_selection_and_keeps_fallbacks() {
5758        // Integration (Linus): prove the band actually constrains end-to-end
5759        // selection AND that excluded models survive as fallbacks. A strong but
5760        // expensive model and a weaker but cheap one, both Reasoning-capable;
5761        // routing a substantive prompt must pick the strong one (band excludes
5762        // the weak one from selection despite its lower cost) yet keep the weak
5763        // one as a fallback (a worse model beats no model if the primary errors).
5764        let tmp = std::path::PathBuf::from("/tmp/car-test-outcome-first-route");
5765        unsafe {
5766            std::env::set_var("OPENAI_API_KEY", "test-openai-key");
5767        }
5768        let mut reg = UnifiedRegistry::new_empty(tmp);
5769        let mut strong = outcome_model("strong-expensive", Some(0.90));
5770        strong.cost = crate::schema::CostModel {
5771            input_per_mtok: Some(20.0),
5772            output_per_mtok: Some(20.0),
5773            ..Default::default()
5774        };
5775        let mut weak = outcome_model("weak-cheap", Some(0.80));
5776        weak.cost = crate::schema::CostModel {
5777            input_per_mtok: Some(0.1),
5778            output_per_mtok: Some(0.1),
5779            ..Default::default()
5780        };
5781        reg.register_project_model(strong);
5782        reg.register_project_model(weak);
5783
5784        let router = AdaptiveRouter::new(
5785            test_hw(),
5786            RoutingConfig {
5787                prior_strength: 100.0, // exploit Phase-2 scores (deterministic-ish)
5788                ..Default::default()
5789            },
5790        );
5791        let tracker = OutcomeTracker::new();
5792        // Reasoning-flavored prompt → substantive task → outcome-first posture.
5793        let decision = router.route(
5794            "Analyze the architecture trade-offs in depth",
5795            &reg,
5796            &tracker,
5797        );
5798
5799        assert_eq!(
5800            decision.model_id, "strong-expensive",
5801            "band must select the higher-reliability model even though it's pricier"
5802        );
5803        assert!(
5804            decision.fallbacks.contains(&"weak-cheap".to_string()),
5805            "the out-of-band weaker model must remain as a fallback, got {:?}",
5806            decision.fallbacks
5807        );
5808    }
5809
5810    #[test]
5811    fn outcome_first_fallbacks_rank_in_band_before_out_of_band() {
5812        // neo: the band must hold for the fallback chain too — on primary
5813        // failure, an in-band (high-reliability) model must be retried before an
5814        // out-of-band cheaper-but-worse one.
5815        let tmp = std::path::PathBuf::from("/tmp/car-test-outcome-first-fb-order");
5816        unsafe {
5817            std::env::set_var("OPENAI_API_KEY", "test-openai-key");
5818        }
5819        let mut reg = UnifiedRegistry::new_empty(tmp);
5820        reg.register_project_model(outcome_model("strong", Some(0.90)));
5821        let mut peer = outcome_model("peer-in-band", Some(0.89)); // within 0.02 of strong
5822        peer.cost = crate::schema::CostModel {
5823            input_per_mtok: Some(30.0),
5824            output_per_mtok: Some(30.0),
5825            ..Default::default()
5826        };
5827        reg.register_project_model(peer);
5828        let mut weak = outcome_model("weak-out-of-band", Some(0.70)); // 0.20 below → out
5829        weak.cost = crate::schema::CostModel {
5830            input_per_mtok: Some(0.01),
5831            output_per_mtok: Some(0.01),
5832            ..Default::default()
5833        };
5834        reg.register_project_model(weak);
5835
5836        let router = AdaptiveRouter::new(
5837            test_hw(),
5838            RoutingConfig {
5839                prior_strength: 100.0,
5840                ..Default::default()
5841            },
5842        );
5843        let tracker = OutcomeTracker::new();
5844        let decision = router.route(
5845            "Analyze the architecture trade-offs in depth",
5846            &reg,
5847            &tracker,
5848        );
5849
5850        // primary must be in-band
5851        assert!(
5852            decision.model_id == "strong" || decision.model_id == "peer-in-band",
5853            "primary must be from the band, got {}",
5854            decision.model_id
5855        );
5856        let pos = |id: &str| decision.fallbacks.iter().position(|x| x == id);
5857        let in_band_id = if decision.model_id == "strong" {
5858            "peer-in-band"
5859        } else {
5860            "strong"
5861        };
5862        let in_band_pos = pos(in_band_id).expect("in-band peer must be a fallback");
5863        let weak_pos = pos("weak-out-of-band").expect("weak must remain a fallback (availability)");
5864        assert!(
5865            in_band_pos < weak_pos,
5866            "in-band fallback ({in_band_id}@{in_band_pos}) must precede out-of-band weak (@{weak_pos})"
5867        );
5868    }
5869
5870    #[test]
5871    fn reliability_is_cost_free() {
5872        // The band key must ignore cost — two models with identical benchmarks
5873        // but different cost have identical reliability.
5874        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
5875        let tracker = OutcomeTracker::new();
5876        let mut cheap = outcome_model("cheap", Some(0.8));
5877        cheap.cost = crate::schema::CostModel {
5878            input_per_mtok: Some(0.1),
5879            output_per_mtok: Some(0.1),
5880            ..Default::default()
5881        };
5882        let mut pricey = outcome_model("pricey", Some(0.8));
5883        pricey.cost = crate::schema::CostModel {
5884            input_per_mtok: Some(50.0),
5885            output_per_mtok: Some(50.0),
5886            ..Default::default()
5887        };
5888        let rc = router.reliability(&cheap, InferenceTask::Code, &tracker);
5889        let rp = router.reliability(&pricey, InferenceTask::Code, &tracker);
5890        assert!(
5891            (rc - rp).abs() < 1e-9,
5892            "reliability must not depend on cost"
5893        );
5894    }
5895
5896    #[test]
5897    fn cold_start_with_no_sample_size_sits_at_schema_estimate() {
5898        // Anti-regression: a 0-call profile with no benchmark sample size must
5899        // resolve to the schema estimate exactly — never averaged down toward
5900        // neutral. This is what keeps a healthy unmeasured frontier model (or
5901        // a good 8B) at its honest schema quality instead of demoting it.
5902        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
5903        let unmeasured = outcome_model("unmeasured", None); // curated remote → 0.68
5904        let schema = router.schema_quality_estimate(&unmeasured);
5905
5906        let mut tracker = OutcomeTracker::new();
5907        let mut p = crate::outcome::ModelProfile::new("unmeasured".into());
5908        p.ema_quality = 0.5; // neutral default, prior_sample_size == 0
5909        tracker.import_profiles(vec![p]);
5910
5911        let r = router.reliability(&unmeasured, InferenceTask::Code, &tracker);
5912        assert!(
5913            (r - schema).abs() < 1e-9,
5914            "no sample size ⇒ reliability == schema estimate ({schema}), got {r}"
5915        );
5916    }
5917
5918    #[test]
5919    fn sparse_seeded_prior_barely_perturbs_schema_anchor() {
5920        // The shadowing bug: a 1-case seeded prior of 1.0 must not pull
5921        // reliability anywhere near 1.0 — it sits close to the schema anchor.
5922        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
5923        // Unbenchmarked: a seed is ignored where the catalog has a benchmark.
5924        let benchmarked = outcome_model("frontier", None); // schema 0.60
5925
5926        let mut tracker = OutcomeTracker::new();
5927        let mut p = crate::outcome::ModelProfile::new("frontier".into());
5928        p.ema_quality = 1.0;
5929        p.prior_sample_size = 1;
5930        p.task_stats.insert(
5931            InferenceTask::Code.to_string(),
5932            crate::outcome::TaskStats {
5933                ema_quality: 1.0,
5934                prior_sample_size: 1,
5935                ..Default::default()
5936            },
5937        );
5938        tracker.import_profiles(vec![p]);
5939
5940        let r = router.reliability(&benchmarked, InferenceTask::Code, &tracker);
5941        // schema 0.60, n=1, K=4 → 0.60 + (1/5)*(1.0-0.60) = 0.68
5942        assert!((r - 0.68).abs() < 0.01, "expected ~0.68, got {r}");
5943        assert!(
5944            r < 0.7,
5945            "a 1-case prior must not pull reliability near its raw 1.0, got {r}"
5946        );
5947    }
5948
5949    #[test]
5950    fn dense_seeded_prior_is_trusted_near_raw() {
5951        // A score backed by many cases pulls reliability close to its raw
5952        // value, away from the schema anchor.
5953        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
5954        // Unbenchmarked: a seed is ignored where the catalog has a benchmark.
5955        let m = outcome_model("dense", None); // schema 0.60
5956
5957        let mut tracker = OutcomeTracker::new();
5958        let mut p = crate::outcome::ModelProfile::new("dense".into());
5959        p.ema_quality = 0.95;
5960        p.prior_sample_size = 40;
5961        tracker.import_profiles(vec![p]);
5962
5963        let r = router.reliability(&m, InferenceTask::Code, &tracker);
5964        // schema 0.60, n=40, K=4 → 0.60 + (40/44)*(0.95-0.60) ≈ 0.92
5965        assert!(
5966            r > 0.88,
5967            "dense prior should be trusted near raw 0.95, got {r}"
5968        );
5969    }
5970
5971    #[test]
5972    fn warm_ungraded_successes_sit_at_schema_not_neutral_ema() {
5973        // #3: a frontier model with many *ungraded* successes (total_calls high,
5974        // but quality_observations == 0 so its EMA never left the 0.5 prior)
5975        // must route on its schema estimate, NOT collapse to a hollow 0.5 that
5976        // buries it under smaller models. This is the warm-path quality bug.
5977        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
5978        let frontier = outcome_model("frontier", Some(0.55)); // schema 0.55
5979        let schema = router.schema_quality_estimate(&frontier);
5980
5981        let mut tracker = OutcomeTracker::new();
5982        let mut p = crate::outcome::ModelProfile::new("frontier".into());
5983        p.total_calls = 9;
5984        p.success_count = 9; // all mechanical successes, none graded
5985        p.ema_quality = 0.5; // never moved off the neutral prior
5986        p.quality_observations = 0;
5987        tracker.import_profiles(vec![p]);
5988
5989        let r = router.reliability(&frontier, InferenceTask::Code, &tracker);
5990        assert!(
5991            (r - schema).abs() < 1e-9,
5992            "ungraded successes ⇒ reliability == schema ({schema}), not neutral EMA; got {r}"
5993        );
5994    }
5995
5996    #[test]
5997    fn ungraded_task_stats_with_zero_ema_still_resolve_to_schema() {
5998        // Load-bearing invariant: a task touched only by mechanical successes
5999        // has a TaskStats with the derived-Default ema_quality == 0.0, n == 0,
6000        // g == 0. reliability() reads that task unit, and `e == 0 ⇒ schema`
6001        // must multiply the 0.0 EMA out — otherwise routing would see a garbage
6002        // 0.0 quality. Pins the formula against a refactor that short-circuits
6003        // differently.
6004        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
6005        let m = outcome_model("mech-only", Some(0.55)); // schema 0.55
6006        let schema = router.schema_quality_estimate(&m);
6007
6008        let mut tracker = OutcomeTracker::new();
6009        let mut p = crate::outcome::ModelProfile::new("mech-only".into());
6010        p.total_calls = 7;
6011        p.success_count = 7;
6012        // Task stats exist but were only ever touched by mechanical successes:
6013        // default ema 0.0, no benchmark, no graded signal.
6014        p.task_stats.insert(
6015            InferenceTask::Code.to_string(),
6016            crate::outcome::TaskStats {
6017                calls: 7,
6018                successes: 7,
6019                ..Default::default()
6020            },
6021        );
6022        tracker.import_profiles(vec![p]);
6023
6024        let r = router.reliability(&m, InferenceTask::Code, &tracker);
6025        assert!(
6026            (r - schema).abs() < 1e-9,
6027            "ungraded task stats (ema 0.0, e=0) must resolve to schema {schema}, got {r}"
6028        );
6029    }
6030
6031    #[test]
6032    fn graded_outcomes_earn_trust_in_the_live_ema() {
6033        // The complement: once real graded signal accumulates, the live EMA is
6034        // trusted near-fully, away from the schema anchor.
6035        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
6036        let m = outcome_model("graded", Some(0.55)); // schema 0.55
6037
6038        let mut tracker = OutcomeTracker::new();
6039        let mut p = crate::outcome::ModelProfile::new("graded".into());
6040        p.total_calls = 30;
6041        p.ema_quality = 0.9;
6042        p.quality_observations = 30; // lots of graded signal
6043        tracker.import_profiles(vec![p]);
6044
6045        let r = router.reliability(&m, InferenceTask::Code, &tracker);
6046        // schema 0.55, e=30, K=4 → 0.55 + (30/34)*(0.9-0.55) ≈ 0.86
6047        assert!(
6048            r > 0.83,
6049            "dense graded signal should trust the live EMA, got {r}"
6050        );
6051    }
6052
6053    #[test]
6054    fn live_grades_outweigh_benchmark_cases_of_equal_count() {
6055        // Asymmetry: the same number of live graded observations must move
6056        // reliability MORE than benchmark cases — live signal is on-distribution
6057        // ground truth, benchmark cases are distribution-shifted. Both models
6058        // have a low EMA (0.2) and a high schema (0.7); the live-graded one must
6059        // be distrusted harder.
6060        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
6061        let benched = outcome_model("benched", Some(0.7));
6062        let graded = outcome_model("graded", Some(0.7));
6063
6064        let mut tracker = OutcomeTracker::new();
6065        let mut pb = crate::outcome::ModelProfile::new("benched".into());
6066        pb.ema_quality = 0.2;
6067        pb.prior_sample_size = 4; // 4 benchmark cases
6068        let mut pg = crate::outcome::ModelProfile::new("graded".into());
6069        pg.ema_quality = 0.2;
6070        pg.total_calls = 4;
6071        pg.quality_observations = 4; // 4 live grades
6072        tracker.import_profiles(vec![pb, pg]);
6073
6074        let r_bench = router.reliability(&benched, InferenceTask::Code, &tracker);
6075        let r_graded = router.reliability(&graded, InferenceTask::Code, &tracker);
6076        assert!(
6077            r_graded < r_bench,
6078            "4 live grades must distrust more than 4 benchmark cases: \
6079             graded={r_graded} bench={r_bench}"
6080        );
6081    }
6082
6083    #[test]
6084    fn graded_failures_count_as_evidence_and_pull_reliability_down() {
6085        // Failures are graded signal (they move the EMA toward 0), so they must
6086        // count toward quality evidence and drag reliability below the schema
6087        // prior — distrust is earned even for a model with a strong prior.
6088        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
6089        let m = outcome_model("flaky", Some(0.8)); // schema 0.8
6090        let schema = router.schema_quality_estimate(&m);
6091
6092        let mut tracker = OutcomeTracker::new();
6093        let mut p = crate::outcome::ModelProfile::new("flaky".into());
6094        p.total_calls = 12;
6095        p.fail_count = 12;
6096        p.ema_quality = 0.1; // EMA driven down by repeated failures
6097        p.quality_observations = 12;
6098        tracker.import_profiles(vec![p]);
6099
6100        let r = router.reliability(&m, InferenceTask::Code, &tracker);
6101        assert!(
6102            r < schema - 0.2,
6103            "12 graded failures must pull reliability well below schema {schema}, got {r}"
6104        );
6105    }
6106
6107    /// Failed calls leave answer quality alone and lower reliability as
6108    /// availability: a model that failed its last three calls, and has
6109    /// answered none, loses a prefer_quality pick to a slightly weaker peer
6110    /// that answers. That pick is deterministic, so nothing else would move it.
6111    #[test]
6112    fn calls_that_fail_lower_reliability_without_touching_quality() {
6113        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
6114        let failing = outcome_model("failing", Some(0.92));
6115        let answering = outcome_model("answering", Some(0.91));
6116        let mut tracker = OutcomeTracker::new();
6117        for _ in 0..3 {
6118            let trace = tracker.record_start("failing", InferenceTask::Code, "test");
6119            tracker.record_failure(&trace, "upstream returned an error");
6120        }
6121
6122        let quality = router.answer_quality(&failing, InferenceTask::Code, &tracker);
6123        assert!(
6124            (quality - router.schema_quality_estimate(&failing)).abs() < 1e-9,
6125            "failures moved answer quality to {quality}"
6126        );
6127        let failing_reliability = router.reliability(&failing, InferenceTask::Code, &tracker);
6128        assert!((failing_reliability - quality / 4.0).abs() < 1e-9);
6129        assert!(
6130            failing_reliability < router.reliability(&answering, InferenceTask::Code, &tracker),
6131            "a model that answers must outrank one that has failed every call"
6132        );
6133
6134        // Answering again earns it back.
6135        for _ in 0..30 {
6136            let trace = tracker.record_start("failing", InferenceTask::Code, "test");
6137            tracker.record_complete(&trace, 500, 100, 50);
6138        }
6139        let recovered = router.reliability(&failing, InferenceTask::Code, &tracker);
6140        assert!(recovered > 0.9 * quality, "{recovered} after 30 answers");
6141    }
6142
6143    #[test]
6144    fn external_grade_moves_reliability_of_a_model_with_no_history() {
6145        // car-releases#110: the grade exists only on a task entry the RPC
6146        // created, whose `ema_quality` is the `Default` 0.0. Blending toward
6147        // that would drag a 0.9 grade down instead of up.
6148        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
6149        let m = outcome_model("graded-outside", Some(0.55));
6150        let schema = router.schema_quality_estimate(&m);
6151        let mut tracker = OutcomeTracker::new();
6152        tracker
6153            .record_external_grade("graded-outside", InferenceTask::Code, 0.9, 8, "neo")
6154            .unwrap();
6155
6156        let r = router.reliability(&m, InferenceTask::Code, &tracker);
6157        let expected = schema + (8.0 / 12.0) * (0.9 - schema);
6158        assert!(
6159            (r - expected).abs() < 1e-9,
6160            "expected {expected} (schema {schema} shrunk toward 0.9 at 8/12), got {r}"
6161        );
6162        // Another task is untouched by the grade.
6163        let other = router.reliability(&m, InferenceTask::Generate, &tracker);
6164        assert!((other - schema).abs() < 1e-9);
6165    }
6166
6167    #[test]
6168    fn external_grade_survives_a_restart_that_applies_benchmark_priors() {
6169        // A graded, never-served model has total_calls == 0, so the startup
6170        // prior pass re-seeds its profile. That must not drop the grade —
6171        // neither on a task the prior names nor on one it does not.
6172        let dir = tempfile::tempdir().unwrap();
6173        let path = dir.path().join("outcome_profiles.json");
6174        let mut tracker = OutcomeTracker::new();
6175        tracker
6176            .record_external_grade("graded-outside", InferenceTask::Code, 0.9, 8, "neo")
6177            .unwrap();
6178        tracker
6179            .record_external_grade("graded-outside", InferenceTask::Reasoning, 0.1, 8, "neo")
6180            .unwrap();
6181        tracker.save_to_file(&path).unwrap();
6182
6183        let mut restarted = OutcomeTracker::new();
6184        restarted.load_from_file(&path).unwrap();
6185        let mut prior = crate::routing_ext::BenchmarkPrior {
6186            overall_score: 0.6,
6187            sample_size: 10,
6188            ..Default::default()
6189        };
6190        prior.task_scores.insert("code".into(), 0.6);
6191        prior.task_sample_size.insert("code".into(), 10);
6192        let priors = std::collections::HashMap::from([("graded-outside".to_string(), prior)]);
6193        crate::routing_ext::apply_benchmark_priors(&mut restarted, &priors);
6194
6195        let p = restarted.profile("graded-outside").unwrap();
6196        assert_eq!(p.prior_sample_size, 10, "the prior was applied");
6197        let code = p.task_stats(InferenceTask::Code).unwrap();
6198        assert_eq!(code.prior_sample_size, 10);
6199        assert_eq!(code.external_grades["neo"].quality, 0.9);
6200        let reasoning = p.task_stats(InferenceTask::Reasoning).unwrap();
6201        assert_eq!(reasoning.external_grades["neo"].quality, 0.1);
6202
6203        // And the grade still reaches reliability: the prior's 10 cases at
6204        // 0.6 pool with the grade's 8 at 0.9.
6205        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
6206        // Unbenchmarked: a seed is ignored where the catalog has a benchmark.
6207        let m = outcome_model("graded-outside", None);
6208        let schema = router.schema_quality_estimate(&m);
6209        let r = router.reliability(&m, InferenceTask::Code, &restarted);
6210        let observed = (10.0 * 0.6 + 8.0 * 0.9) / 18.0;
6211        let expected = schema + (18.0 / 22.0) * (observed - schema);
6212        assert!((r - expected).abs() < 1e-9, "expected {expected}, got {r}");
6213    }
6214
6215    #[test]
6216    fn external_grade_pools_with_served_evidence_instead_of_replacing_it() {
6217        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
6218        let m = outcome_model("served", Some(0.55));
6219        let schema = router.schema_quality_estimate(&m);
6220        let mut tracker = OutcomeTracker::new();
6221        let mut p = crate::outcome::ModelProfile::new("served".into());
6222        p.total_calls = 10;
6223        p.ema_quality = 0.2;
6224        p.quality_observations = 10; // served evidence on the overall profile only
6225        tracker.import_profiles(vec![p]);
6226        tracker
6227            .record_external_grade("served", InferenceTask::Code, 1.0, 4, "neo")
6228            .unwrap();
6229
6230        let r = router.reliability(&m, InferenceTask::Code, &tracker);
6231        // served e = 2*10 = 20 at 0.2; external 4 at 1.0 → mean 0.3333, e = 24
6232        let observed = (20.0 * 0.2 + 4.0 * 1.0) / 24.0;
6233        let expected = schema + (24.0 / 28.0) * (observed - schema);
6234        assert!(
6235            (r - expected).abs() < 1e-9,
6236            "expected pooled {expected}, got {r}"
6237        );
6238    }
6239
6240    #[test]
6241    fn external_grade_reaches_route_candidates_and_their_ranking() {
6242        let _environment = crate::openrouter::test_environment_scope();
6243        let tmp = std::path::PathBuf::from("/tmp/car-test-route-external-grade");
6244        unsafe {
6245            std::env::set_var("OPENAI_API_KEY", "test-openai-key");
6246        }
6247        let mut reg = UnifiedRegistry::new_with_state_root(tmp.clone(), tmp);
6248        reg.register_project_model(outcome_model("strong", Some(0.90)));
6249        reg.register_project_model(outcome_model("runner-up", Some(0.70)));
6250        let router = AdaptiveRouter::new(
6251            test_hw(),
6252            RoutingConfig {
6253                prior_strength: 100.0,
6254                ..Default::default()
6255            },
6256        );
6257        let prompt = "Analyze the architecture trade-offs in depth";
6258        let mut tracker = OutcomeTracker::new();
6259        let before = router.route(prompt, &reg, &tracker);
6260        assert!(before
6261            .candidates
6262            .iter()
6263            .all(|c| c.external_grades.is_empty()));
6264        let reliability = |d: &AdaptiveRoutingDecision, id: &str| {
6265            d.candidates
6266                .iter()
6267                .find(|c| c.model_id == id)
6268                .unwrap_or_else(|| panic!("{id} is a candidate: {d:?}"))
6269                .reliability
6270        };
6271        assert!(reliability(&before, "strong") > reliability(&before, "runner-up"));
6272
6273        // An outside head-to-head: `strong` lost every case.
6274        tracker
6275            .record_external_grade("strong", before.task, 0.0, 8, "neo")
6276            .unwrap();
6277        let after = router.route(prompt, &reg, &tracker);
6278        let strong = after
6279            .candidates
6280            .iter()
6281            .find(|c| c.model_id == "strong")
6282            .expect("strong is still a candidate");
6283        assert_eq!(
6284            strong.external_grades,
6285            vec![RouteExternalGrade {
6286                source: "neo".into(),
6287                quality: 0.0,
6288                observations: 8,
6289                evidence: 8,
6290            }]
6291        );
6292        assert!(strong.reliability < reliability(&before, "strong") - 0.3);
6293        assert!(
6294            reliability(&after, "strong") < reliability(&after, "runner-up"),
6295            "the outside grade reorders the two models"
6296        );
6297        assert_ne!(
6298            after.model_id, "strong",
6299            "a model that lost every case is not chosen"
6300        );
6301        let wire = serde_json::to_value(&after).unwrap();
6302        assert!(wire["candidates"]
6303            .as_array()
6304            .unwrap()
6305            .iter()
6306            .any(|c| c["external_grades"][0]["source"] == "neo"));
6307    }
6308
6309    #[test]
6310    fn route_exposes_ranked_advisory_candidates() {
6311        // Constructing a `UnifiedRegistry` ends in `refresh_availability()`,
6312        // which WRITES the process-global gateway observation. Full reasoning
6313        // on `TestRegistry` in this crate; family in
6314        // docs/solutions/process-global-state-in-tests.md.
6315        let _environment = crate::openrouter::test_environment_scope();
6316        // Task 7: the adaptive path must expose the tradeoff it resolved — every
6317        // scored model present, the winner flagged, in-band membership marked,
6318        // and reliability cost-free. This is the explainability surface a caller
6319        // (UI/operator) uses to see *why* a model was picked and what the
6320        // alternatives were.
6321        let tmp = std::path::PathBuf::from("/tmp/car-test-route-candidates");
6322        unsafe {
6323            std::env::set_var("OPENAI_API_KEY", "test-openai-key");
6324        }
6325        let mut reg = UnifiedRegistry::new_with_state_root(tmp.clone(), tmp);
6326        reg.register_project_model(outcome_model("strong", Some(0.90)));
6327        let mut weak = outcome_model("weak-cheap", Some(0.70)); // 0.20 below → out of band
6328        weak.cost = crate::schema::CostModel {
6329            input_per_mtok: Some(0.01),
6330            output_per_mtok: Some(0.01),
6331            ..Default::default()
6332        };
6333        reg.register_project_model(weak);
6334
6335        let router = AdaptiveRouter::new(
6336            test_hw(),
6337            // Pin priors so band membership is deterministic (exploit, no exploration).
6338            RoutingConfig {
6339                prior_strength: 100.0,
6340                ..Default::default()
6341            },
6342        );
6343        let tracker = OutcomeTracker::new();
6344        let decision = router.route(
6345            "Analyze the architecture trade-offs in depth",
6346            &reg,
6347            &tracker,
6348        );
6349
6350        // Both scored models are present as candidates.
6351        assert!(
6352            decision.candidates.iter().any(|c| c.model_id == "strong"),
6353            "strong must appear as a candidate, got {:?}",
6354            decision.candidates
6355        );
6356        assert!(
6357            decision
6358                .candidates
6359                .iter()
6360                .any(|c| c.model_id == "weak-cheap"),
6361            "weak-cheap must appear as a candidate even though out of band"
6362        );
6363
6364        // Exactly one candidate is the selected one, and it matches model_id.
6365        let selected: Vec<&RouteCandidate> =
6366            decision.candidates.iter().filter(|c| c.selected).collect();
6367        assert_eq!(selected.len(), 1, "exactly one candidate may be selected");
6368        assert_eq!(
6369            selected[0].model_id, decision.model_id,
6370            "selected flag must match model_id"
6371        );
6372
6373        // Outcome-first: the winner is in-band; the 0.20-weaker model is not.
6374        let strong = decision
6375            .candidates
6376            .iter()
6377            .find(|c| c.model_id == "strong")
6378            .unwrap();
6379        let weak_c = decision
6380            .candidates
6381            .iter()
6382            .find(|c| c.model_id == "weak-cheap")
6383            .unwrap();
6384        assert!(strong.in_band, "strong must be in the reliability band");
6385        assert!(
6386            !weak_c.in_band,
6387            "weak-cheap (0.20 below) must be out of band"
6388        );
6389        // Reliability is the cost-free band key: strong outranks weak on quality.
6390        assert!(
6391            strong.reliability > weak_c.reliability,
6392            "reliability must reflect quality, not the weak model's lower cost"
6393        );
6394    }
6395
6396    #[test]
6397    fn prefer_quality_selection_is_deterministic_no_exploration() {
6398        // The live shakedown caught this: prefer_quality must NOT Thompson-
6399        // sample (exploration picked the cheap Qwen3-1.7B over 4B even though
6400        // 4B scored higher). Under the Quality workload selection is argmax —
6401        // the same most-capable model every call.
6402        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
6403        let reg = test_registry();
6404        let tracker = OutcomeTracker::new();
6405        let intent = crate::intent::IntentHint {
6406            task: Some(crate::intent::TaskHint::Code),
6407            require: vec![ModelCapability::Code],
6408            prefer_quality: true,
6409            ..Default::default()
6410        };
6411        let first = router.route_with_intent("write a function", &reg, &tracker, &intent);
6412        for _ in 0..8 {
6413            let again = router.route_with_intent("write a function", &reg, &tracker, &intent);
6414            assert_eq!(
6415                again.model_id, first.model_id,
6416                "prefer_quality must be deterministic (no Thompson exploration)"
6417            );
6418        }
6419        // And it is genuinely a code-capable choice.
6420        let chosen = reg
6421            .find_by_name(&first.model_name)
6422            .or_else(|| reg.list().into_iter().find(|m| m.id == first.model_id));
6423        if let Some(m) = chosen {
6424            assert!(m.has_capability(ModelCapability::Code));
6425        }
6426    }
6427
6428    /// Minimal available remote model schema for capability-filter tests.
6429    fn remote_model(id: &str, name: &str, caps: Vec<ModelCapability>) -> ModelSchema {
6430        ModelSchema {
6431            id: id.into(),
6432            name: name.into(),
6433            provider: "openai".into(),
6434            family: name.into(),
6435            version: "latest".into(),
6436            capabilities: caps,
6437            context_length: 128_000,
6438            max_output_tokens: None,
6439            param_count: "api".into(),
6440            quantization: None,
6441            performance: Default::default(),
6442            cost: Default::default(),
6443            source: crate::schema::ModelSource::RemoteApi {
6444                endpoint: "https://api.openai.com/v1".into(),
6445                api_key_env: "OPENAI_API_KEY".into(),
6446                api_key_envs: vec![],
6447                api_version: None,
6448                protocol: crate::schema::ApiProtocol::OpenAiCompat,
6449            },
6450            tags: vec!["trusted-remote".into()],
6451            supported_params: vec![],
6452            public_benchmarks: vec![],
6453            trust_tier: crate::schema::TrustTier::Curated,
6454            deprecated: false,
6455            available: true,
6456            weights_ready: true,
6457        }
6458    }
6459
6460    #[test]
6461    fn code_intent_filters_generate_only_models_on_simple_prompt() {
6462        // Constructing a `UnifiedRegistry` ends in `refresh_availability()`,
6463        // which WRITES the process-global gateway observation. Full reasoning
6464        // on `TestRegistry` in this crate; family in
6465        // docs/solutions/process-global-state-in-tests.md.
6466        let _environment = crate::openrouter::test_environment_scope();
6467        // Regression: a caller that sets task=code + prefer_quality but NOT
6468        // `require` must still get a code-capable model even when the prompt
6469        // reads as "simple". Before the fix, required_caps came only from prompt
6470        // complexity ([Generate]), so a generate-only model stayed a candidate
6471        // and code capability was never required — the exact path neo's
6472        // CarAdapter uses, and the car#52 regression.
6473        unsafe {
6474            std::env::set_var("OPENAI_API_KEY", "test-openai-key");
6475        }
6476        let intent_filter_dir = std::path::PathBuf::from("/tmp/car-test-code-intent-filter");
6477        let mut reg =
6478            UnifiedRegistry::new_with_state_root(intent_filter_dir.clone(), intent_filter_dir);
6479        reg.register_project_model(remote_model(
6480            "openai/gen-only:latest",
6481            "gen-only",
6482            vec![ModelCapability::Generate],
6483        ));
6484        reg.register_project_model(remote_model(
6485            "openai/coder:latest",
6486            "coder",
6487            vec![
6488                ModelCapability::Generate,
6489                ModelCapability::Code,
6490                ModelCapability::Reasoning,
6491            ],
6492        ));
6493
6494        let router = AdaptiveRouter::new(
6495            test_hw(),
6496            RoutingConfig {
6497                prior_strength: 100.0,
6498                ..Default::default()
6499            },
6500        );
6501        let tracker = OutcomeTracker::new();
6502        let intent = crate::intent::IntentHint {
6503            task: Some(crate::intent::TaskHint::Code),
6504            prefer_quality: true,
6505            ..Default::default() // note: NO `require`
6506        };
6507
6508        // No code markers in the prompt -> TaskComplexity::Simple.
6509        let decision = router.route_with_intent(
6510            "Give me a short two-sentence overview.",
6511            &reg,
6512            &tracker,
6513            &intent,
6514        );
6515
6516        assert_eq!(
6517            decision.task,
6518            InferenceTask::Code,
6519            "explicit task=code must be honored"
6520        );
6521        // The fix's guarantee: the generate-only model is filtered OUT of the
6522        // candidate set by the code-capability requirement.
6523        assert!(
6524            !decision
6525                .candidates
6526                .iter()
6527                .any(|c| c.model_id == "openai/gen-only:latest"),
6528            "generate-only model must be filtered out for task=code; candidates: {:?}",
6529            decision
6530                .candidates
6531                .iter()
6532                .map(|c| &c.model_id)
6533                .collect::<Vec<_>>()
6534        );
6535        let schema = reg
6536            .find_by_name(&decision.model_name)
6537            .or_else(|| reg.list().into_iter().find(|m| m.id == decision.model_id))
6538            .expect("selected model exists");
6539        assert!(
6540            schema.has_capability(ModelCapability::Code),
6541            "task=code on a simple prompt must route to a code-capable model, got {}",
6542            decision.model_name,
6543        );
6544    }
6545
6546    #[test]
6547    fn intent_default_does_not_override_task_or_caps() {
6548        // Thompson sampling makes per-call model selection
6549        // non-deterministic, so we can't compare model_ids directly.
6550        // What we can assert deterministically: a default IntentHint
6551        // must not change the task selection or the capability
6552        // requirements — those are functions of the prompt only when
6553        // no hint is supplied.
6554        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
6555        let reg = test_registry();
6556        let tracker = OutcomeTracker::new();
6557
6558        let baseline = router.route("write a haiku", &reg, &tracker);
6559        let with_default = router.route_with_intent(
6560            "write a haiku",
6561            &reg,
6562            &tracker,
6563            &crate::intent::IntentHint::default(),
6564        );
6565
6566        assert_eq!(
6567            baseline.task, with_default.task,
6568            "default IntentHint must not change the prompt-derived task"
6569        );
6570        assert_eq!(
6571            baseline.complexity, with_default.complexity,
6572            "default IntentHint must not change the prompt-derived complexity"
6573        );
6574    }
6575
6576    /// Parslee-ai/car#638 — a caller on a deadline must not be handed a model
6577    /// it would have to download first.
6578    ///
6579    /// `available` is true for an MLX model as soon as an `hf_repo` is declared,
6580    /// because `ensure_local()` lazy-downloads on first use (#164). That is
6581    /// right for open-ended work and fatal under a bounded timeout: `car code`
6582    /// derives its outcome contract inside a 120s cap, and on a machine with no
6583    /// local weights the router picked a 4.8 GB model, spent the whole budget
6584    /// fetching it, and failed all three attempts — while cloud models that
6585    /// answer in ~2s sat unreached in the fallback list.
6586    #[test]
6587    fn require_ready_skips_models_that_are_not_on_disk() {
6588        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
6589        let reg = test_registry();
6590        let tracker = OutcomeTracker::new();
6591
6592        // Every model the fixture put on disk is ready; anything else is not.
6593        let ready: std::collections::HashSet<String> = reg
6594            .list()
6595            .into_iter()
6596            .filter(|m| m.weights_ready)
6597            .map(|m| m.id.clone())
6598            .collect();
6599        assert!(!ready.is_empty(), "fixture should have ready models");
6600
6601        let hint = crate::intent::IntentHint {
6602            require_ready: true,
6603            ..Default::default()
6604        };
6605        let decision = router.route_with_intent("write a python function", &reg, &tracker, &hint);
6606        assert!(
6607            ready.contains(&decision.model_id),
6608            "require_ready picked {} which is not on disk; ready set: {:?}",
6609            decision.model_id,
6610            ready
6611        );
6612    }
6613
6614    /// The constraint is soft: if nothing is ready, routing still returns a
6615    /// model rather than refusing. A slow answer beats no answer.
6616    #[test]
6617    fn require_ready_falls_back_when_nothing_is_ready() {
6618        // Constructing a `UnifiedRegistry` ends in `refresh_availability()`,
6619        // which WRITES the process-global gateway observation. Full reasoning
6620        // on `TestRegistry` in this crate; family in
6621        // docs/solutions/process-global-state-in-tests.md.
6622        let _environment = crate::openrouter::test_environment_scope();
6623        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
6624        let tracker = OutcomeTracker::new();
6625        // Registry over an empty dir: no local weights anywhere.
6626        let empty_dir = std::env::temp_dir().join("car-test-require-ready-empty");
6627        let _ = std::fs::create_dir_all(&empty_dir);
6628        let reg = UnifiedRegistry::new_with_state_root(empty_dir.clone(), empty_dir);
6629
6630        let hint = crate::intent::IntentHint {
6631            require_ready: true,
6632            ..Default::default()
6633        };
6634        let decision = router.route_with_intent("write a python function", &reg, &tracker, &hint);
6635        assert!(
6636            !decision.model_id.is_empty(),
6637            "soft constraint must still yield a model when nothing is ready"
6638        );
6639    }
6640
6641    #[test]
6642    fn exclude_models_picks_a_different_model() {
6643        // Adversarial-reviewer separation (car#358): given the model that
6644        // "just did the work", routing must pick a DIFFERENT one. Use
6645        // prefer_quality so the baseline pick is deterministic (argmax),
6646        // then exclude it and assert the winner changes.
6647        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
6648        let reg = test_registry();
6649        let tracker = OutcomeTracker::new();
6650        let base = crate::intent::IntentHint {
6651            prefer_quality: true,
6652            ..Default::default()
6653        };
6654        let author = router.route_with_intent("review this", &reg, &tracker, &base);
6655
6656        let with_exclude = crate::intent::IntentHint {
6657            prefer_quality: true,
6658            exclude_models: vec![author.model_id.clone()],
6659            ..Default::default()
6660        };
6661        let reviewer = router.route_with_intent("review this", &reg, &tracker, &with_exclude);
6662        assert_ne!(
6663            reviewer.model_id, author.model_id,
6664            "an excluded model must never be the routed pick"
6665        );
6666        // The excluded id must also be gone from the retry chain and the
6667        // advisory candidate ranking — not just the primary pick.
6668        assert!(
6669            !reviewer.fallbacks.contains(&author.model_id),
6670            "excluded model leaked into fallbacks"
6671        );
6672        assert!(
6673            !reviewer
6674                .candidates
6675                .iter()
6676                .any(|c| c.model_id == author.model_id),
6677            "excluded model leaked into candidates[]"
6678        );
6679    }
6680
6681    /// An `on_device_only` request is answered only from models on this
6682    /// machine, even from a registry that lists models elsewhere.
6683    #[test]
6684    fn on_device_only_picks_only_a_model_on_this_machine() {
6685        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
6686        let reg = test_registry();
6687        let tracker = OutcomeTracker::new();
6688        assert!(
6689            reg.list().iter().any(|model| !model.is_local()),
6690            "fixture lists a remote model"
6691        );
6692        let decision = router.route_with_intent(
6693            "write a function",
6694            &reg,
6695            &tracker,
6696            &crate::intent::IntentHint {
6697                on_device_only: true,
6698                ..Default::default()
6699            },
6700        );
6701        for id in std::iter::once(&decision.model_id).chain(decision.fallbacks.iter()) {
6702            if id.is_empty() {
6703                continue;
6704            }
6705            let schema = reg.get(id).or_else(|| reg.find_by_name(id));
6706            assert!(
6707                schema.is_some_and(|schema| schema.is_local()),
6708                "{id}: {decision:?}"
6709            );
6710        }
6711    }
6712
6713    /// With no model on this machine left, an `on_device_only` request gets
6714    /// no route, not the complexity default (which may be a model elsewhere,
6715    /// or none at all).
6716    #[test]
6717    fn on_device_only_with_nothing_on_this_machine_is_no_route() {
6718        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
6719        let mut reg = test_registry();
6720        let local: Vec<String> = reg
6721            .registry
6722            .all()
6723            .filter(|model| model.is_local())
6724            .map(|model| model.id.clone())
6725            .collect();
6726        for id in local {
6727            reg.registry.unregister(&id);
6728        }
6729        let tracker = OutcomeTracker::new();
6730        let decision = router.route_with_intent(
6731            "write a function",
6732            &reg,
6733            &tracker,
6734            &crate::intent::IntentHint {
6735                on_device_only: true,
6736                ..Default::default()
6737            },
6738        );
6739        assert!(decision.model_id.is_empty(), "{decision:?}");
6740        assert!(decision.fallbacks.is_empty(), "{decision:?}");
6741        assert!(decision.reason.contains("on_device_only"), "{decision:?}");
6742        // The same registry without the constraint still routes elsewhere.
6743        let ordinary = router.route_with_intent(
6744            "write a function",
6745            &reg,
6746            &tracker,
6747            &crate::intent::IntentHint::default(),
6748        );
6749        assert!(!ordinary.model_id.is_empty(), "{ordinary:?}");
6750    }
6751
6752    #[test]
6753    fn strict_exclusions_refuse_when_every_candidate_is_a_panel_seat() {
6754        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
6755        let reg = test_registry();
6756        let tracker = OutcomeTracker::new();
6757        let panel: Vec<String> = reg
6758            .list()
6759            .into_iter()
6760            .map(|model| model.name.clone())
6761            .collect();
6762        assert!(!panel.is_empty(), "fixture must contain candidate models");
6763
6764        let decision = router.route_with_intent(
6765            "repair this code",
6766            &reg,
6767            &tracker,
6768            &crate::intent::IntentHint {
6769                task: Some(crate::intent::TaskHint::Code),
6770                exclude_models: panel,
6771                strict_exclusions: true,
6772                ..Default::default()
6773            },
6774        );
6775
6776        assert!(decision.model_id.is_empty(), "{decision:?}");
6777        assert!(decision.model_name.is_empty(), "{decision:?}");
6778        assert!(decision.fallbacks.is_empty(), "{decision:?}");
6779        assert!(
6780            decision.reason.contains("no eligible model"),
6781            "the refusal must tell the operator what is missing: {decision:?}"
6782        );
6783    }
6784
6785    #[test]
6786    fn exclude_models_accepts_the_name_a_result_reports() {
6787        // `InferenceResult::model_used` carries `ModelSchema.name`, and that is
6788        // the ONLY identifier a caller holding a result has. The filter compares
6789        // `ModelSchema.id`, and the two differ for most models — so excluding by
6790        // the name used to match nothing at all and silently route straight back
6791        // to the model that just failed. That no-op is what left coder contract
6792        // derivation re-asking the same JSON-unreliable fallback three times
6793        // (car#889).
6794        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
6795        let reg = test_registry();
6796        let tracker = OutcomeTracker::new();
6797        let base = crate::intent::IntentHint {
6798            prefer_quality: true,
6799            ..Default::default()
6800        };
6801        let author = router.route_with_intent("write a python function", &reg, &tracker, &base);
6802        let author_name = reg
6803            .get(&author.model_id)
6804            .expect("the routed pick is in the registry")
6805            .name
6806            .clone();
6807        assert_ne!(
6808            author_name, author.model_id,
6809            "this test is only meaningful on a model whose name and id differ"
6810        );
6811
6812        let by_name = crate::intent::IntentHint {
6813            prefer_quality: true,
6814            exclude_models: vec![author_name.clone()],
6815            ..Default::default()
6816        };
6817        let rerouted =
6818            router.route_with_intent("write a python function", &reg, &tracker, &by_name);
6819        assert_ne!(
6820            rerouted.model_id, author.model_id,
6821            "excluding by the name a result reports ({author_name}) must route elsewhere"
6822        );
6823        assert!(
6824            !rerouted.fallbacks.contains(&author.model_id),
6825            "name-excluded model leaked into fallbacks"
6826        );
6827    }
6828
6829    #[test]
6830    fn cold_start_honors_exclusion() {
6831        // The cold-start fallback (hard filter yielded nothing) must also
6832        // avoid the excluded model when an alternative exists (car#358 review
6833        // follow-up). Drive cold_start_decision directly: pick its baseline,
6834        // then exclude that and assert it picks something else.
6835        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
6836        let reg = test_registry();
6837        let caps = [ModelCapability::Generate];
6838        let base = router.cold_start_decision(
6839            TaskComplexity::Simple,
6840            InferenceTask::Generate,
6841            &caps,
6842            &reg,
6843            false,
6844            &std::collections::HashSet::new(),
6845            false,
6846            AvailabilitySnapshot::capture(&reg),
6847        );
6848        let mut exclude = std::collections::HashSet::new();
6849        exclude.insert(base.model_id.clone());
6850        let excluded = router.cold_start_decision(
6851            TaskComplexity::Simple,
6852            InferenceTask::Generate,
6853            &caps,
6854            &reg,
6855            false,
6856            &exclude,
6857            false,
6858            AvailabilitySnapshot::capture(&reg),
6859        );
6860        // Either a different model, or the hardcoded last-resort floor (the
6861        // soft tolerance) — but the registry-driven tiers must not re-pick the
6862        // excluded id while another registry model qualifies.
6863        let other_generate_exists = reg
6864            .list()
6865            .into_iter()
6866            .filter(|m| m.available && m.has_capability(ModelCapability::Generate))
6867            .any(|m| m.id != base.model_id);
6868        if other_generate_exists {
6869            assert_ne!(
6870                excluded.model_id, base.model_id,
6871                "cold start re-picked the excluded model despite an alternative"
6872            );
6873        }
6874    }
6875
6876    #[test]
6877    fn exclude_all_models_falls_back_to_full_set() {
6878        // Soft exclusion: if excluding leaves no candidate, the exclusion is
6879        // dropped rather than failing — a same-model review beats no review.
6880        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
6881        let reg = test_registry();
6882        let tracker = OutcomeTracker::new();
6883        let exclude_everything: Vec<String> =
6884            reg.list().into_iter().map(|m| m.id.clone()).collect();
6885        let hint = crate::intent::IntentHint {
6886            exclude_models: exclude_everything,
6887            ..Default::default()
6888        };
6889        let decision = router.route_with_intent("hi", &reg, &tracker, &hint);
6890        assert!(
6891            !decision.model_id.is_empty(),
6892            "excluding everything must still yield a routed model (fallback)"
6893        );
6894    }
6895
6896    #[test]
6897    fn intent_task_hint_overrides_prompt_complexity() {
6898        // A short prompt that complexity assessment would route as
6899        // Generate should land on Reasoning when the intent says so.
6900        let router = AdaptiveRouter::new(test_hw(), RoutingConfig::default());
6901        let reg = test_registry();
6902        let tracker = OutcomeTracker::new();
6903
6904        let hint = crate::intent::IntentHint {
6905            task: Some(crate::intent::TaskHint::Reasoning),
6906            ..Default::default()
6907        };
6908        let decision = router.route_with_intent("hi", &reg, &tracker, &hint);
6909
6910        assert_eq!(
6911            decision.task,
6912            InferenceTask::Reasoning,
6913            "TaskHint::Reasoning should override the prompt-derived task"
6914        );
6915    }
6916
6917    /// #333 residual: on Apple Silicon a local GGUF model with an MLX
6918    /// equivalent is EXECUTED as the MLX model and books its outcomes against
6919    /// the MLX id. The router must therefore propose / breaker-gate / learn the
6920    /// MLX id — not the pre-redirect GGUF id whose profile stays forever empty.
6921    /// A proven MLX model is proposed by its MLX id, and the pre-redirect GGUF
6922    /// twin id — which would read an empty profile and never actually run — is
6923    /// never proposed (decision or fallback chain). Run against the real
6924    /// builtin catalog, which has the qwen3 GGUF/MLX twins.
6925    #[cfg(all(target_os = "macos", target_arch = "aarch64", not(car_skip_mlx)))]
6926    #[test]
6927    fn router_proposes_mlx_twin_not_pre_redirect_gguf() {
6928        let gguf_id = "qwen/qwen3-8b:q4_k_m";
6929        let mlx_id = "mlx/qwen3-8b:4bit";
6930
6931        let reg = test_registry();
6932        // Precondition: the GGUF resolves 1:1 to its same-size MLX twin (not,
6933        // say, the 4B — the param_count fix in resolve_mlx_equivalent).
6934        let gguf = reg.get(gguf_id).expect("gguf in builtin catalog").clone();
6935        assert_eq!(
6936            reg.resolve_mlx_equivalent(&gguf).map(|m| m.id.as_str()),
6937            Some(mlx_id),
6938            "GGUF must resolve to its SAME-SIZE MLX twin"
6939        );
6940
6941        let router = AdaptiveRouter::new(
6942            test_hw(),
6943            RoutingConfig {
6944                min_observations: 3,
6945                ..Default::default()
6946            },
6947        );
6948        let mut tracker = OutcomeTracker::new();
6949        // Prove the MLX twin on code tasks.
6950        for _ in 0..20 {
6951            let t = tracker.record_start(mlx_id, InferenceTask::Code, "test");
6952            tracker.record_complete(&t, 500, 100, 50);
6953            tracker.record_inferred_outcome(&t, InferredOutcome::Accepted { confidence: 0.95 });
6954        }
6955
6956        let mut mlx_wins = 0;
6957        for _ in 0..20 {
6958            let d = router.route("Fix this bug in the parser", &reg, &tracker);
6959            // The pre-redirect GGUF id must never be proposed or queued (#333):
6960            // it was substituted to the MLX twin before scoring.
6961            assert_ne!(
6962                d.model_id, gguf_id,
6963                "router proposed the pre-redirect GGUF id"
6964            );
6965            assert!(
6966                !d.fallbacks.iter().any(|f| f == gguf_id),
6967                "pre-redirect GGUF id leaked into the fallback chain"
6968            );
6969            if d.model_id == mlx_id {
6970                mlx_wins += 1;
6971            }
6972        }
6973        assert!(
6974            mlx_wins >= 12,
6975            "proven MLX twin should win the majority (the router reads the id it \
6976             learns/runs); won only {mlx_wins}/20"
6977        );
6978    }
6979
6980    // ---------------------------------------------------------------------
6981    // Routing scenarios: the shipped catalog on a stated M5 Pro 64 GB, with a
6982    // chosen set of cloud keys, routed with request shapes CAR actually sends.
6983    // These pin which model a request gets, not how the score is computed.
6984    // ---------------------------------------------------------------------
6985
6986    fn m5_pro_64gb() -> HardwareInfo {
6987        HardwareInfo {
6988            os: "macos".into(),
6989            arch: "aarch64".into(),
6990            cpu_cores: 14,
6991            total_ram_mb: 64 * 1024,
6992            gpu_backend: crate::hardware::GpuBackend::Metal,
6993            gpu_memory_mb: None,
6994            gpu_devices: Vec::new(),
6995            recommended_model: "fixture".into(),
6996            recommended_context: 8192,
6997            max_model_mb: 64 * 1024,
6998        }
6999    }
7000
7001    const CLOUD_KEYS: [&str; 3] = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY"];
7002
7003    /// The shipped catalog with exactly `keys` set, and nothing from the
7004    /// developer's keychain or environment.
7005    fn scenario_registry(keys: &[&str]) -> TestRegistry {
7006        let environment = crate::openrouter::test_environment_scope();
7007        let mut names = CLOUD_KEYS.to_vec();
7008        names.extend([
7009            "CAR_SECRETS_FILE_DIR",
7010            "OPENROUTER_API_KEY",
7011            "GOOGLE_API_KEY",
7012            "CAR_CODEX_BIN",
7013        ]);
7014        let restore = RestoredEnvironment::capture(&names);
7015        let root = std::env::temp_dir().join(format!(
7016            "car-routing-scenarios-{}",
7017            keys.join("-").to_ascii_lowercase()
7018        ));
7019        unsafe {
7020            for name in &names {
7021                std::env::remove_var(name);
7022            }
7023            std::env::set_var("CAR_SECRETS_FILE_DIR", root.join("empty-secrets"));
7024            // The ChatGPT-subscription row is available wherever the Codex CLI
7025            // is installed and signed in; a scenario names its keys instead.
7026            std::env::set_var("CAR_CODEX_BIN", root.join("no-codex"));
7027            for key in keys {
7028                std::env::set_var(key, "scenario-key");
7029            }
7030        }
7031        let _ = std::fs::create_dir_all(&root);
7032        let registry =
7033            UnifiedRegistry::new_with_state_root(root.join("models"), root.join("state"));
7034        TestRegistry {
7035            registry,
7036            _restore: restore,
7037            _environment: environment,
7038        }
7039    }
7040
7041    struct Scenario {
7042        name: &'static str,
7043        prompt: &'static str,
7044        workload: RoutingWorkload,
7045        intent: crate::intent::IntentHint,
7046        has_tools: bool,
7047    }
7048
7049    fn scenarios() -> Vec<Scenario> {
7050        use crate::intent::{IntentHint, TaskHint};
7051        vec![
7052            Scenario {
7053                name: "chat, interactive",
7054                prompt: "What's a good name for a golden retriever?",
7055                workload: RoutingWorkload::Interactive,
7056                intent: IntentHint::default(),
7057                has_tools: false,
7058            },
7059            Scenario {
7060                name: "code, interactive",
7061                prompt: "Write a Python function that merges two sorted lists.",
7062                workload: RoutingWorkload::Interactive,
7063                intent: IntentHint::default(),
7064                has_tools: false,
7065            },
7066            Scenario {
7067                name: "agent turn with tools, interactive",
7068                prompt: "Read notes.txt and summarize it.",
7069                workload: RoutingWorkload::Interactive,
7070                intent: IntentHint::default(),
7071                has_tools: true,
7072            },
7073            Scenario {
7074                name: "code, prefer_local",
7075                prompt: "Write a Python function that merges two sorted lists.",
7076                workload: RoutingWorkload::Interactive,
7077                intent: IntentHint {
7078                    prefer_local: true,
7079                    ..Default::default()
7080                },
7081                has_tools: false,
7082            },
7083            Scenario {
7084                name: "agent turn with tools, prefer_local",
7085                prompt: "Read notes.txt and summarize it.",
7086                workload: RoutingWorkload::Interactive,
7087                intent: IntentHint {
7088                    prefer_local: true,
7089                    ..Default::default()
7090                },
7091                has_tools: true,
7092            },
7093            Scenario {
7094                name: "voice reply, prefer_fast",
7095                prompt: "What time is it in Tokyo?",
7096                workload: RoutingWorkload::Interactive,
7097                intent: IntentHint {
7098                    prefer_fast: true,
7099                    task: Some(TaskHint::Chat),
7100                    ..Default::default()
7101                },
7102                has_tools: false,
7103            },
7104            Scenario {
7105                name: "hard reasoning, prefer_quality",
7106                prompt: "Prove that the halting problem is undecidable, step by step.",
7107                workload: RoutingWorkload::Interactive,
7108                intent: IntentHint {
7109                    prefer_quality: true,
7110                    task: Some(TaskHint::Reasoning),
7111                    ..Default::default()
7112                },
7113                has_tools: false,
7114            },
7115            Scenario {
7116                name: "summary, background",
7117                prompt: "Summarize this meeting transcript in three bullets.",
7118                workload: RoutingWorkload::Background,
7119                intent: IntentHint::default(),
7120                has_tools: false,
7121            },
7122        ]
7123    }
7124
7125    fn route_scenario(
7126        router: &AdaptiveRouter,
7127        registry: &UnifiedRegistry,
7128        tracker: &OutcomeTracker,
7129        scenario: &Scenario,
7130    ) -> AdaptiveRoutingDecision {
7131        router.route_with(RouteRequest {
7132            intent: Some(&scenario.intent),
7133            has_tools: scenario.has_tools,
7134            workload: scenario.workload,
7135            ..RouteRequest::new(scenario.prompt, registry, tracker)
7136        })
7137    }
7138
7139    const SCENARIO_KEY_SETS: [&[&str]; 4] = [
7140        &[],
7141        &["OPENAI_API_KEY"],
7142        &["ANTHROPIC_API_KEY"],
7143        &["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY"],
7144    ];
7145
7146    /// A request that says `prefer_local` or `prefer_fast` keeps its local
7147    /// candidates whatever cloud keys are set. Before, any trusted remote
7148    /// removed every local candidate from both. Both are biases, not
7149    /// `on_device_only`, so where the shipped locals are frontier-grade (the
7150    /// MLX lineup on Apple Silicon) the pick is local every time; elsewhere
7151    /// (Candle Qwen3-4B/8B) a much stronger remote can still win the score.
7152    #[test]
7153    fn a_stated_local_or_fast_preference_keeps_local_candidates_under_every_key_set() {
7154        let frontier_grade_locals = cfg!(all(
7155            target_os = "macos",
7156            target_arch = "aarch64",
7157            not(car_skip_mlx)
7158        ));
7159        let router = AdaptiveRouter::with_default_config(m5_pro_64gb());
7160        for keys in SCENARIO_KEY_SETS {
7161            let registry = scenario_registry(keys);
7162            let tracker = OutcomeTracker::new();
7163            for scenario in scenarios()
7164                .iter()
7165                .filter(|s| s.intent.prefer_local || s.intent.prefer_fast)
7166            {
7167                // Sampling is stochastic; the property must hold every time.
7168                for _ in 0..10 {
7169                    let decision = route_scenario(&router, &registry, &tracker, scenario);
7170                    let is_local = |id: &String| registry.get(id).is_some_and(|m| m.is_local());
7171                    let picked_local = is_local(&decision.model_id);
7172                    assert!(
7173                        picked_local || decision.fallbacks.iter().any(is_local),
7174                        "{} with keys {keys:?} had no local candidate: {} ({})",
7175                        scenario.name,
7176                        decision.model_id,
7177                        decision.reason
7178                    );
7179                    assert!(
7180                        picked_local || !frontier_grade_locals,
7181                        "{} with keys {keys:?} routed to {} ({})",
7182                        scenario.name,
7183                        decision.model_id,
7184                        decision.reason
7185                    );
7186                }
7187            }
7188        }
7189    }
7190
7191    /// A turn that carries tools goes to a model the catalog says can call
7192    /// them, under every key set and every draw, over the shipped catalog.
7193    /// This guards the router's capability filter, not the catalog's honesty:
7194    /// a false `tool_use` claim (gpt-oss on vllm-mlx, removed in #3755) passes
7195    /// it, which is why that claim was measured live before it was dropped.
7196    #[test]
7197    fn a_tool_turn_always_goes_to_a_model_that_can_call_tools() {
7198        let router = AdaptiveRouter::with_default_config(m5_pro_64gb());
7199        for keys in SCENARIO_KEY_SETS {
7200            let registry = scenario_registry(keys);
7201            let tracker = OutcomeTracker::new();
7202            for scenario in scenarios().iter().filter(|s| s.has_tools) {
7203                for _ in 0..10 {
7204                    let decision = route_scenario(&router, &registry, &tracker, scenario);
7205                    let model = registry.get(&decision.model_id).expect("a routed model");
7206                    assert!(
7207                        model.has_capability(ModelCapability::ToolUse),
7208                        "{} with keys {keys:?} routed to {}, which cannot call tools",
7209                        scenario.name,
7210                        decision.model_id
7211                    );
7212                }
7213            }
7214        }
7215    }
7216
7217    /// With no cloud key, the quality lane's pick is not worse-measured on
7218    /// CAR's own suite than any local model it ranked behind it: the
7219    /// benchmark scores are what quality routing is supposed to follow.
7220    #[test]
7221    fn the_quality_lane_follows_the_measured_scores_with_no_keys() {
7222        let car_judged = |m: &ModelSchema| {
7223            m.public_benchmarks
7224                .iter()
7225                .find(|b| b.name == "car-judged")
7226                .map(|b| b.score)
7227        };
7228        let router = AdaptiveRouter::with_default_config(m5_pro_64gb());
7229        let registry = scenario_registry(&[]);
7230        let tracker = OutcomeTracker::new();
7231        let mut compared = 0;
7232        for scenario in scenarios().iter().filter(|s| s.intent.prefer_quality) {
7233            let decision = route_scenario(&router, &registry, &tracker, scenario);
7234            let picked = registry.get(&decision.model_id).expect("a routed model");
7235            let picked_score = car_judged(picked).unwrap_or(0.0);
7236            for id in &decision.fallbacks {
7237                let Some(alt) = registry.get(id).filter(|m| m.is_local()) else {
7238                    continue;
7239                };
7240                if let Some(score) = car_judged(alt) {
7241                    compared += 1;
7242                    assert!(
7243                        picked_score + 1e-9 >= score,
7244                        "{}: picked {} (car-judged {picked_score:.3}) over {} ({score:.3})",
7245                        scenario.name,
7246                        decision.model_id,
7247                        id
7248                    );
7249                }
7250            }
7251        }
7252        assert!(compared > 0, "no measured local alternative was compared");
7253    }
7254
7255    /// Prints the pick for every scenario under each key set. Run with
7256    /// `--ignored --nocapture` to see what the router does today.
7257    #[test]
7258    #[ignore = "report: prints routing picks, asserts nothing"]
7259    fn routing_scenario_report() {
7260        let router = AdaptiveRouter::with_default_config(m5_pro_64gb());
7261        for keys in SCENARIO_KEY_SETS {
7262            let registry = scenario_registry(keys);
7263            let tracker = OutcomeTracker::new();
7264            println!("== keys: {keys:?}");
7265            for scenario in scenarios() {
7266                let decision = route_scenario(&router, &registry, &tracker, &scenario);
7267                let local = registry
7268                    .get(&decision.model_id)
7269                    .is_some_and(|m| m.is_local());
7270                println!(
7271                    "  {:<38} -> {:<40} {} | {}",
7272                    scenario.name,
7273                    decision.model_id,
7274                    if local { "local " } else { "remote" },
7275                    decision.reason.chars().take(110).collect::<String>()
7276                );
7277            }
7278        }
7279    }
7280}