Skip to main content

car_inference/
intent.rs

1//! Caller-facing routing intent — express requirements, not model IDs.
2//!
3//! Tracks Parslee-ai/car-releases#18. The motivation is that callers
4//! today choose between two extremes:
5//!
6//! - `model = None` → the adaptive router picks. Quality on average is
7//!   good but per-request variability surfaces as UX inconsistency.
8//! - `model = Some("claude-sonnet-4-7")` → the caller pins. Provider
9//!   awareness leaks up the stack — exactly what CAR is supposed to
10//!   prevent.
11//!
12//! `IntentHint` is the middle ground. The caller expresses *what* they
13//! need; the router resolves intent → model. Existing `model = None`
14//! and `model = Some(...)` paths are unchanged when no intent is
15//! supplied.
16//!
17//! ## MVP scope
18//!
19//! Just `task`, `prefer_local`, `require`. Cost/latency ceilings wait
20//! for clean registry numbers; `prefer_family` was cut as a soft
21//! routing knob that accumulates tweaks without clear semantics
22//! (Linus design review, 2026-05-04).
23//!
24//! ## Routing semantics
25//!
26//! `prefer_local: true` maps to a dedicated
27//! [`crate::RoutingWorkload::LocalPreferred`] variant. Distinct from
28//! `Background` (which is "this is a background job, latency barely
29//! matters") — `LocalPreferred` keeps a quality-aware weight profile
30//! and a strong local_bonus so the hint wins ties decisively.
31
32use serde::{Deserialize, Serialize};
33
34use crate::schema::ModelCapability;
35
36/// What the caller is doing — coarse-grained categories the adaptive
37/// router maps to `InferenceTask`. A closed enum so adding a new task
38/// type is a deliberate FFI-visible change rather than a silent
39/// fallback when the router doesn't recognize a string.
40///
41/// The MVP intentionally ships only the variants that map to a
42/// distinct `InferenceTask` today. `Summarize` / `Extract` were cut
43/// because both would have collapsed to `Generate` with no observable
44/// behavior change — shipping enum variants that are accepted, parsed,
45/// and silently discarded is exactly the routing variability the
46/// intent surface is designed to remove. Add them back when the
47/// registry actually distinguishes summarize-tuned or extract-tuned
48/// models.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum TaskHint {
52    /// Conversational chat — maps to `InferenceTask::Generate`.
53    Chat,
54    /// Label assignment / categorization. Maps to
55    /// `InferenceTask::Classify`.
56    Classify,
57    /// Chain-of-thought, planning, multi-step analysis. Maps to
58    /// `InferenceTask::Reasoning` and tends to favor frontier
59    /// reasoning models.
60    Reasoning,
61    /// Code generation, repair, refactoring. Maps to
62    /// `InferenceTask::Code`.
63    Code,
64}
65
66/// Caller-supplied routing intent. All fields are optional / additive.
67/// An `IntentHint` with default values matches the no-intent path
68/// exactly, so threading `Option<IntentHint>` through is safe.
69#[derive(Debug, Clone, Default, Serialize, Deserialize)]
70pub struct IntentHint {
71    /// What the caller is doing. None = let the router infer from the
72    /// prompt as today.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub task: Option<TaskHint>,
75
76    /// Hard filter — every required capability must be present on the
77    /// candidate. Empty = no extra filter.
78    #[serde(default, skip_serializing_if = "Vec::is_empty")]
79    pub require: Vec<ModelCapability>,
80
81    /// Hard exclusion — model ids the router must NOT pick on the unpinned
82    /// adaptive arm. The motivating case is adversarial-reviewer
83    /// separation (car#358): "any capable model that is *not* the one that
84    /// just did the work", so a model never grades its own output. Each entry
85    /// is matched against `ModelSchema.id` *or* `ModelSchema.name` — the two
86    /// differ for most models, and `name` is what
87    /// [`InferenceResult::model_used`](crate::InferenceResult::model_used)
88    /// reports, so a caller who has only just seen a result can name the model
89    /// it holds and have it actually excluded (car#889).
90    /// Empty = no exclusion. Soft by default — if excluding leaves no
91    /// candidate, the exclusion is dropped rather than failing the request (a
92    /// same-model review beats no review). Set [`Self::strict_exclusions`] when
93    /// using an excluded model would invalidate the operation itself.
94    #[serde(default, skip_serializing_if = "Vec::is_empty")]
95    pub exclude_models: Vec<String>,
96
97    /// Refuse adaptive routing when every otherwise-eligible model appears in
98    /// [`Self::exclude_models`]. This is for separation boundaries such as an
99    /// unattended self-heal coder that must not share a model with its review
100    /// panel. It has no effect when `exclude_models` is empty or the caller pins
101    /// an explicit model outside adaptive routing.
102    #[serde(default, skip_serializing_if = "is_false")]
103    pub strict_exclusions: bool,
104
105    /// Route only to models CAR runs on this machine ([`ModelSchema::is_local`]
106    /// — MLX, Candle GGUF, a CAR-managed vLLM-MLX, Apple's on-device model; an
107    /// Ollama or external vLLM-MLX endpoint does not count, since it may be on
108    /// another machine). Hard, and checked against the registry the request
109    /// is routed with when it is routed: every other model is removed before
110    /// any routing path (adaptive, a preferred-model override, cold start,
111    /// fallbacks) sees it, a requested model that is not on this machine is
112    /// refused, and with none left the request fails with
113    /// [`InferenceError::NoEligibleModel`] before anything is sent. For a
114    /// caller whose data must not leave the machine, such as an agent whose
115    /// person asked to keep everything on it.
116    ///
117    /// [`ModelSchema::is_local`]: crate::schema::ModelSchema::is_local
118    /// [`InferenceError::NoEligibleModel`]: crate::InferenceError::NoEligibleModel
119    #[serde(default, skip_serializing_if = "is_false")]
120    pub on_device_only: bool,
121
122    /// Bias the score profile toward local models (cost over quality).
123    /// Internally this maps to `RoutingWorkload::Background` until the
124    /// follow-up split lands (parslee-ai/car#106).
125    #[serde(default, skip_serializing_if = "is_false")]
126    pub prefer_local: bool,
127
128    /// Bias the score profile aggressively toward latency. Maps to
129    /// [`crate::tasks::RoutingWorkload::Fastest`] — a weight profile
130    /// that downweights quality and cost in favour of time-to-first-token.
131    /// Designed for voice turns where a sub-500ms first-audio target
132    /// beats a richer-but-slower answer. Takes precedence over
133    /// `prefer_local`; if both are set, the request is routed by
134    /// `Fastest` rules.
135    #[serde(default, skip_serializing_if = "is_false")]
136    pub prefer_fast: bool,
137
138    /// Bias the score profile toward the most capable model — quality
139    /// dominates, latency and cost are near-floor (maps to
140    /// [`crate::tasks::RoutingWorkload::Quality`]). For quality-critical,
141    /// infrequent work where a weak model fails (building/verifying an agent,
142    /// deriving a contract, structured extraction): the strongest candidate
143    /// wins, not the cheapest. Precedence: `prefer_fast` wins outright
144    /// (latency is the most extreme need), then `prefer_quality`, then
145    /// `prefer_local`.
146    #[serde(default, skip_serializing_if = "is_false")]
147    pub prefer_quality: bool,
148
149    /// The operation is high-stakes — consequential or irreversible (e.g. the
150    /// session is authorized for `FullAccess`/externally-consequential actions).
151    /// Forces the strongest quality posture
152    /// ([`crate::tasks::RoutingWorkload::Quality`]) regardless of task,
153    /// complexity, or any cost/latency preference: never economize on what you
154    /// can't take back. Highest precedence — wins over `prefer_fast`,
155    /// `prefer_quality`, and `prefer_local`. Distinct from `prefer_quality`
156    /// because it has different *precedence* (it outranks `prefer_fast`, which
157    /// `prefer_quality` does not) — a stakes-driven quality need must beat even
158    /// the latency lane. Callers set it from the action/session permission tier.
159    #[serde(default, skip_serializing_if = "is_false")]
160    pub high_stakes: bool,
161
162    /// Only consider models usable **right now, without a download**.
163    ///
164    /// For a local MLX model, `available` is true as soon as an `hf_repo` is
165    /// declared, because `ensure_local()` lazy-downloads on first use (#164).
166    /// That is right for open-ended work and wrong for work on a deadline: a
167    /// step with a bounded budget that selects a model it must first fetch
168    /// spends the entire budget downloading and then fails.
169    ///
170    /// `car code`'s contract derivation is the motivating case
171    /// (Parslee-ai/car#638). It runs under a 120s cap, routed with
172    /// `require: [Code], prefer_quality: true`, and on a machine with no local
173    /// weights that selected a 4.8 GB model it had to download first — timing
174    /// out three times while cloud models capable of answering in ~2s sat in
175    /// the fallback list, unreached. Set this whenever a bounded timeout is
176    /// enforced around the call.
177    ///
178    /// Soft by necessity, like [`Self::exclude_models`]: if requiring readiness
179    /// leaves no candidate, the constraint is dropped rather than failing the
180    /// request — a slow answer beats no answer.
181    #[serde(default, skip_serializing_if = "is_false")]
182    pub require_ready: bool,
183
184    /// Never start a model download for this request. Hard, where
185    /// [`Self::require_ready`] is soft: every model CAR would have to fetch
186    /// files for first (its weights, a missing GGUF tokenizer, or a declared
187    /// speculative-decoding drafter) is removed before any routing path
188    /// (adaptive, a preferred-model override, cold start, fallbacks) sees it,
189    /// a requested model that is not installed is refused, and with nothing
190    /// usable left the request fails with
191    /// [`InferenceError::NoEligibleModel`] before anything is sent, naming
192    /// the model it would have used and the `car models pull` command that
193    /// installs it. On the inference generate and stream paths, the model,
194    /// drafter and tokenizer acquisitions the request reaches also refuse to
195    /// fetch, the self-heal re-pull included, and a child process CAR starts
196    /// for it with the Hugging Face environment (the mlx-vlm CLI, the vllm-mlx
197    /// server) runs with `HF_HUB_OFFLINE=1`. Nothing is installed either: a
198    /// CAR-managed vLLM-MLX model whose vllm-mlx runtime is missing or too old counts as
199    /// not installed, and CAR's runtime install refuses. For work nobody asked to
200    /// download for, such as checking an agent while it is built
201    /// (Parslee-ai/car#3718).
202    ///
203    /// [`InferenceError::NoEligibleModel`]: crate::InferenceError::NoEligibleModel
204    #[serde(default, skip_serializing_if = "is_false")]
205    pub never_download: bool,
206}
207
208impl IntentHint {
209    /// `Some(IntentHint { high_stakes: true, .. })` when `b`, else `None`.
210    ///
211    /// The single source of truth for "mark this generation high-stakes" so the
212    /// in-process autonomous loops (active-planner, reason, agents) don't each
213    /// hand-roll the struct literal — keeping the `intent: None` fast path and
214    /// the high-stakes path identical across call sites. `None` is a true no-op
215    /// (a default `IntentHint` doesn't trigger the Quality posture either), so a
216    /// benign loop routes exactly as it did before stakes-awareness existed.
217    pub fn high_stakes_if(b: bool) -> Option<IntentHint> {
218        b.then(|| IntentHint {
219            high_stakes: true,
220            ..Default::default()
221        })
222    }
223}
224
225fn is_false(b: &bool) -> bool {
226    !*b
227}
228
229// ---------------------------------------------------------------------------
230// Acquisition intent — which model to *recommend/install* for this machine.
231//
232// Distinct layer from `TaskHint`/`IntentHint` above, which are *inference-time*
233// routing intent (which already-installed model should serve this request).
234// The types below answer the earlier question: "given this hardware and what
235// the user wants to do, which model should they acquire?" They are consumed by
236// `ModelRecommender` (see docs/solutions/first-class-model-ux.md) and never
237// expose model IDs, quantization, or HF repos to the caller.
238// ---------------------------------------------------------------------------
239
240/// The kind of model a use case needs. The recommender ranks only *within*
241/// a role's lane — an embedding model and a chat model are not comparable,
242/// so a retrieval pick never competes with a generative one. A use case that
243/// spans roles resolves to a bundle (one recommendation per role).
244#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
245#[serde(rename_all = "snake_case")]
246pub enum UseCaseRole {
247    /// Produces text/tokens (chat, code, vision-to-text, summarize).
248    Generative,
249    /// Produces vectors / relevance scores (embeddings, rerank).
250    Retrieval,
251    /// Consumes audio (transcription).
252    Audio,
253}
254
255/// What the user wants to do, in their terms — not a model ID. Closed enum:
256/// adding a use case is a deliberate FFI-visible change, never a silent
257/// string fallback. Each variant maps to a [`UseCaseRole`] and a required /
258/// preferred [`ModelCapability`] set (see [`UseCase::required_capabilities`]).
259#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
260#[serde(rename_all = "snake_case")]
261#[derive(Default)]
262pub enum UseCase {
263    /// General chat / Q&A. The default.
264    #[default]
265    Assistant,
266    /// Code generation, repair, refactoring.
267    Coding,
268    /// Text condensation.
269    Summarize,
270    /// Image understanding (a generative model that also sees).
271    Vision,
272    /// Audio → text.
273    Transcription,
274    /// Semantic search — an embedding model for retrieval. NOT an LLM
275    /// performing web search with tools; this is the `Retrieval` role and
276    /// maps to the `Embed` capability, so it is ranked separately from any
277    /// generative chat model.
278    Search,
279}
280
281impl UseCase {
282    /// The role lane this use case is ranked within.
283    pub fn role(self) -> UseCaseRole {
284        match self {
285            UseCase::Assistant | UseCase::Coding | UseCase::Summarize | UseCase::Vision => {
286                UseCaseRole::Generative
287            }
288            UseCase::Search => UseCaseRole::Retrieval,
289            UseCase::Transcription => UseCaseRole::Audio,
290        }
291    }
292
293    /// Hard eligibility filter — a model missing any of these is excluded.
294    pub fn required_capabilities(self) -> &'static [ModelCapability] {
295        use ModelCapability::*;
296        match self {
297            UseCase::Assistant => &[Generate],
298            UseCase::Coding => &[Generate, Code],
299            UseCase::Summarize => &[Generate],
300            UseCase::Vision => &[Vision, Generate],
301            UseCase::Transcription => &[SpeechToText],
302            UseCase::Search => &[Embed],
303        }
304    }
305
306    /// Soft preference — present capabilities add a ranking bonus but are
307    /// never required for eligibility.
308    pub fn preferred_capabilities(self) -> &'static [ModelCapability] {
309        use ModelCapability::*;
310        match self {
311            UseCase::Assistant => &[ToolUse],
312            UseCase::Coding => &[ToolUse, Reasoning],
313            UseCase::Summarize => &[Summarize],
314            UseCase::Vision => &[],
315            UseCase::Transcription => &[],
316            UseCase::Search => &[Rerank],
317        }
318    }
319}
320
321/// Speed/quality knob. Each tier is a fixed weighting over the recommender's
322/// soft-score axes, applied *after* the hard eligibility filter, so tier
323/// semantics are explicit rather than reinvented per call site.
324#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
325#[serde(rename_all = "snake_case")]
326#[derive(Default)]
327pub enum QualityTier {
328    /// Smallest eligible model; lowest latency.
329    Fastest,
330    /// Best quality that fits with KV-cache headroom. The default.
331    #[default]
332    Balanced,
333    /// Largest model that fits at all; accepts slower output.
334    MostCapable,
335}
336
337/// Relative weights a [`QualityTier`] places on each soft-score axis. The
338/// recommender normalizes each axis to `[0,1]` and combines them with these.
339#[derive(Debug, Clone, Copy, PartialEq)]
340pub struct TierWeights {
341    /// Toward higher-quality models (benchmarks / param-count prior).
342    pub quality: f32,
343    /// Toward lower-latency models (smaller, better-accelerated).
344    pub latency: f32,
345    /// Toward leaving memory headroom (smaller fraction of budget used).
346    pub memory_pressure: f32,
347}
348
349impl QualityTier {
350    /// The fixed axis weighting for this tier. Mirrors the table in
351    /// docs/solutions/first-class-model-ux.md.
352    pub fn weights(self) -> TierWeights {
353        match self {
354            QualityTier::Fastest => TierWeights {
355                quality: 0.2,
356                latency: 0.6,
357                memory_pressure: 0.2,
358            },
359            QualityTier::Balanced => TierWeights {
360                quality: 0.5,
361                latency: 0.2,
362                memory_pressure: 0.3,
363            },
364            // Memory is a *fit* question on this tier, and fit is already a hard
365            // constraint: `FitStatus` and `within_recommendation_target` exclude
366            // anything the machine cannot hold. Leaving 0.2 on the soft penalty
367            // on top of that made "most capable" contradict its own name — on a
368            // 64 GB Mac, Qwen3-8B (judged 0.770, 4.7 GB) beat
369            // Qwen3.6-35B-A3B (judged 0.830, 19.9 GB) by 0.007, entirely because
370            // the larger model used more of the memory the user has and asked to
371            // use. A measured quality difference should not lose to that here.
372            // Some pressure term remains, so among models of equal measured
373            // quality the leaner one still wins.
374            QualityTier::MostCapable => TierWeights {
375                quality: 0.9,
376                latency: 0.0,
377                memory_pressure: 0.1,
378            },
379        }
380    }
381}
382
383/// Where the user is willing to run inference. Orthogonal to [`QualityTier`].
384/// Choosing the cloud is never silent — `CloudOk` only makes remote models
385/// *eligible*; the recommender still flags them as requiring one-time consent.
386#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
387#[serde(rename_all = "snake_case")]
388#[derive(Default)]
389pub enum Privacy {
390    /// Local models only.
391    #[default]
392    OnDevice,
393    /// Remote APIs / the Parslee gateway may compete and win.
394    CloudOk,
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    #[test]
402    fn empty_intent_serializes_compactly() {
403        // No-intent must round-trip through serde without verbose
404        // null fields — the FFI layer transmits as JSON and clients
405        // shouldn't see {"task":null,"require":[],"prefer_local":false}.
406        let hint = IntentHint::default();
407        let json = serde_json::to_string(&hint).unwrap();
408        assert_eq!(json, "{}");
409    }
410
411    #[test]
412    fn round_trip_with_capability_require() {
413        let hint = IntentHint {
414            task: Some(TaskHint::Code),
415            require: vec![ModelCapability::Code, ModelCapability::ToolUse],
416            prefer_local: true,
417            ..Default::default()
418        };
419        let json = serde_json::to_string(&hint).unwrap();
420        let back: IntentHint = serde_json::from_str(&json).unwrap();
421        assert_eq!(back.task, Some(TaskHint::Code));
422        assert_eq!(
423            back.require,
424            vec![ModelCapability::Code, ModelCapability::ToolUse]
425        );
426        assert!(back.prefer_local);
427        assert!(!back.prefer_fast);
428    }
429
430    #[test]
431    fn exclude_models_round_trips_and_skips_when_empty() {
432        // Empty exclusion must keep the compact `{}` wire shape (car#358).
433        let off = IntentHint::default();
434        assert_eq!(serde_json::to_string(&off).unwrap(), "{}");
435
436        let on = IntentHint {
437            exclude_models: vec!["author-model-id".to_string()],
438            ..Default::default()
439        };
440        let json = serde_json::to_string(&on).unwrap();
441        assert!(json.contains("exclude_models"));
442        let back: IntentHint = serde_json::from_str(&json).unwrap();
443        assert_eq!(back.exclude_models, vec!["author-model-id".to_string()]);
444    }
445
446    #[test]
447    fn missing_fields_default_cleanly() {
448        // Pre-MVP clients that don't know about IntentHint may send
449        // partial JSON. Defaults must match the no-intent path.
450        let hint: IntentHint = serde_json::from_str("{}").unwrap();
451        assert_eq!(hint.task, None);
452        assert!(hint.require.is_empty());
453        assert!(!hint.prefer_local);
454        assert!(!hint.prefer_fast);
455    }
456
457    #[test]
458    fn prefer_fast_round_trips_and_skips_when_false() {
459        let off = IntentHint::default();
460        assert_eq!(serde_json::to_string(&off).unwrap(), "{}");
461
462        let on = IntentHint {
463            prefer_fast: true,
464            ..IntentHint::default()
465        };
466        let json = serde_json::to_string(&on).unwrap();
467        assert!(json.contains("prefer_fast"));
468        let back: IntentHint = serde_json::from_str(&json).unwrap();
469        assert!(back.prefer_fast);
470    }
471
472    // --- acquisition intent (UseCase / QualityTier / Privacy) ---
473
474    #[test]
475    fn use_case_defaults_to_assistant_and_balanced_on_device() {
476        assert_eq!(UseCase::default(), UseCase::Assistant);
477        assert_eq!(QualityTier::default(), QualityTier::Balanced);
478        assert_eq!(Privacy::default(), Privacy::OnDevice);
479    }
480
481    #[test]
482    fn coding_requires_both_generate_and_code() {
483        // Regression guard for the design-review point that a coding
484        // model that can't generate is useless — Code alone is not enough.
485        let req = UseCase::Coding.required_capabilities();
486        assert!(req.contains(&ModelCapability::Generate));
487        assert!(req.contains(&ModelCapability::Code));
488    }
489
490    #[test]
491    fn search_is_a_retrieval_role_not_generative() {
492        // Search must never be ranked against chat models.
493        assert_eq!(UseCase::Search.role(), UseCaseRole::Retrieval);
494        assert_eq!(UseCase::Assistant.role(), UseCaseRole::Generative);
495        assert_eq!(UseCase::Transcription.role(), UseCaseRole::Audio);
496        assert_eq!(
497            UseCase::Search.required_capabilities(),
498            &[ModelCapability::Embed]
499        );
500    }
501
502    #[test]
503    fn required_and_preferred_are_disjoint() {
504        // A capability listed as required must not also be "preferred" —
505        // that would double-count it in scoring.
506        for uc in [
507            UseCase::Assistant,
508            UseCase::Coding,
509            UseCase::Summarize,
510            UseCase::Vision,
511            UseCase::Transcription,
512            UseCase::Search,
513        ] {
514            for p in uc.preferred_capabilities() {
515                assert!(
516                    !uc.required_capabilities().contains(p),
517                    "{uc:?}: {p:?} is both required and preferred"
518                );
519            }
520        }
521    }
522
523    #[test]
524    fn tier_weights_match_documented_table() {
525        let b = QualityTier::Balanced.weights();
526        assert_eq!((b.quality, b.latency, b.memory_pressure), (0.5, 0.2, 0.3));
527        let f = QualityTier::Fastest.weights();
528        assert!(f.latency > f.quality, "Fastest must favor latency");
529        let c = QualityTier::MostCapable.weights();
530        assert!(c.quality > c.latency, "MostCapable must favor quality");
531    }
532
533    #[test]
534    fn tier_weights_are_non_negative_and_sum_to_one() {
535        // Guards against a future edit silently letting one axis dominate
536        // by making weights sum to ≠ 1.0. Epsilon compare for floats.
537        for tier in [
538            QualityTier::Fastest,
539            QualityTier::Balanced,
540            QualityTier::MostCapable,
541        ] {
542            let w = tier.weights();
543            for axis in [w.quality, w.latency, w.memory_pressure] {
544                assert!(axis >= 0.0, "{tier:?}: negative weight {axis}");
545            }
546            let sum = w.quality + w.latency + w.memory_pressure;
547            assert!(
548                (sum - 1.0).abs() < 1e-6,
549                "{tier:?}: weights sum to {sum}, expected 1.0"
550            );
551        }
552    }
553
554    #[test]
555    fn use_case_round_trips_snake_case() {
556        let json = serde_json::to_string(&UseCase::Coding).unwrap();
557        assert_eq!(json, "\"coding\"");
558        let back: UseCase = serde_json::from_str("\"search\"").unwrap();
559        assert_eq!(back, UseCase::Search);
560    }
561}