use crate::models::{self, ModelKind, Platform};
use crate::trust::ProducerTrust;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ModelTask {
Embed,
Draft,
Chat,
Review,
Transcribe,
Describe,
Ocr,
}
pub const TASKS: [ModelTask; 7] = [
ModelTask::Embed,
ModelTask::Draft,
ModelTask::Chat,
ModelTask::Review,
ModelTask::Transcribe,
ModelTask::Describe,
ModelTask::Ocr,
];
pub const DEFAULT_GENERATIVE: &str = "qwen3-0.6b";
pub const DEFAULT_OCR: &str = "ocrs-text";
impl ModelTask {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Embed => "embed",
Self::Draft => "draft",
Self::Chat => "chat",
Self::Review => "review",
Self::Transcribe => "transcribe",
Self::Describe => "describe",
Self::Ocr => "ocr",
}
}
#[must_use]
pub fn surface(self) -> &'static str {
match self {
Self::Embed => "roteiro infer",
Self::Draft => "roteiro spec draft",
Self::Chat => "roteiro serve / Ask",
Self::Review => "roteiro review --llm",
Self::Transcribe => "roteiro media build (audio)",
Self::Describe => "roteiro media build (vision)",
Self::Ocr => "roteiro sync (image OCR)",
}
}
#[must_use]
pub fn config_key(self) -> &'static str {
match self {
Self::Embed => "embedding",
Self::Draft | Self::Chat | Self::Review => "generative",
Self::Transcribe => "audio",
Self::Describe => "vision",
Self::Ocr => "ocr",
}
}
#[must_use]
pub fn capable(self, kind: ModelKind) -> bool {
match self {
Self::Embed => kind == ModelKind::Embedding,
Self::Draft | Self::Review => kind == ModelKind::Generative,
Self::Chat => matches!(kind, ModelKind::Generative | ModelKind::Vision),
Self::Transcribe => kind == ModelKind::Audio,
Self::Describe => kind == ModelKind::Vision,
Self::Ocr => kind == ModelKind::Ocr,
}
}
#[must_use]
pub fn goes_remote(self) -> bool {
matches!(self, Self::Draft | Self::Chat | Self::Review)
}
#[must_use]
pub fn default_model(self) -> Option<&'static str> {
match self {
Self::Embed => None,
Self::Draft | Self::Chat | Self::Review => Some(DEFAULT_GENERATIVE),
Self::Transcribe => Some(crate::media::MediaKind::Audio.model()),
Self::Describe => Some(crate::media::MediaKind::Vision.model()),
Self::Ocr => Some(DEFAULT_OCR),
}
}
}
impl std::fmt::Display for ModelTask {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[must_use]
pub fn pin_accepts(key: &str, kind: ModelKind) -> bool {
let mut governs = TASKS.iter().filter(|t| t.config_key() == key).peekable();
governs.peek().is_some() && governs.all(|t| t.capable(kind))
}
fn pin_kinds(key: &str) -> Vec<ModelKind> {
ModelKind::ALL
.iter()
.copied()
.filter(|k| pin_accepts(key, *k))
.collect()
}
fn kinds_phrase(kinds: &[ModelKind]) -> String {
let names: Vec<&str> = kinds.iter().map(|k| k.as_str()).collect();
let joined = match names.as_slice() {
[] => return "no".to_owned(),
[one] => (*one).to_owned(),
[rest @ .., last] => format!("{} or {last}", rest.join(", ")),
};
let article = if joined.starts_with(['a', 'e', 'i', 'o', 'u']) {
"an"
} else {
"a"
};
format!("{article} {joined}")
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ModelPins {
pub embedding: Option<String>,
pub generative: Option<String>,
pub vision: Option<String>,
pub audio: Option<String>,
pub ocr: Option<String>,
}
impl ModelPins {
#[must_use]
pub fn by_key(&self, key: &str) -> Option<&str> {
let raw = match key {
"embedding" => &self.embedding,
"generative" => &self.generative,
"vision" => &self.vision,
"audio" => &self.audio,
"ocr" => &self.ocr,
_ => &None,
};
raw.as_deref().map(str::trim).filter(|v| !v.is_empty())
}
#[must_use]
pub fn for_task(&self, task: ModelTask) -> Option<&str> {
self.by_key(task.config_key())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelSource {
Pinned,
Default,
Remote {
trust: ProducerTrust,
},
}
impl ModelSource {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Pinned => "pinned",
Self::Default => "default",
Self::Remote { .. } => "remote",
}
}
#[must_use]
pub fn trust(self) -> Option<ProducerTrust> {
match self {
Self::Pinned | Self::Default => None,
Self::Remote { trust } => Some(trust),
}
}
#[must_use]
pub fn is_remote(self) -> bool {
matches!(self, Self::Remote { .. })
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RemoteTier {
#[default]
Unavailable,
Granted {
trust: ProducerTrust,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelChoice {
pub task: ModelTask,
pub model: Option<&'static str>,
pub source: ModelSource,
pub installed: Option<bool>,
}
impl ModelChoice {
#[must_use]
pub fn label(&self) -> &'static str {
if self.source.is_remote() {
return "the remote model tier (`[remote] model`)";
}
self.model.unwrap_or("hashing embedder (offline default)")
}
#[must_use]
pub fn why(&self) -> String {
let key = self.task.config_key();
match self.source {
ModelSource::Pinned => format!("pinned by `[models] {key}`"),
ModelSource::Default if self.model.is_none() => {
format!("built-in default, no model needed (`[models] {key}` unset)")
}
ModelSource::Default => format!("built-in default (`[models] {key}` unset)"),
ModelSource::Remote { trust } => format!(
"the remote model tier, granted for this run (ADR-0019) — `[remote] model`, \
not `[models] {key}`; identity is {trust}, a claim rather than a measurement"
),
}
}
pub fn require_installed(&self) -> Result<&'static str, ModelChoiceError> {
if self.source.is_remote() {
return Err(ModelChoiceError::RemoteHasNoWeights { task: self.task });
}
let Some(name) = self.model else {
return Err(ModelChoiceError::NoModel { task: self.task });
};
if self.installed == Some(true) {
return Ok(name);
}
Err(match self.source {
ModelSource::Pinned => ModelChoiceError::PinNotInstalled {
key: self.task.config_key(),
name: name.to_owned(),
},
ModelSource::Default | ModelSource::Remote { .. } => ModelChoiceError::NotInstalled {
name: name.to_owned(),
},
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ModelChoiceError {
#[error(
"`[models] {key} = {name:?}` names no model this build knows — \
run `roteiro model list` for the names it does"
)]
Unknown {
key: &'static str,
name: String,
},
#[error(
"`[models] {key} = {name:?}` is {got} model, but that key needs {want} model \
({key} governs {surfaces}) — run `roteiro model list` to see each model's kind"
)]
WrongKind {
key: &'static str,
name: String,
got: String,
want: String,
surfaces: String,
},
#[error("`[models] {key} = {name:?}` is not installed: run `roteiro model pull {name}`")]
PinNotInstalled {
key: &'static str,
name: String,
},
#[error("model `{name}` is not installed: run `roteiro model pull {name}`")]
NotInstalled {
name: String,
},
#[error("`{task}` has no model to install — it uses the built-in offline default")]
NoModel {
task: ModelTask,
},
#[error(
"`{task}` resolved to the remote model tier for this run, which has no weights on \
this machine to install or load — a hosted model has no registry entry and no \
digest. Send the request through the remote tier, or re-run without \
`--allow-remote` to use the local model deliberately"
)]
RemoteHasNoWeights {
task: ModelTask,
},
}
pub fn resolve_with(task: ModelTask, pins: &ModelPins) -> Result<ModelChoice, ModelChoiceError> {
let key = task.config_key();
let Some(name) = pins.for_task(task) else {
let model = task.default_model();
return Ok(ModelChoice {
task,
installed: Some(model.is_none_or(is_installed_now)),
model,
source: ModelSource::Default,
});
};
let spec = models::find(name).ok_or_else(|| ModelChoiceError::Unknown {
key,
name: name.to_owned(),
})?;
if !pin_accepts(key, spec.kind) {
return Err(ModelChoiceError::WrongKind {
key,
name: name.to_owned(),
got: kinds_phrase(&[spec.kind]),
want: kinds_phrase(&pin_kinds(key)),
surfaces: TASKS
.iter()
.filter(|t| t.config_key() == key)
.map(|t| t.surface())
.collect::<Vec<_>>()
.join(" and "),
});
}
Ok(ModelChoice {
task,
model: Some(spec.name),
source: ModelSource::Pinned,
installed: Some(is_installed_now(spec.name)),
})
}
pub fn resolve_with_remote(
task: ModelTask,
pins: &ModelPins,
remote: RemoteTier,
) -> Result<ModelChoice, ModelChoiceError> {
let local = resolve_with(task, pins)?;
let RemoteTier::Granted { trust } = remote else {
return Ok(local);
};
if !task.goes_remote() {
return Ok(local);
}
Ok(ModelChoice {
task,
model: None,
source: ModelSource::Remote { trust },
installed: None,
})
}
fn is_installed_now(name: &str) -> bool {
models::find(name)
.and_then(|spec| spec.variant_for(Platform::host()))
.is_some_and(|variant| models::is_installed(name, variant))
}
static PINS: std::sync::OnceLock<ModelPins> = std::sync::OnceLock::new();
pub fn set_model_pins(pins: ModelPins) {
let _ = PINS.set(pins);
}
#[must_use]
pub fn model_pins() -> &'static ModelPins {
static UNSET: ModelPins = ModelPins {
embedding: None,
generative: None,
vision: None,
audio: None,
ocr: None,
};
PINS.get().unwrap_or(&UNSET)
}
pub fn resolve(task: ModelTask) -> Result<ModelChoice, ModelChoiceError> {
resolve_with(task, model_pins())
}
#[must_use]
pub fn resolve_all_with(
pins: &ModelPins,
) -> Vec<(ModelTask, Result<ModelChoice, ModelChoiceError>)> {
TASKS
.iter()
.map(|&task| (task, resolve_with(task, pins)))
.collect()
}
#[cfg(test)]
mod tests {
use super::{
DEFAULT_GENERATIVE, DEFAULT_OCR, ModelChoiceError, ModelPins, ModelSource, ModelTask,
RemoteTier, TASKS, pin_accepts, resolve_all_with, resolve_with, resolve_with_remote,
};
use crate::models::{ModelKind, ModelRole, REGISTRY, ResourceTier};
use crate::trust::ProducerTrust;
fn granted() -> RemoteTier {
RemoteTier::Granted {
trust: ProducerTrust::VendorAsserted,
}
}
fn pins(key: &str, value: &str) -> ModelPins {
let mut p = ModelPins::default();
let slot = match key {
"embedding" => &mut p.embedding,
"generative" => &mut p.generative,
"vision" => &mut p.vision,
"audio" => &mut p.audio,
"ocr" => &mut p.ocr,
other => panic!("no such key {other}"),
};
*slot = Some(value.to_owned());
p
}
#[test]
fn resolve_unset_matches_the_previous_hard_coded_models() {
let unset = ModelPins::default();
let expected = [
(ModelTask::Embed, None),
(ModelTask::Draft, Some("qwen3-0.6b")),
(ModelTask::Chat, Some("qwen3-0.6b")),
(ModelTask::Review, Some("qwen3-0.6b")),
(ModelTask::Transcribe, Some("voxtral-mini-3b")),
(ModelTask::Describe, Some("smolvlm-500m-gguf")),
(ModelTask::Ocr, Some("ocrs-text")),
];
for (task, model) in expected {
let choice = resolve_with(task, &unset).expect("unset resolves");
assert_eq!(choice.model, model, "{task} default");
assert_eq!(choice.source, ModelSource::Default, "{task} source");
assert!(
choice.why().contains("unset"),
"{task} explains itself: {}",
choice.why()
);
}
}
#[test]
fn default_generative_is_still_the_low_tier_instruct_pick() {
let searched = REGISTRY
.iter()
.find(|m| {
m.kind == ModelKind::Generative
&& m.role == ModelRole::Instruct
&& m.tier == ResourceTier::Low
})
.expect("a low-tier instruct model is curated");
assert_eq!(searched.name, DEFAULT_GENERATIVE);
}
#[test]
fn every_default_is_a_registry_model_the_task_can_use() {
for task in TASKS {
let Some(name) = task.default_model() else {
continue;
};
let spec = crate::models::find(name).unwrap_or_else(|| panic!("{task}: {name} exists"));
assert!(task.capable(spec.kind), "{task} can use {name}");
assert!(
pin_accepts(task.config_key(), spec.kind),
"{task}'s own default would be accepted by its key"
);
}
assert_eq!(DEFAULT_OCR, "ocrs-text");
}
#[test]
fn a_wrong_modality_pin_errors_and_names_the_key() {
let err = resolve_with(ModelTask::Transcribe, &pins("audio", "smolvlm-500m-gguf"))
.expect_err("a vision model cannot transcribe");
let ModelChoiceError::WrongKind { key, ref name, .. } = err else {
panic!("expected WrongKind, got {err:?}");
};
assert_eq!(key, "audio");
assert_eq!(name, "smolvlm-500m-gguf");
let text = err.to_string();
assert!(text.contains("[models] audio"), "names the key: {text}");
assert!(text.contains("vision model"), "names what it is: {text}");
assert!(
text.contains("an audio model"),
"names what it needs: {text}"
);
}
#[test]
fn an_embedding_model_is_refused_as_the_generative_pin() {
for task in [ModelTask::Draft, ModelTask::Chat] {
let err = resolve_with(task, &pins("generative", "bge-small-en-v1.5-gguf"))
.expect_err("an encoder cannot generate");
assert!(
matches!(
err,
ModelChoiceError::WrongKind {
key: "generative",
..
}
),
"{task}: {err:?}"
);
}
}
#[test]
fn a_shared_key_accepts_only_what_all_its_surfaces_accept() {
assert!(ModelTask::Chat.capable(ModelKind::Vision));
assert!(!ModelTask::Draft.capable(ModelKind::Vision));
assert!(!pin_accepts("generative", ModelKind::Vision));
assert!(pin_accepts("generative", ModelKind::Generative));
let err = resolve_with(ModelTask::Chat, &pins("generative", "smolvlm-500m-gguf"))
.expect_err("the key is shared with draft");
let text = err.to_string();
assert!(
text.contains("roteiro spec draft") && text.contains("roteiro serve / Ask"),
"says which surfaces the key governs: {text}"
);
}
#[test]
fn an_unknown_pin_errors_rather_than_falling_back() {
let err = resolve_with(ModelTask::Describe, &pins("vision", "smolvlm-500m"))
.expect_err("no such model");
assert!(
matches!(err, ModelChoiceError::Unknown { key: "vision", .. }),
"{err:?}"
);
assert!(
err.to_string().contains("roteiro model list"),
"actionable: {err}"
);
}
#[test]
fn a_blank_pin_reads_as_unset() {
let choice = resolve_with(ModelTask::Transcribe, &pins("audio", " "))
.expect("blank is not a name");
assert_eq!(choice.source, ModelSource::Default);
assert_eq!(choice.model, Some("voxtral-mini-3b"));
}
#[test]
fn a_valid_pin_is_honoured_and_reports_its_key() {
let choice = resolve_with(ModelTask::Draft, &pins("generative", "qwen3-8b"))
.expect("a generative model is valid for draft");
assert_eq!(choice.model, Some("qwen3-8b"));
assert_eq!(choice.source, ModelSource::Pinned);
assert_eq!(choice.why(), "pinned by `[models] generative`");
}
#[test]
fn a_pin_moves_only_its_own_tasks() {
let pinned = pins("audio", "voxtral-mini-3b");
for (task, result) in resolve_all_with(&pinned) {
let choice = result.expect("valid");
let expected = if task == ModelTask::Transcribe {
ModelSource::Pinned
} else {
ModelSource::Default
};
assert_eq!(choice.source, expected, "{task}");
}
}
#[test]
fn require_installed_on_a_modelless_task_is_named() {
let choice = resolve_with(ModelTask::Embed, &ModelPins::default()).expect("resolves");
let err = choice.require_installed().expect_err("no model to install");
assert!(
matches!(
err,
ModelChoiceError::NoModel {
task: ModelTask::Embed
}
),
"{err:?}"
);
}
#[test]
fn a_granted_tier_takes_the_generative_surfaces_and_no_others() {
let unset = ModelPins::default();
for task in TASKS {
let choice = resolve_with_remote(task, &unset, granted()).expect("resolves");
if task.goes_remote() {
assert_eq!(
choice.source,
ModelSource::Remote {
trust: ProducerTrust::VendorAsserted
},
"{task} is one of ADR-0019's two surfaces"
);
assert!(choice.model.is_none(), "{task}: no registry name exists");
assert!(
choice.installed.is_none(),
"{task}: `installed` has no answer, not a negative one"
);
} else {
assert_eq!(
choice,
resolve_with(task, &unset).expect("resolves"),
"{task} is untouched by a granted tier"
);
assert!(choice.installed.is_some(), "{task}: locally answerable");
}
}
assert!(
ModelTask::Draft.goes_remote()
&& ModelTask::Chat.goes_remote()
&& ModelTask::Review.goes_remote()
);
assert!(
!ModelTask::Embed.goes_remote()
&& !ModelTask::Transcribe.goes_remote()
&& !ModelTask::Describe.goes_remote()
&& !ModelTask::Ocr.goes_remote()
);
}
#[test]
fn review_is_a_task_on_the_existing_generative_key() {
assert_eq!(ModelTask::Review.config_key(), "generative");
assert_eq!(ModelTask::Review.as_str(), "review");
assert_eq!(ModelTask::Review.surface(), "roteiro review --llm");
assert_eq!(ModelTask::Review.default_model(), Some(DEFAULT_GENERATIVE));
assert!(ModelTask::Review.capable(ModelKind::Generative));
assert!(!ModelTask::Review.capable(ModelKind::Vision));
assert!(!ModelTask::Review.capable(ModelKind::Embedding));
assert!(!pin_accepts("generative", ModelKind::Vision));
assert!(pin_accepts("generative", ModelKind::Generative));
let choice = resolve_with(ModelTask::Review, &pins("generative", "qwen3-8b"))
.expect("a generative pin resolves");
assert_eq!(choice.model, Some("qwen3-8b"));
assert_eq!(choice.source, ModelSource::Pinned);
assert!(
choice.why().contains("[models] generative"),
"names the key that chose it: {}",
choice.why()
);
let err = resolve_with(
ModelTask::Review,
&pins("generative", "bge-small-en-v1.5-gguf"),
)
.expect_err("an encoder cannot review");
assert!(
matches!(
err,
ModelChoiceError::WrongKind {
key: "generative",
..
}
),
"{err:?}"
);
}
#[test]
fn every_task_is_listed_exactly_once() {
let tokens: Vec<&str> = TASKS.iter().map(|t| t.as_str()).collect();
let mut deduped = tokens.clone();
deduped.sort_unstable();
deduped.dedup();
assert_eq!(deduped.len(), tokens.len(), "TASKS repeats a task");
assert!(tokens.contains(&"review"), "{tokens:?}");
assert_eq!(
resolve_all_with(&ModelPins::default()).len(),
TASKS.len(),
"`roteiro config` reports every task"
);
}
#[test]
fn a_remote_resolution_declares_its_identity_a_claim() {
let choice = resolve_with_remote(ModelTask::Chat, &ModelPins::default(), granted())
.expect("resolves");
assert_eq!(choice.source.as_str(), "remote");
assert!(choice.source.is_remote());
assert_eq!(choice.source.trust(), Some(ProducerTrust::VendorAsserted));
let why = choice.why();
assert!(why.contains("vendor_asserted"), "{why}");
assert!(why.contains("claim rather than a measurement"), "{why}");
assert!(why.contains("granted for this run"), "{why}");
assert_eq!(choice.label(), "the remote model tier (`[remote] model`)");
let local = resolve_with(ModelTask::Chat, &ModelPins::default()).expect("resolves");
assert_eq!(local.source.trust(), None);
assert!(!local.source.is_remote());
}
#[test]
fn a_granted_tier_displaces_a_pin_and_says_which_key_it_displaced() {
let pinned = pins("generative", "qwen3-8b");
let local = resolve_with(ModelTask::Draft, &pinned).expect("a valid pin");
assert_eq!(local.model, Some("qwen3-8b"));
assert_eq!(local.source, ModelSource::Pinned);
let remote = resolve_with_remote(ModelTask::Draft, &pinned, granted()).expect("resolves");
assert!(remote.source.is_remote(), "the invocation outranks the pin");
let why = remote.why();
assert!(
why.contains("not `[models] generative`"),
"the displaced key is named: {why}"
);
}
#[test]
fn a_broken_pin_still_fails_under_a_granted_tier() {
let err = resolve_with_remote(
ModelTask::Draft,
&pins("generative", "bge-small-en-v1.5-gguf"),
granted(),
)
.expect_err("an encoder cannot generate, tier or no tier");
assert!(
matches!(
err,
ModelChoiceError::WrongKind {
key: "generative",
..
}
),
"{err:?}"
);
let err = resolve_with_remote(
ModelTask::Chat,
&pins("generative", "no-such-model"),
granted(),
)
.expect_err("an unknown name is still unknown");
assert!(matches!(err, ModelChoiceError::Unknown { .. }), "{err:?}");
}
#[test]
fn an_unavailable_tier_resolves_exactly_as_the_local_resolver_does() {
assert_eq!(RemoteTier::default(), RemoteTier::Unavailable);
for pins in [
ModelPins::default(),
pins("generative", "qwen3-8b"),
pins("audio", "voxtral-mini-3b"),
] {
for task in TASKS {
assert_eq!(
resolve_with_remote(task, &pins, RemoteTier::default()).ok(),
resolve_with(task, &pins).ok(),
"{task}: an unavailable tier changes nothing"
);
}
}
}
#[test]
fn require_installed_on_a_remote_choice_refuses_rather_than_falling_back() {
let choice = resolve_with_remote(ModelTask::Draft, &ModelPins::default(), granted())
.expect("resolves");
let err = choice
.require_installed()
.expect_err("there are no weights to require");
assert!(
matches!(
err,
ModelChoiceError::RemoteHasNoWeights {
task: ModelTask::Draft
}
),
"{err:?}"
);
let text = err.to_string();
assert!(text.contains("no registry entry and no digest"), "{text}");
assert!(text.contains("--allow-remote"), "actionable: {text}");
assert!(!matches!(err, ModelChoiceError::NoModel { .. }));
}
#[test]
fn every_key_and_task_are_connected() {
let keys = ["embedding", "generative", "vision", "audio", "ocr"];
for task in TASKS {
assert!(keys.contains(&task.config_key()), "{task}");
}
for key in keys {
assert!(
TASKS.iter().any(|t| t.config_key() == key),
"{key} governs something"
);
assert!(
!super::pin_kinds(key).is_empty(),
"{key} accepts some model kind"
);
}
assert!(!pin_accepts("nonsense", ModelKind::Generative));
}
}