Skip to main content

aria_router_algorithm/
lib.rs

1//! Selection algorithms (static / latency-aware / multi-factor / elo).
2
3mod elo;
4
5pub use elo::{global_elo, EloTable};
6
7use aria_router_config::{DecisionCfg, RouterDocument};
8use aria_router_core::{RouterError, ModelCard};
9
10#[derive(Debug, Clone, Default)]
11pub struct RuntimeStats {
12    pub latency_ms: std::collections::HashMap<String, f32>,
13    pub load: std::collections::HashMap<String, f32>,
14    pub cost: std::collections::HashMap<String, f32>,
15    /// Optional Elo ratings snapshot (model → rating).
16    pub elo: std::collections::HashMap<String, f32>,
17}
18
19pub fn select(
20    _doc: &RouterDocument,
21    decision: &DecisionCfg,
22    eligible: &[ModelCard],
23    stats: &RuntimeStats,
24) -> Result<String, RouterError> {
25    if eligible.is_empty() {
26        return Err(RouterError::FailClosed("no eligible models".into()));
27    }
28    let algo = decision.algorithm.as_deref().unwrap_or("static");
29    if RouterDocument::unimplemented_algorithm(algo) {
30        return Err(RouterError::Unsupported(format!("algorithm {algo} not implemented")));
31    }
32    let names: Vec<String> = if decision.model_refs.is_empty() {
33        eligible.iter().map(|m| m.name.clone()).collect()
34    } else {
35        decision
36            .model_refs
37            .iter()
38            .map(|r| r.model.clone())
39            .filter(|n| eligible.iter().any(|e| e.name == *n))
40            .collect()
41    };
42    if names.is_empty() {
43        return Err(RouterError::FailClosed(
44            "decision modelRefs not in eligible pool".into(),
45        ));
46    }
47    match algo {
48        "static" => Ok(names[0].clone()),
49        "latency-aware" | "latency_aware" => {
50            let best = names
51                .iter()
52                .min_by(|a, b| {
53                    let la = stats.latency_ms.get(*a).copied().unwrap_or(1000.0);
54                    let lb = stats.latency_ms.get(*b).copied().unwrap_or(1000.0);
55                    la.partial_cmp(&lb).unwrap_or(std::cmp::Ordering::Equal)
56                })
57                .cloned()
58                .unwrap();
59            Ok(best)
60        }
61        "multi-factor" | "multi_factor" => {
62            let best = names
63                .iter()
64                .min_by(|a, b| {
65                    let sa = score(a, stats);
66                    let sb = score(b, stats);
67                    sa.partial_cmp(&sb).unwrap_or(std::cmp::Ordering::Equal)
68                })
69                .cloned()
70                .unwrap();
71            Ok(best)
72        }
73        "elo" | "ratings" => {
74            let best = names
75                .iter()
76                .max_by(|a, b| {
77                    let ra = stats
78                        .elo
79                        .get(*a)
80                        .copied()
81                        .unwrap_or_else(|| global_elo().rating(a));
82                    let rb = stats
83                        .elo
84                        .get(*b)
85                        .copied()
86                        .unwrap_or_else(|| global_elo().rating(b));
87                    ra.partial_cmp(&rb).unwrap_or(std::cmp::Ordering::Equal)
88                })
89                .cloned()
90                .unwrap();
91            Ok(best)
92        }
93        other => Err(RouterError::Unsupported(format!("algorithm {other}"))),
94    }
95}
96
97fn score(name: &str, stats: &RuntimeStats) -> f32 {
98    let lat = stats.latency_ms.get(name).copied().unwrap_or(100.0);
99    let load = stats.load.get(name).copied().unwrap_or(0.0);
100    let cost = stats.cost.get(name).copied().unwrap_or(1.0);
101    lat * 0.5 + load * 20.0 + cost * 10.0
102}
103
104pub fn hard_filter(
105    doc: &RouterDocument,
106    names: &[String],
107    require_locality: Option<&str>,
108    require_modality: Option<&str>,
109) -> Vec<ModelCard> {
110    names
111        .iter()
112        .filter_map(|n| doc.provider(n))
113        .filter(|p| {
114            if let Some(loc) = require_locality {
115                if p.locality != loc {
116                    return false;
117                }
118            }
119            if let Some(mod_) = require_modality {
120                if p.modality != mod_ && p.modality != "any" {
121                    return false;
122                }
123            }
124            true
125        })
126        .map(|p| ModelCard {
127            name: p.name.clone(),
128            locality: p.locality.clone(),
129            modality: p.modality.clone(),
130            capabilities: p.capabilities.clone(),
131            provider_model_id: p.provider_model_id.clone(),
132            tier: p.tier.clone(),
133        })
134        .collect()
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    fn cards() -> Vec<ModelCard> {
142        vec![
143            ModelCard {
144                name: "a".into(),
145                locality: "local".into(),
146                modality: "text".into(),
147                capabilities: vec!["chat".into()],
148                provider_model_id: "a".into(),
149                tier: None,
150            },
151            ModelCard {
152                name: "b".into(),
153                locality: "local".into(),
154                modality: "text".into(),
155                capabilities: vec!["chat".into()],
156                provider_model_id: "b".into(),
157                tier: None,
158            },
159        ]
160    }
161
162    fn decision(algo: &str) -> DecisionCfg {
163        DecisionCfg {
164            name: "d".into(),
165            description: None,
166            priority: 1,
167            rules: Default::default(),
168            model_refs: vec![
169                aria_router_config::ModelRef { model: "a".into() },
170                aria_router_config::ModelRef { model: "b".into() },
171            ],
172            algorithm: Some(algo.into()),
173            plugins: vec![],
174            locality: None,
175            emits: vec![],
176        }
177    }
178
179    fn doc() -> RouterDocument {
180        RouterDocument::from_yaml_str(
181            r#"
182version: v0.3
183providers:
184  models:
185    - name: a
186      locality: local
187      backend_refs: [{name: p, endpoint: 127.0.0.1:1}]
188    - name: b
189      locality: local
190      backend_refs: [{name: p, endpoint: 127.0.0.1:2}]
191entrypoints:
192  - model_names: [auto]
193    router: semantic
194    recipe: r
195recipes:
196  - name: r
197    router: semantic
198    routing:
199      decisions:
200        - name: d
201          rules: { operator: AND, conditions: [] }
202          modelRefs: [{model: a}]
203"#,
204        )
205        .unwrap()
206    }
207
208    #[test]
209    fn static_first() {
210        let d = doc();
211        let got = select(&d, &decision("static"), &cards(), &RuntimeStats::default()).unwrap();
212        assert_eq!(got, "a");
213    }
214
215    #[test]
216    fn latency_aware_picks_faster() {
217        let d = doc();
218        let mut stats = RuntimeStats::default();
219        stats.latency_ms.insert("a".into(), 200.0);
220        stats.latency_ms.insert("b".into(), 10.0);
221        let got = select(&d, &decision("latency-aware"), &cards(), &stats).unwrap();
222        assert_eq!(got, "b");
223    }
224
225    #[test]
226    fn multi_factor_picks_cheaper() {
227        let d = doc();
228        let mut stats = RuntimeStats::default();
229        stats.cost.insert("a".into(), 9.0);
230        stats.cost.insert("b".into(), 1.0);
231        let got = select(&d, &decision("multi-factor"), &cards(), &stats).unwrap();
232        assert_eq!(got, "b");
233    }
234
235    #[test]
236    fn elo_picks_higher_rating() {
237        let d = doc();
238        let mut stats = RuntimeStats::default();
239        stats.elo.insert("a".into(), 900.0);
240        stats.elo.insert("b".into(), 1200.0);
241        let got = select(&d, &decision("elo"), &cards(), &stats).unwrap();
242        assert_eq!(got, "b");
243    }
244
245    #[test]
246    fn unimplemented_algorithm() {
247        let d = doc();
248        let err = select(&d, &decision("knn"), &cards(), &RuntimeStats::default()).unwrap_err();
249        assert!(matches!(err, RouterError::Unsupported(_)));
250    }
251}