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 /// Bias the score profile toward local models (cost over quality).
106 /// Internally this maps to `RoutingWorkload::Background` until the
107 /// follow-up split lands (parslee-ai/car#106).
108 #[serde(default, skip_serializing_if = "is_false")]
109 pub prefer_local: bool,
110
111 /// Bias the score profile aggressively toward latency. Maps to
112 /// [`crate::tasks::RoutingWorkload::Fastest`] — a weight profile
113 /// that downweights quality and cost in favour of time-to-first-token.
114 /// Designed for voice turns where a sub-500ms first-audio target
115 /// beats a richer-but-slower answer. Takes precedence over
116 /// `prefer_local`; if both are set, the request is routed by
117 /// `Fastest` rules.
118 #[serde(default, skip_serializing_if = "is_false")]
119 pub prefer_fast: bool,
120
121 /// Bias the score profile toward the most capable model — quality
122 /// dominates, latency and cost are near-floor (maps to
123 /// [`crate::tasks::RoutingWorkload::Quality`]). For quality-critical,
124 /// infrequent work where a weak model fails (building/verifying an agent,
125 /// deriving a contract, structured extraction): the strongest candidate
126 /// wins, not the cheapest. Precedence: `prefer_fast` wins outright
127 /// (latency is the most extreme need), then `prefer_quality`, then
128 /// `prefer_local`.
129 #[serde(default, skip_serializing_if = "is_false")]
130 pub prefer_quality: bool,
131
132 /// The operation is high-stakes — consequential or irreversible (e.g. the
133 /// session is authorized for `FullAccess`/externally-consequential actions).
134 /// Forces the strongest quality posture
135 /// ([`crate::tasks::RoutingWorkload::Quality`]) regardless of task,
136 /// complexity, or any cost/latency preference: never economize on what you
137 /// can't take back. Highest precedence — wins over `prefer_fast`,
138 /// `prefer_quality`, and `prefer_local`. Distinct from `prefer_quality`
139 /// because it has different *precedence* (it outranks `prefer_fast`, which
140 /// `prefer_quality` does not) — a stakes-driven quality need must beat even
141 /// the latency lane. Callers set it from the action/session permission tier.
142 #[serde(default, skip_serializing_if = "is_false")]
143 pub high_stakes: bool,
144
145 /// Only consider models usable **right now, without a download**.
146 ///
147 /// For a local MLX model, `available` is true as soon as an `hf_repo` is
148 /// declared, because `ensure_local()` lazy-downloads on first use (#164).
149 /// That is right for open-ended work and wrong for work on a deadline: a
150 /// step with a bounded budget that selects a model it must first fetch
151 /// spends the entire budget downloading and then fails.
152 ///
153 /// `car code`'s contract derivation is the motivating case
154 /// (Parslee-ai/car#638). It runs under a 120s cap, routed with
155 /// `require: [Code], prefer_quality: true`, and on a machine with no local
156 /// weights that selected a 4.8 GB model it had to download first — timing
157 /// out three times while cloud models capable of answering in ~2s sat in
158 /// the fallback list, unreached. Set this whenever a bounded timeout is
159 /// enforced around the call.
160 ///
161 /// Soft by necessity, like [`Self::exclude_models`]: if requiring readiness
162 /// leaves no candidate, the constraint is dropped rather than failing the
163 /// request — a slow answer beats no answer.
164 #[serde(default, skip_serializing_if = "is_false")]
165 pub require_ready: bool,
166}
167
168impl IntentHint {
169 /// `Some(IntentHint { high_stakes: true, .. })` when `b`, else `None`.
170 ///
171 /// The single source of truth for "mark this generation high-stakes" so the
172 /// in-process autonomous loops (active-planner, reason, agents) don't each
173 /// hand-roll the struct literal — keeping the `intent: None` fast path and
174 /// the high-stakes path identical across call sites. `None` is a true no-op
175 /// (a default `IntentHint` doesn't trigger the Quality posture either), so a
176 /// benign loop routes exactly as it did before stakes-awareness existed.
177 pub fn high_stakes_if(b: bool) -> Option<IntentHint> {
178 b.then(|| IntentHint {
179 high_stakes: true,
180 ..Default::default()
181 })
182 }
183}
184
185fn is_false(b: &bool) -> bool {
186 !*b
187}
188
189// ---------------------------------------------------------------------------
190// Acquisition intent — which model to *recommend/install* for this machine.
191//
192// Distinct layer from `TaskHint`/`IntentHint` above, which are *inference-time*
193// routing intent (which already-installed model should serve this request).
194// The types below answer the earlier question: "given this hardware and what
195// the user wants to do, which model should they acquire?" They are consumed by
196// `ModelRecommender` (see docs/solutions/first-class-model-ux.md) and never
197// expose model IDs, quantization, or HF repos to the caller.
198// ---------------------------------------------------------------------------
199
200/// The kind of model a use case needs. The recommender ranks only *within*
201/// a role's lane — an embedding model and a chat model are not comparable,
202/// so a retrieval pick never competes with a generative one. A use case that
203/// spans roles resolves to a bundle (one recommendation per role).
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
205#[serde(rename_all = "snake_case")]
206pub enum UseCaseRole {
207 /// Produces text/tokens (chat, code, vision-to-text, summarize).
208 Generative,
209 /// Produces vectors / relevance scores (embeddings, rerank).
210 Retrieval,
211 /// Consumes audio (transcription).
212 Audio,
213}
214
215/// What the user wants to do, in their terms — not a model ID. Closed enum:
216/// adding a use case is a deliberate FFI-visible change, never a silent
217/// string fallback. Each variant maps to a [`UseCaseRole`] and a required /
218/// preferred [`ModelCapability`] set (see [`UseCase::required_capabilities`]).
219#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
220#[serde(rename_all = "snake_case")]
221#[derive(Default)]
222pub enum UseCase {
223 /// General chat / Q&A. The default.
224 #[default]
225 Assistant,
226 /// Code generation, repair, refactoring.
227 Coding,
228 /// Text condensation.
229 Summarize,
230 /// Image understanding (a generative model that also sees).
231 Vision,
232 /// Audio → text.
233 Transcription,
234 /// Semantic search — an embedding model for retrieval. NOT an LLM
235 /// performing web search with tools; this is the `Retrieval` role and
236 /// maps to the `Embed` capability, so it is ranked separately from any
237 /// generative chat model.
238 Search,
239}
240
241impl UseCase {
242 /// The role lane this use case is ranked within.
243 pub fn role(self) -> UseCaseRole {
244 match self {
245 UseCase::Assistant | UseCase::Coding | UseCase::Summarize | UseCase::Vision => {
246 UseCaseRole::Generative
247 }
248 UseCase::Search => UseCaseRole::Retrieval,
249 UseCase::Transcription => UseCaseRole::Audio,
250 }
251 }
252
253 /// Hard eligibility filter — a model missing any of these is excluded.
254 pub fn required_capabilities(self) -> &'static [ModelCapability] {
255 use ModelCapability::*;
256 match self {
257 UseCase::Assistant => &[Generate],
258 UseCase::Coding => &[Generate, Code],
259 UseCase::Summarize => &[Generate],
260 UseCase::Vision => &[Vision, Generate],
261 UseCase::Transcription => &[SpeechToText],
262 UseCase::Search => &[Embed],
263 }
264 }
265
266 /// Soft preference — present capabilities add a ranking bonus but are
267 /// never required for eligibility.
268 pub fn preferred_capabilities(self) -> &'static [ModelCapability] {
269 use ModelCapability::*;
270 match self {
271 UseCase::Assistant => &[ToolUse],
272 UseCase::Coding => &[ToolUse, Reasoning],
273 UseCase::Summarize => &[Summarize],
274 UseCase::Vision => &[],
275 UseCase::Transcription => &[],
276 UseCase::Search => &[Rerank],
277 }
278 }
279}
280
281/// Speed/quality knob. Each tier is a fixed weighting over the recommender's
282/// soft-score axes, applied *after* the hard eligibility filter, so tier
283/// semantics are explicit rather than reinvented per call site.
284#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
285#[serde(rename_all = "snake_case")]
286#[derive(Default)]
287pub enum QualityTier {
288 /// Smallest eligible model; lowest latency.
289 Fastest,
290 /// Best quality that fits with KV-cache headroom. The default.
291 #[default]
292 Balanced,
293 /// Largest model that fits at all; accepts slower output.
294 MostCapable,
295}
296
297/// Relative weights a [`QualityTier`] places on each soft-score axis. The
298/// recommender normalizes each axis to `[0,1]` and combines them with these.
299#[derive(Debug, Clone, Copy, PartialEq)]
300pub struct TierWeights {
301 /// Toward higher-quality models (benchmarks / param-count prior).
302 pub quality: f32,
303 /// Toward lower-latency models (smaller, better-accelerated).
304 pub latency: f32,
305 /// Toward leaving memory headroom (smaller fraction of budget used).
306 pub memory_pressure: f32,
307}
308
309impl QualityTier {
310 /// The fixed axis weighting for this tier. Mirrors the table in
311 /// docs/solutions/first-class-model-ux.md.
312 pub fn weights(self) -> TierWeights {
313 match self {
314 QualityTier::Fastest => TierWeights {
315 quality: 0.2,
316 latency: 0.6,
317 memory_pressure: 0.2,
318 },
319 QualityTier::Balanced => TierWeights {
320 quality: 0.5,
321 latency: 0.2,
322 memory_pressure: 0.3,
323 },
324 // Memory is a *fit* question on this tier, and fit is already a hard
325 // constraint: `FitStatus` and `within_recommendation_target` exclude
326 // anything the machine cannot hold. Leaving 0.2 on the soft penalty
327 // on top of that made "most capable" contradict its own name — on a
328 // 64 GB Mac, Qwen3-8B (judged 0.770, 4.7 GB) beat
329 // Qwen3.6-35B-A3B (judged 0.830, 19.9 GB) by 0.007, entirely because
330 // the larger model used more of the memory the user has and asked to
331 // use. A measured quality difference should not lose to that here.
332 // Some pressure term remains, so among models of equal measured
333 // quality the leaner one still wins.
334 QualityTier::MostCapable => TierWeights {
335 quality: 0.9,
336 latency: 0.0,
337 memory_pressure: 0.1,
338 },
339 }
340 }
341}
342
343/// Where the user is willing to run inference. Orthogonal to [`QualityTier`].
344/// Choosing the cloud is never silent — `CloudOk` only makes remote models
345/// *eligible*; the recommender still flags them as requiring one-time consent.
346#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
347#[serde(rename_all = "snake_case")]
348#[derive(Default)]
349pub enum Privacy {
350 /// Local models only.
351 #[default]
352 OnDevice,
353 /// Remote APIs / the Parslee gateway may compete and win.
354 CloudOk,
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360
361 #[test]
362 fn empty_intent_serializes_compactly() {
363 // No-intent must round-trip through serde without verbose
364 // null fields — the FFI layer transmits as JSON and clients
365 // shouldn't see {"task":null,"require":[],"prefer_local":false}.
366 let hint = IntentHint::default();
367 let json = serde_json::to_string(&hint).unwrap();
368 assert_eq!(json, "{}");
369 }
370
371 #[test]
372 fn round_trip_with_capability_require() {
373 let hint = IntentHint {
374 task: Some(TaskHint::Code),
375 require: vec![ModelCapability::Code, ModelCapability::ToolUse],
376 prefer_local: true,
377 ..Default::default()
378 };
379 let json = serde_json::to_string(&hint).unwrap();
380 let back: IntentHint = serde_json::from_str(&json).unwrap();
381 assert_eq!(back.task, Some(TaskHint::Code));
382 assert_eq!(
383 back.require,
384 vec![ModelCapability::Code, ModelCapability::ToolUse]
385 );
386 assert!(back.prefer_local);
387 assert!(!back.prefer_fast);
388 }
389
390 #[test]
391 fn exclude_models_round_trips_and_skips_when_empty() {
392 // Empty exclusion must keep the compact `{}` wire shape (car#358).
393 let off = IntentHint::default();
394 assert_eq!(serde_json::to_string(&off).unwrap(), "{}");
395
396 let on = IntentHint {
397 exclude_models: vec!["author-model-id".to_string()],
398 ..Default::default()
399 };
400 let json = serde_json::to_string(&on).unwrap();
401 assert!(json.contains("exclude_models"));
402 let back: IntentHint = serde_json::from_str(&json).unwrap();
403 assert_eq!(back.exclude_models, vec!["author-model-id".to_string()]);
404 }
405
406 #[test]
407 fn missing_fields_default_cleanly() {
408 // Pre-MVP clients that don't know about IntentHint may send
409 // partial JSON. Defaults must match the no-intent path.
410 let hint: IntentHint = serde_json::from_str("{}").unwrap();
411 assert_eq!(hint.task, None);
412 assert!(hint.require.is_empty());
413 assert!(!hint.prefer_local);
414 assert!(!hint.prefer_fast);
415 }
416
417 #[test]
418 fn prefer_fast_round_trips_and_skips_when_false() {
419 let off = IntentHint::default();
420 assert_eq!(serde_json::to_string(&off).unwrap(), "{}");
421
422 let on = IntentHint {
423 prefer_fast: true,
424 ..IntentHint::default()
425 };
426 let json = serde_json::to_string(&on).unwrap();
427 assert!(json.contains("prefer_fast"));
428 let back: IntentHint = serde_json::from_str(&json).unwrap();
429 assert!(back.prefer_fast);
430 }
431
432 // --- acquisition intent (UseCase / QualityTier / Privacy) ---
433
434 #[test]
435 fn use_case_defaults_to_assistant_and_balanced_on_device() {
436 assert_eq!(UseCase::default(), UseCase::Assistant);
437 assert_eq!(QualityTier::default(), QualityTier::Balanced);
438 assert_eq!(Privacy::default(), Privacy::OnDevice);
439 }
440
441 #[test]
442 fn coding_requires_both_generate_and_code() {
443 // Regression guard for the design-review point that a coding
444 // model that can't generate is useless — Code alone is not enough.
445 let req = UseCase::Coding.required_capabilities();
446 assert!(req.contains(&ModelCapability::Generate));
447 assert!(req.contains(&ModelCapability::Code));
448 }
449
450 #[test]
451 fn search_is_a_retrieval_role_not_generative() {
452 // Search must never be ranked against chat models.
453 assert_eq!(UseCase::Search.role(), UseCaseRole::Retrieval);
454 assert_eq!(UseCase::Assistant.role(), UseCaseRole::Generative);
455 assert_eq!(UseCase::Transcription.role(), UseCaseRole::Audio);
456 assert_eq!(
457 UseCase::Search.required_capabilities(),
458 &[ModelCapability::Embed]
459 );
460 }
461
462 #[test]
463 fn required_and_preferred_are_disjoint() {
464 // A capability listed as required must not also be "preferred" —
465 // that would double-count it in scoring.
466 for uc in [
467 UseCase::Assistant,
468 UseCase::Coding,
469 UseCase::Summarize,
470 UseCase::Vision,
471 UseCase::Transcription,
472 UseCase::Search,
473 ] {
474 for p in uc.preferred_capabilities() {
475 assert!(
476 !uc.required_capabilities().contains(p),
477 "{uc:?}: {p:?} is both required and preferred"
478 );
479 }
480 }
481 }
482
483 #[test]
484 fn tier_weights_match_documented_table() {
485 let b = QualityTier::Balanced.weights();
486 assert_eq!((b.quality, b.latency, b.memory_pressure), (0.5, 0.2, 0.3));
487 let f = QualityTier::Fastest.weights();
488 assert!(f.latency > f.quality, "Fastest must favor latency");
489 let c = QualityTier::MostCapable.weights();
490 assert!(c.quality > c.latency, "MostCapable must favor quality");
491 }
492
493 #[test]
494 fn tier_weights_are_non_negative_and_sum_to_one() {
495 // Guards against a future edit silently letting one axis dominate
496 // by making weights sum to ≠ 1.0. Epsilon compare for floats.
497 for tier in [
498 QualityTier::Fastest,
499 QualityTier::Balanced,
500 QualityTier::MostCapable,
501 ] {
502 let w = tier.weights();
503 for axis in [w.quality, w.latency, w.memory_pressure] {
504 assert!(axis >= 0.0, "{tier:?}: negative weight {axis}");
505 }
506 let sum = w.quality + w.latency + w.memory_pressure;
507 assert!(
508 (sum - 1.0).abs() < 1e-6,
509 "{tier:?}: weights sum to {sum}, expected 1.0"
510 );
511 }
512 }
513
514 #[test]
515 fn use_case_round_trips_snake_case() {
516 let json = serde_json::to_string(&UseCase::Coding).unwrap();
517 assert_eq!(json, "\"coding\"");
518 let back: UseCase = serde_json::from_str("\"search\"").unwrap();
519 assert_eq!(back, UseCase::Search);
520 }
521}