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#[cfg(test)]
179mod tests {
180    use super::*;
181
182    fn config() -> ModelTierConfig {
183        ModelTierConfig {
184            speculator_model: "spec-1".into(),
185            verifier_model: "verif-1".into(),
186            actuator_model: "act-1".into(),
187            architect_model: "arch-1".into(),
188            explorer_model: None,
189            explorer_tier: None,
190        }
191    }
192
193    #[test]
194    fn explore_defaults_to_speculator() {
195        let route = resolve_route(AgentPhase::Explore, &config(), None, ModelBudget::default());
196        assert_eq!(route.resolved_tier, ModelTier::Speculator);
197        assert_eq!(route.model, "spec-1");
198    }
199
200    #[test]
201    fn explorer_model_wins_over_speculator() {
202        let mut config = config();
203        config.explorer_model = Some("cheap-explorer".into());
204        let route = resolve_route(AgentPhase::Explore, &config, None, ModelBudget::default());
205        assert_eq!(route.model, "cheap-explorer");
206        assert_eq!(route.reason, "explorer_model override");
207    }
208
209    #[test]
210    fn explorer_tier_reuses_existing_tier_model() {
211        let mut config = config();
212        config.explorer_tier = Some(ModelTier::Verifier);
213        let route = resolve_route(AgentPhase::Explore, &config, None, ModelBudget::default());
214        assert_eq!(route.resolved_tier, ModelTier::Verifier);
215        assert_eq!(route.model, "verif-1");
216    }
217
218    #[test]
219    fn plan_routes_to_architect() {
220        let route = resolve_route(AgentPhase::Plan, &config(), None, ModelBudget::default());
221        assert_eq!(route.resolved_tier, ModelTier::Architect);
222        assert_eq!(route.model, "arch-1");
223    }
224
225    #[test]
226    fn explicit_tier_override_beats_phase_default() {
227        let route = resolve_route(
228            AgentPhase::Implement,
229            &config(),
230            Some(ModelTier::Speculator),
231            ModelBudget::default(),
232        );
233        assert_eq!(route.resolved_tier, ModelTier::Speculator);
234    }
235
236    #[test]
237    fn uniform_sets_all_tiers() {
238        let config = ModelTierConfig::uniform("one-model");
239        for phase in [
240            AgentPhase::Explore,
241            AgentPhase::Plan,
242            AgentPhase::Implement,
243            AgentPhase::Verify,
244        ] {
245            let route = resolve_route(phase, &config, None, ModelBudget::default());
246            assert_eq!(route.model, "one-model");
247        }
248    }
249}