Skip to main content

roder_core/
speed_policy.rs

1use roder_api::catalog::{
2    REASONING_HIGH, REASONING_LOW, REASONING_MEDIUM, REASONING_XHIGH,
3    model_supports_reasoning_effort,
4};
5use roder_api::inference::{
6    ReasoningConfig, RuntimeProfile, SpeedPolicyDecision, SpeedPolicyPhase,
7};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct RuntimeSpeedPolicyConfig {
11    pub enabled: bool,
12    pub orientation_reasoning: String,
13    pub execution_reasoning: String,
14    pub verification_reasoning: String,
15    pub recovery_reasoning: String,
16    pub ultracode_reasoning: String,
17}
18
19impl Default for RuntimeSpeedPolicyConfig {
20    fn default() -> Self {
21        Self {
22            enabled: true,
23            orientation_reasoning: REASONING_HIGH.to_string(),
24            execution_reasoning: REASONING_LOW.to_string(),
25            verification_reasoning: REASONING_HIGH.to_string(),
26            recovery_reasoning: REASONING_MEDIUM.to_string(),
27            ultracode_reasoning: REASONING_XHIGH.to_string(),
28        }
29    }
30}
31
32#[derive(Debug, Clone, Default)]
33pub(crate) struct SpeedPolicyState {
34    assistant_messages: u32,
35    tool_rounds: u32,
36    verification_required: bool,
37    failure_seen: bool,
38}
39
40impl SpeedPolicyState {
41    pub(crate) fn record_model_output(&mut self, assistant_message_seen: bool, tool_calls: usize) {
42        if assistant_message_seen {
43            self.assistant_messages = self.assistant_messages.saturating_add(1);
44        }
45        if tool_calls > 0 {
46            self.tool_rounds = self.tool_rounds.saturating_add(1);
47        }
48    }
49
50    pub(crate) fn record_verification_required(&mut self) {
51        self.verification_required = true;
52    }
53
54    pub(crate) fn record_failure(&mut self) {
55        self.failure_seen = true;
56    }
57
58    pub(crate) fn decision(
59        &self,
60        runtime_profile: RuntimeProfile,
61        model: &str,
62        config: &RuntimeSpeedPolicyConfig,
63    ) -> Option<SpeedPolicyDecision> {
64        if !config.enabled {
65            return None;
66        }
67        if runtime_profile != RuntimeProfile::Eval {
68            return None;
69        }
70
71        let phase = self.phase();
72        let desired_reasoning = config.reasoning_for_phase(phase).to_string();
73        let supported = model_supports_reasoning_effort(model, &desired_reasoning);
74        Some(SpeedPolicyDecision {
75            phase,
76            applied_reasoning: supported.then(|| desired_reasoning.clone()),
77            desired_reasoning,
78            supported,
79        })
80    }
81
82    pub(crate) fn phase(&self) -> SpeedPolicyPhase {
83        if self.failure_seen {
84            SpeedPolicyPhase::Recovery
85        } else if self.verification_required {
86            SpeedPolicyPhase::Verification
87        } else if self.assistant_messages == 0 && self.tool_rounds == 0 {
88            SpeedPolicyPhase::Orientation
89        } else {
90            SpeedPolicyPhase::Execution
91        }
92    }
93}
94
95impl RuntimeSpeedPolicyConfig {
96    fn reasoning_for_phase(&self, phase: SpeedPolicyPhase) -> &str {
97        match phase {
98            SpeedPolicyPhase::Orientation => &self.orientation_reasoning,
99            SpeedPolicyPhase::Execution => &self.execution_reasoning,
100            SpeedPolicyPhase::Verification => &self.verification_reasoning,
101            SpeedPolicyPhase::Recovery => &self.recovery_reasoning,
102        }
103    }
104}
105
106pub(crate) fn reasoning_from_decision(
107    decision: Option<&SpeedPolicyDecision>,
108    fallback: ReasoningConfig,
109) -> ReasoningConfig {
110    let Some(level) = decision.and_then(|decision| decision.applied_reasoning.as_deref()) else {
111        return fallback;
112    };
113    ReasoningConfig {
114        enabled: true,
115        level: Some(level.to_string()),
116    }
117}
118
119pub(crate) fn reasoning_for_supported_effort(
120    model: &str,
121    desired_reasoning: &str,
122    fallback: ReasoningConfig,
123) -> (ReasoningConfig, bool) {
124    if model_supports_reasoning_effort(model, desired_reasoning) {
125        (
126            ReasoningConfig {
127                enabled: true,
128                level: Some(desired_reasoning.to_string()),
129            },
130            true,
131        )
132    } else {
133        (fallback, false)
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use roder_api::catalog::{REASONING_NONE, REASONING_XHIGH};
141
142    #[test]
143    fn speed_policy_moves_high_to_low_to_high_and_recovery_medium() {
144        let mut state = SpeedPolicyState::default();
145        let config = RuntimeSpeedPolicyConfig::default();
146
147        let orientation = state
148            .decision(RuntimeProfile::Eval, "gpt-5.5", &config)
149            .unwrap();
150        assert_eq!(orientation.phase, SpeedPolicyPhase::Orientation);
151        assert_eq!(
152            orientation.applied_reasoning.as_deref(),
153            Some(REASONING_HIGH)
154        );
155
156        state.record_model_output(true, 1);
157        let execution = state
158            .decision(RuntimeProfile::Eval, "gpt-5.5", &config)
159            .unwrap();
160        assert_eq!(execution.phase, SpeedPolicyPhase::Execution);
161        assert_eq!(execution.applied_reasoning.as_deref(), Some(REASONING_LOW));
162
163        state.record_verification_required();
164        let verification = state
165            .decision(RuntimeProfile::Eval, "gpt-5.5", &config)
166            .unwrap();
167        assert_eq!(verification.phase, SpeedPolicyPhase::Verification);
168        assert_eq!(
169            verification.applied_reasoning.as_deref(),
170            Some(REASONING_HIGH)
171        );
172
173        state.record_failure();
174        let recovery = state
175            .decision(RuntimeProfile::Eval, "gpt-5.5", &config)
176            .unwrap();
177        assert_eq!(recovery.phase, SpeedPolicyPhase::Recovery);
178        assert_eq!(
179            recovery.applied_reasoning.as_deref(),
180            Some(REASONING_MEDIUM)
181        );
182    }
183
184    #[test]
185    fn unsupported_model_degrades_to_fallback_reasoning() {
186        let state = SpeedPolicyState::default();
187        let config = RuntimeSpeedPolicyConfig::default();
188        let decision = state
189            .decision(RuntimeProfile::Eval, "mock", &config)
190            .unwrap();
191        let fallback = ReasoningConfig {
192            enabled: true,
193            level: Some(REASONING_XHIGH.to_string()),
194        };
195
196        assert_eq!(decision.phase, SpeedPolicyPhase::Orientation);
197        assert_eq!(decision.desired_reasoning, REASONING_HIGH);
198        assert_eq!(decision.applied_reasoning, None);
199        assert!(!decision.supported);
200        assert_eq!(
201            reasoning_from_decision(Some(&decision), fallback.clone()),
202            fallback
203        );
204    }
205
206    #[test]
207    fn supported_effort_override_degrades_to_fallback_reasoning() {
208        let fallback = ReasoningConfig {
209            enabled: true,
210            level: Some(REASONING_MEDIUM.to_string()),
211        };
212
213        let (reasoning, supported) =
214            reasoning_for_supported_effort("mock", REASONING_XHIGH, fallback.clone());
215        assert!(!supported);
216        assert_eq!(reasoning, fallback);
217
218        let (reasoning, supported) =
219            reasoning_for_supported_effort("gpt-5.5", REASONING_XHIGH, fallback);
220        assert!(supported);
221        assert_eq!(reasoning.level.as_deref(), Some(REASONING_XHIGH));
222    }
223
224    #[test]
225    fn non_eval_profiles_do_not_apply_speed_policy() {
226        let state = SpeedPolicyState::default();
227        let config = RuntimeSpeedPolicyConfig::default();
228        assert_eq!(
229            state.decision(RuntimeProfile::Interactive, "gpt-5.5", &config),
230            None
231        );
232        assert_eq!(
233            reasoning_from_decision(
234                None,
235                ReasoningConfig {
236                    enabled: false,
237                    level: Some(REASONING_NONE.to_string())
238                }
239            ),
240            ReasoningConfig {
241                enabled: false,
242                level: Some(REASONING_NONE.to_string())
243            }
244        );
245    }
246}