Skip to main content

perspt_sdk/
routing.rs

1//! Phase-aware model routing (PSP-8 System 3).
2//!
3//! Exploration is read-only orientation and should use a low-cost model without
4//! weakening acceptance gates. Routing therefore resolves a [`ModelRoute`] per
5//! [`AgentPhase`]: an explicit `explorer_model` wins; otherwise exploration
6//! defaults to the cheapest tier (`Speculator`). `--model` sets all tiers unless
7//! a phase-specific override is provided.
8
9use serde::{Deserialize, Serialize};
10
11/// Model capability tiers, cheapest to most capable.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum ModelTier {
15    Speculator,
16    Verifier,
17    Actuator,
18    Architect,
19}
20
21/// Agent phases that request a model.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum AgentPhase {
25    Explore,
26    Plan,
27    Implement,
28    Verify,
29    Repair,
30    Review,
31    Research,
32}
33
34impl AgentPhase {
35    /// The default tier for a phase when no override is configured.
36    pub fn default_tier(self) -> ModelTier {
37        match self {
38            AgentPhase::Explore | AgentPhase::Research => ModelTier::Speculator,
39            AgentPhase::Verify | AgentPhase::Review => ModelTier::Verifier,
40            AgentPhase::Implement | AgentPhase::Repair => ModelTier::Actuator,
41            AgentPhase::Plan => ModelTier::Architect,
42        }
43    }
44}
45
46/// Per-route budget.
47#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
48pub struct ModelBudget {
49    pub max_tokens: u64,
50    pub max_calls: u32,
51    pub max_wall_clock_secs: u64,
52}
53
54impl Default for ModelBudget {
55    fn default() -> Self {
56        Self {
57            max_tokens: 100_000,
58            max_calls: 50,
59            max_wall_clock_secs: 600,
60        }
61    }
62}
63
64/// The configured models per tier, plus optional explorer overrides.
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub struct ModelTierConfig {
67    pub speculator_model: String,
68    pub verifier_model: String,
69    pub actuator_model: String,
70    pub architect_model: String,
71    /// Explicit exploration model override (`explorer_model` / `--explorer-model`).
72    pub explorer_model: Option<String>,
73    /// Reuse an existing tier for exploration (`--explorer-tier`).
74    pub explorer_tier: Option<ModelTier>,
75}
76
77impl ModelTierConfig {
78    /// Set every tier to a single model (`--model`).
79    pub fn uniform(model: impl Into<String>) -> Self {
80        let m = model.into();
81        Self {
82            speculator_model: m.clone(),
83            verifier_model: m.clone(),
84            actuator_model: m.clone(),
85            architect_model: m,
86            explorer_model: None,
87            explorer_tier: None,
88        }
89    }
90
91    pub fn model_for_tier(&self, tier: ModelTier) -> &str {
92        match tier {
93            ModelTier::Speculator => &self.speculator_model,
94            ModelTier::Verifier => &self.verifier_model,
95            ModelTier::Actuator => &self.actuator_model,
96            ModelTier::Architect => &self.architect_model,
97        }
98    }
99}
100
101/// A resolved model route for one phase (PSP-8 `ModelRoute`).
102#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
103pub struct ModelRoute {
104    pub phase: AgentPhase,
105    pub requested_tier: Option<ModelTier>,
106    pub resolved_tier: ModelTier,
107    pub model: String,
108    pub fallback_model: Option<String>,
109    pub budget: ModelBudget,
110    pub reason: String,
111}
112
113/// Resolve the model route for a phase.
114///
115/// Resolution order:
116/// 1. An explicit `requested_tier` is honored.
117/// 2. For `Explore`, an `explorer_model` override wins (tier = `explorer_tier`
118///    or `Speculator`).
119/// 3. Otherwise the phase's default tier applies.
120pub fn resolve_route(
121    phase: AgentPhase,
122    config: &ModelTierConfig,
123    requested_tier: Option<ModelTier>,
124    budget: ModelBudget,
125) -> ModelRoute {
126    // Explicit per-call tier override always wins.
127    if let Some(tier) = requested_tier {
128        return ModelRoute {
129            phase,
130            requested_tier,
131            resolved_tier: tier,
132            model: config.model_for_tier(tier).to_string(),
133            fallback_model: Some(config.speculator_model.clone()),
134            budget,
135            reason: "explicit tier override".into(),
136        };
137    }
138
139    // Exploration model override.
140    if phase == AgentPhase::Explore {
141        if let Some(model) = &config.explorer_model {
142            let tier = config.explorer_tier.unwrap_or(ModelTier::Speculator);
143            return ModelRoute {
144                phase,
145                requested_tier: None,
146                resolved_tier: tier,
147                model: model.clone(),
148                fallback_model: Some(config.speculator_model.clone()),
149                budget,
150                reason: "explorer_model override".into(),
151            };
152        }
153        if let Some(tier) = config.explorer_tier {
154            return ModelRoute {
155                phase,
156                requested_tier: None,
157                resolved_tier: tier,
158                model: config.model_for_tier(tier).to_string(),
159                fallback_model: Some(config.speculator_model.clone()),
160                budget,
161                reason: "explorer_tier override".into(),
162            };
163        }
164    }
165
166    let tier = phase.default_tier();
167    ModelRoute {
168        phase,
169        requested_tier: None,
170        resolved_tier: tier,
171        model: config.model_for_tier(tier).to_string(),
172        fallback_model: Some(config.speculator_model.clone()),
173        budget,
174        reason: "phase default tier".into(),
175    }
176}
177
178// ---------------------------------------------------------------------------
179// PSP-9 system 3: portfolio routing over fully qualified ModelIds
180// ---------------------------------------------------------------------------
181
182use crate::model::{ModelFamily, ModelId, ProviderCapabilities, ProviderCapabilityMask};
183
184/// What a route resolution optimizes (PSP-9 system 3).
185#[derive(Debug, Clone, Default, PartialEq)]
186pub struct RouteObjective {
187    /// Hard capability requirements for the route.
188    pub require: ProviderCapabilityMask,
189    /// Prefer a family decorrelated from this route's family (system 8's
190    /// review preference). Family is a routing prior, never a measurement.
191    pub decorrelate_from: Option<ModelId>,
192}
193
194/// The per-tier fully qualified routes from the `[models]` table.
195#[derive(Debug, Clone, Default, PartialEq)]
196pub struct PortfolioModels {
197    pub architect: Option<ModelId>,
198    pub actuator: Option<ModelId>,
199    pub verifier: Option<ModelId>,
200    pub speculator: Option<ModelId>,
201    pub adjudicator: Option<ModelId>,
202}
203
204impl PortfolioModels {
205    fn for_tier(&self, tier: ModelTier) -> Option<&ModelId> {
206        match tier {
207            ModelTier::Speculator => self.speculator.as_ref(),
208            ModelTier::Verifier => self.verifier.as_ref(),
209            ModelTier::Actuator => self.actuator.as_ref(),
210            ModelTier::Architect => self.architect.as_ref(),
211        }
212    }
213
214    fn all(&self) -> Vec<&ModelId> {
215        [
216            self.speculator.as_ref(),
217            self.verifier.as_ref(),
218            self.actuator.as_ref(),
219            self.architect.as_ref(),
220        ]
221        .into_iter()
222        .flatten()
223        .collect()
224    }
225}
226
227/// A resolved portfolio route: fully qualified, with a failover chain.
228///
229/// Routing is *sticky within a node* by default — cross-vendor moves happen
230/// at node and role boundaries, not per turn, because switching vendors
231/// mid-node discards the prompt cache. Failover to `fallback` is recovery
232/// level 1, not a retry.
233#[derive(Debug, Clone, PartialEq)]
234pub struct PortfolioRoute {
235    pub phase: AgentPhase,
236    pub resolved_tier: ModelTier,
237    pub model: ModelId,
238    /// Failover chain, preferentially on *different providers*.
239    pub fallback: Vec<ModelId>,
240    pub budget: ModelBudget,
241    pub reason: String,
242}
243
244/// Resolve a portfolio route for a phase (PSP-9 system 3).
245///
246/// `caps_of` reports each candidate route's capability record; a route that
247/// fails `objective.require` is ineligible. `family_of` classifies by model
248/// name. Routing policy: Explore/Research take the cheapest capable route;
249/// Verify/Review prefer a family different from `decorrelate_from` whenever
250/// the portfolio permits; Implement/Repair take the strongest actuator.
251pub fn resolve_portfolio_route(
252    phase: AgentPhase,
253    models: &PortfolioModels,
254    objective: &RouteObjective,
255    budget: ModelBudget,
256    caps_of: &dyn Fn(&ModelId) -> ProviderCapabilities,
257    family_of: &dyn Fn(&ModelId) -> ModelFamily,
258) -> Option<PortfolioRoute> {
259    let tier = phase.default_tier();
260    let capable = |m: &&ModelId| caps_of(m).satisfies(&objective.require);
261
262    let primary: &ModelId = match phase {
263        AgentPhase::Verify | AgentPhase::Review => {
264            // Prefer a decorrelated family among capable routes.
265            let preferred = objective.decorrelate_from.as_ref().map(family_of);
266            let candidates: Vec<&ModelId> = models.all().into_iter().filter(capable).collect();
267            let decorrelated = preferred.as_ref().and_then(|from| {
268                candidates
269                    .iter()
270                    .find(|m| family_of(m).is_distinct_from(from))
271                    .copied()
272            });
273            decorrelated
274                .or_else(|| models.for_tier(tier).filter(|m| capable(m)))
275                .or_else(|| candidates.first().copied())?
276        }
277        _ => models
278            .for_tier(tier)
279            .filter(|m| capable(m))
280            .or_else(|| models.all().into_iter().find(capable))?,
281    };
282
283    // Failover chain: every other capable route, different providers first.
284    let mut fallback: Vec<ModelId> = models
285        .all()
286        .into_iter()
287        .filter(capable)
288        .filter(|m| *m != primary)
289        .cloned()
290        .collect();
291    fallback.sort_by_key(|m| (m.provider == primary.provider, m.to_string()));
292    fallback.dedup();
293
294    Some(PortfolioRoute {
295        phase,
296        resolved_tier: tier,
297        model: primary.clone(),
298        fallback,
299        budget,
300        reason: format!("{phase:?} default tier {tier:?} under objective"),
301    })
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    fn config() -> ModelTierConfig {
309        ModelTierConfig {
310            speculator_model: "spec-1".into(),
311            verifier_model: "verif-1".into(),
312            actuator_model: "act-1".into(),
313            architect_model: "arch-1".into(),
314            explorer_model: None,
315            explorer_tier: None,
316        }
317    }
318
319    #[test]
320    fn explore_defaults_to_speculator() {
321        let route = resolve_route(AgentPhase::Explore, &config(), None, ModelBudget::default());
322        assert_eq!(route.resolved_tier, ModelTier::Speculator);
323        assert_eq!(route.model, "spec-1");
324    }
325
326    #[test]
327    fn explorer_model_wins_over_speculator() {
328        let mut config = config();
329        config.explorer_model = Some("cheap-explorer".into());
330        let route = resolve_route(AgentPhase::Explore, &config, None, ModelBudget::default());
331        assert_eq!(route.model, "cheap-explorer");
332        assert_eq!(route.reason, "explorer_model override");
333    }
334
335    #[test]
336    fn explorer_tier_reuses_existing_tier_model() {
337        let mut config = config();
338        config.explorer_tier = Some(ModelTier::Verifier);
339        let route = resolve_route(AgentPhase::Explore, &config, None, ModelBudget::default());
340        assert_eq!(route.resolved_tier, ModelTier::Verifier);
341        assert_eq!(route.model, "verif-1");
342    }
343
344    #[test]
345    fn plan_routes_to_architect() {
346        let route = resolve_route(AgentPhase::Plan, &config(), None, ModelBudget::default());
347        assert_eq!(route.resolved_tier, ModelTier::Architect);
348        assert_eq!(route.model, "arch-1");
349    }
350
351    #[test]
352    fn explicit_tier_override_beats_phase_default() {
353        let route = resolve_route(
354            AgentPhase::Implement,
355            &config(),
356            Some(ModelTier::Speculator),
357            ModelBudget::default(),
358        );
359        assert_eq!(route.resolved_tier, ModelTier::Speculator);
360    }
361
362    #[test]
363    fn uniform_sets_all_tiers() {
364        let config = ModelTierConfig::uniform("one-model");
365        for phase in [
366            AgentPhase::Explore,
367            AgentPhase::Plan,
368            AgentPhase::Implement,
369            AgentPhase::Verify,
370        ] {
371            let route = resolve_route(phase, &config, None, ModelBudget::default());
372            assert_eq!(route.model, "one-model");
373        }
374    }
375
376    fn qualified(models: &[(&str, &str)]) -> PortfolioModels {
377        let id = |i: usize| ModelId::new(models[i].0, models[i].1);
378        PortfolioModels {
379            architect: Some(id(0)),
380            actuator: Some(id(1)),
381            verifier: Some(id(2)),
382            speculator: Some(id(3)),
383            adjudicator: None,
384        }
385    }
386
387    fn full_caps(_m: &ModelId) -> crate::model::ProviderCapabilities {
388        crate::model::ProviderCapabilities {
389            tool_calling: true,
390            strict_schema: false,
391            parallel_tool_calls: true,
392            streaming_tool_calls: true,
393            prompt_caching: false,
394            structured_output: true,
395            max_context_tokens: 200_000,
396        }
397    }
398
399    fn by_name(m: &ModelId) -> crate::model::ModelFamily {
400        crate::model::ModelFamily::from_model_name(&m.model)
401    }
402
403    #[test]
404    fn review_prefers_a_decorrelated_family_when_the_portfolio_permits() {
405        let models = qualified(&[
406            ("anthropic", "claude-opus-5"),
407            ("openai", "gpt-5.5"),
408            ("anthropic", "claude-sonnet-5"),
409            ("local", "qwen2.5-coder"),
410        ]);
411        let objective = RouteObjective {
412            decorrelate_from: Some(ModelId::new("openai", "gpt-5.5")),
413            ..RouteObjective::default()
414        };
415        let route = resolve_portfolio_route(
416            AgentPhase::Review,
417            &models,
418            &objective,
419            ModelBudget::default(),
420            &full_caps,
421            &by_name,
422        )
423        .unwrap();
424        assert!(
425            by_name(&route.model).is_distinct_from(&crate::model::ModelFamily::OpenAiGpt),
426            "review route {} shares the actuator family",
427            route.model
428        );
429    }
430
431    #[test]
432    fn failover_chain_prefers_a_different_provider_first() {
433        let models = qualified(&[
434            ("anthropic", "claude-opus-5"),
435            ("openai", "gpt-5.5"),
436            ("anthropic", "claude-sonnet-5"),
437            ("local", "qwen2.5-coder"),
438        ]);
439        let route = resolve_portfolio_route(
440            AgentPhase::Implement,
441            &models,
442            &RouteObjective::default(),
443            ModelBudget::default(),
444            &full_caps,
445            &by_name,
446        )
447        .unwrap();
448        assert_eq!(route.model, ModelId::new("openai", "gpt-5.5"));
449        let first = route.fallback.first().expect("has fallback");
450        assert_ne!(
451            first.provider, route.model.provider,
452            "failover crosses providers first"
453        );
454    }
455
456    #[test]
457    fn a_capability_requirement_excludes_incapable_routes() {
458        let models = qualified(&[
459            ("anthropic", "claude-opus-5"),
460            ("openai", "gpt-5.5"),
461            ("anthropic", "claude-sonnet-5"),
462            ("local", "tiny-text-model"),
463        ]);
464        let text_only = |m: &ModelId| {
465            if m.provider == "local" {
466                crate::model::ProviderCapabilities::text_only(8_192)
467            } else {
468                full_caps(m)
469            }
470        };
471        let objective = RouteObjective {
472            require: crate::model::ProviderCapabilityMask::tool_loop(),
473            ..RouteObjective::default()
474        };
475        // Explore defaults to the speculator, but the local route lacks tool
476        // calling: routing selects another capable route instead.
477        let route = resolve_portfolio_route(
478            AgentPhase::Explore,
479            &models,
480            &objective,
481            ModelBudget::default(),
482            &text_only,
483            &by_name,
484        )
485        .unwrap();
486        assert_ne!(route.model.provider, "local");
487    }
488}