use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::{AccelerationMode, ModelRegistry, ModelTask};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum ModelBackend {
OnnxRuntime,
Tract,
RemoteApi,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum SelectionPreference {
Accuracy,
Latency,
#[default]
Balanced,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
pub enum ModelReadiness {
#[default]
Unknown,
Available,
Loadable,
Tested,
Validated,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ModelCapability {
pub backends: Vec<ModelBackend>,
pub acceleration: Vec<AccelerationMode>,
pub languages: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ModelEvidence {
pub readiness: ModelReadiness,
pub accuracy_score: Option<f32>,
pub warm_latency_ms: Option<u64>,
pub memory_mb: Option<u64>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ModelSelectionMetadata {
pub capability: ModelCapability,
pub evidence: ModelEvidence,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelCandidate {
pub id: String,
pub task: ModelTask,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelSelectionRequest {
pub task: ModelTask,
pub backend: Option<ModelBackend>,
pub acceleration: Option<AccelerationMode>,
pub language: Option<String>,
pub preference: SelectionPreference,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelSelectionDecision {
pub selected: Option<String>,
pub fallbacks: Vec<String>,
pub reasons: Vec<SelectionReason>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SelectionReason {
RejectedTask {
model_id: String,
},
RejectedBackend {
model_id: String,
backend: ModelBackend,
},
RejectedAcceleration {
model_id: String,
acceleration: AccelerationMode,
},
RejectedLanguage {
model_id: String,
language: String,
},
Selected {
model_id: String,
score: i64,
},
Fallback {
model_id: String,
score: i64,
},
}
#[derive(Debug, Clone, Default)]
pub struct ModelSelectionPolicy {
metadata: BTreeMap<String, ModelSelectionMetadata>,
}
impl ModelSelectionPolicy {
pub fn with_metadata(
mut self,
model_id: impl Into<String>,
metadata: ModelSelectionMetadata,
) -> Self {
self.metadata.insert(model_id.into(), metadata);
self
}
pub fn select_registry(
&self,
registry: &ModelRegistry,
request: &ModelSelectionRequest,
) -> ModelSelectionDecision {
let candidates: Vec<ModelCandidate> = registry
.find_by_task(request.task)
.into_iter()
.map(|(manifest, _)| ModelCandidate {
id: manifest.id.clone(),
task: manifest.task,
})
.collect();
self.select(candidates, request)
}
pub fn select(
&self,
candidates: impl IntoIterator<Item = ModelCandidate>,
request: &ModelSelectionRequest,
) -> ModelSelectionDecision {
let mut reasons = Vec::new();
let mut eligible = Vec::new();
for candidate in candidates {
if candidate.task != request.task {
reasons.push(SelectionReason::RejectedTask {
model_id: candidate.id,
});
continue;
}
let metadata = self
.metadata
.get(&candidate.id)
.cloned()
.unwrap_or_default();
if let Some(backend) = request.backend {
if !metadata.capability.backends.is_empty()
&& !metadata.capability.backends.contains(&backend)
{
reasons.push(SelectionReason::RejectedBackend {
model_id: candidate.id,
backend,
});
continue;
}
}
if let Some(acceleration) = request.acceleration {
if !metadata.capability.acceleration.is_empty()
&& !metadata.capability.acceleration.contains(&acceleration)
{
reasons.push(SelectionReason::RejectedAcceleration {
model_id: candidate.id,
acceleration,
});
continue;
}
}
if let Some(language) = request.language.as_deref() {
if !metadata.capability.languages.is_empty()
&& !metadata
.capability
.languages
.iter()
.any(|candidate_language| candidate_language.eq_ignore_ascii_case(language))
{
reasons.push(SelectionReason::RejectedLanguage {
model_id: candidate.id,
language: language.to_string(),
});
continue;
}
}
eligible.push((candidate.id, score(&metadata.evidence, request.preference)));
}
eligible.sort_by(|(left_id, left_score), (right_id, right_score)| {
right_score
.cmp(left_score)
.then_with(|| left_id.cmp(right_id))
});
let selected = eligible.first().map(|(id, _)| id.clone());
for (index, (model_id, score)) in eligible.into_iter().enumerate() {
reasons.push(if index == 0 {
SelectionReason::Selected { model_id, score }
} else {
SelectionReason::Fallback { model_id, score }
});
}
let fallbacks = reasons
.iter()
.filter_map(|reason| match reason {
SelectionReason::Fallback { model_id, .. } => Some(model_id.clone()),
_ => None,
})
.collect();
ModelSelectionDecision {
selected,
fallbacks,
reasons,
}
}
}
fn score(evidence: &ModelEvidence, preference: SelectionPreference) -> i64 {
let readiness = evidence.readiness as i64 * 1_000_000;
let accuracy = evidence
.accuracy_score
.map(|value| (value * 10_000.0) as i64)
.unwrap_or_default();
let latency = evidence
.warm_latency_ms
.map(|value| 100_000_i64.saturating_sub(value as i64).max(0))
.unwrap_or_default();
match preference {
SelectionPreference::Accuracy => readiness + accuracy * 10 + latency,
SelectionPreference::Latency => readiness + latency * 10 + accuracy,
SelectionPreference::Balanced => readiness + accuracy * 5 + latency * 5,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn selection_is_deterministic_and_returns_fallbacks() {
let policy = ModelSelectionPolicy::default()
.with_metadata(
"formula/cpu",
ModelSelectionMetadata {
capability: ModelCapability {
backends: vec![ModelBackend::OnnxRuntime],
acceleration: vec![AccelerationMode::Cpu],
languages: vec!["en".to_string()],
},
evidence: ModelEvidence {
readiness: ModelReadiness::Validated,
accuracy_score: Some(0.95),
warm_latency_ms: Some(20),
memory_mb: None,
},
},
)
.with_metadata(
"formula/fast",
ModelSelectionMetadata {
capability: ModelCapability {
backends: vec![ModelBackend::OnnxRuntime],
acceleration: vec![AccelerationMode::Cpu],
languages: vec!["en".to_string()],
},
evidence: ModelEvidence {
readiness: ModelReadiness::Tested,
accuracy_score: Some(0.90),
warm_latency_ms: Some(1),
memory_mb: None,
},
},
);
let request = ModelSelectionRequest {
task: ModelTask::FormulaRecognition,
backend: Some(ModelBackend::OnnxRuntime),
acceleration: Some(AccelerationMode::Cpu),
language: Some("en".to_string()),
preference: SelectionPreference::Accuracy,
};
let decision = policy.select(
vec![
ModelCandidate {
id: "formula/fast".to_string(),
task: ModelTask::FormulaRecognition,
},
ModelCandidate {
id: "formula/cpu".to_string(),
task: ModelTask::FormulaRecognition,
},
],
&request,
);
assert_eq!(decision.selected.as_deref(), Some("formula/cpu"));
assert_eq!(decision.fallbacks, vec!["formula/fast"]);
}
#[test]
fn incompatible_candidates_are_explained() {
let request = ModelSelectionRequest {
task: ModelTask::TextRecognition,
backend: Some(ModelBackend::Tract),
acceleration: None,
language: None,
preference: SelectionPreference::Balanced,
};
let decision = ModelSelectionPolicy::default()
.with_metadata(
"text/ort-only",
ModelSelectionMetadata {
capability: ModelCapability {
backends: vec![ModelBackend::OnnxRuntime],
..ModelCapability::default()
},
..ModelSelectionMetadata::default()
},
)
.select(
vec![ModelCandidate {
id: "text/ort-only".to_string(),
task: ModelTask::TextRecognition,
}],
&request,
);
assert!(decision.selected.is_none());
assert!(matches!(
decision.reasons.as_slice(),
[SelectionReason::RejectedBackend { .. }]
));
}
}