Skip to main content

turnframe_runtime/
effort.rs

1//! The effort a turn runs at, resolved into the profiles, budgets and settings it runs
2//! under. `medium` with nothing configured is the configuration as it is.
3
4use serde::{Deserialize, Serialize};
5pub use turnframe_core::effort::Effort;
6use turnframe_provider::request::ReasoningEffort;
7use turnframe_tasks::{
8    Budget, Disagreement, ProfileChange, ProfileChanges, TaskKind, TaskProfiles,
9};
10use turnframe_understand::Settings;
11
12use crate::config::OrchestratorConfig;
13
14/// What a deployment changes about one level.
15#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
16#[serde(default, deny_unknown_fields)]
17#[non_exhaustive]
18pub struct EffortOverrides {
19    /// Task profile changes, over the level's own.
20    pub tasks: ProfileChanges,
21    /// The understanding budget, replacing the level's.
22    pub budget: Option<Budget>,
23    /// The reply budget, replacing the level's.
24    pub reply_budget: Option<Budget>,
25    /// The pipeline settings, replacing the level's.
26    pub settings: Option<Settings>,
27}
28
29/// The default level, and what a deployment changes about each.
30#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
31#[serde(default, deny_unknown_fields)]
32#[non_exhaustive]
33pub struct EffortConfig {
34    /// The level of a turn that does not force one.
35    pub default: Effort,
36    /// Changes to `low`.
37    pub low: EffortOverrides,
38    /// Changes to `medium`.
39    pub medium: EffortOverrides,
40    /// Changes to `high`.
41    pub high: EffortOverrides,
42}
43
44impl EffortOverrides {
45    /// No change to the level.
46    #[must_use]
47    pub const fn none() -> Self {
48        Self {
49            tasks: ProfileChanges::new(),
50            budget: None,
51            reply_budget: None,
52            settings: None,
53        }
54    }
55}
56
57impl EffortConfig {
58    /// `medium` by default, and every level as shipped.
59    #[must_use]
60    pub const fn conservative() -> Self {
61        Self {
62            default: Effort::Medium,
63            low: EffortOverrides::none(),
64            medium: EffortOverrides::none(),
65            high: EffortOverrides::none(),
66        }
67    }
68
69    /// What a deployment changes about `effort`.
70    #[must_use]
71    pub const fn overrides(&self, effort: Effort) -> &EffortOverrides {
72        match effort {
73            Effort::Low => &self.low,
74            Effort::High => &self.high,
75            _ => &self.medium,
76        }
77    }
78}
79
80/// One level, resolved: what a turn at that level runs under.
81#[derive(Debug, Clone, PartialEq)]
82#[non_exhaustive]
83pub struct EffortProfile {
84    /// The level.
85    pub effort: Effort,
86    /// Every task kind's profile.
87    pub tasks: TaskProfiles,
88    /// What understanding may spend.
89    pub budget: Budget,
90    /// What writing the reply may spend.
91    pub reply_budget: Budget,
92    /// How the understanding pipeline runs.
93    pub settings: Settings,
94    /// Whether step prose may be written, when narration asks for it.
95    pub steps: bool,
96}
97
98/// The tasks that read the message, which `high` gives some reasoning. Not `extract`: on a mini
99/// model, reasoning made it copy the words naming a field into the value.
100const READING: [TaskKind; 8] = [
101    TaskKind::Segment,
102    TaskKind::Coverage,
103    TaskKind::Route,
104    TaskKind::Locate,
105    TaskKind::Verify,
106    TaskKind::QuestionFrame,
107    TaskKind::CrossCheck,
108    TaskKind::Respects,
109];
110
111/// The output cap of a task that reasons: billed per token used, so room costs nothing.
112const REASONING_ROOM: u32 = 2_000;
113
114/// The level's own changes, before a deployment's.
115fn shipped(effort: Effort) -> ProfileChanges {
116    let mut changes = ProfileChanges::default();
117    match effort {
118        Effort::Low => {
119            let mut review = ProfileChange::default();
120            review.review = Some(false);
121            changes = changes.with(TaskKind::Acknowledge, review);
122        }
123        // How a message is split and routed decides every act after it: three readings,
124        // and a split vote read once more, shown the answers that disagreed.
125        Effort::Medium => {
126            for kind in [TaskKind::Segment, TaskKind::Route] {
127                let mut change = ProfileChange::default();
128                change.votes = Some(3);
129                change.on_disagreement = Some(Disagreement::Reread);
130                changes = changes.with(kind, change);
131            }
132        }
133        Effort::High => {
134            for kind in READING {
135                let mut change = ProfileChange::default();
136                change.reasoning_effort = Some(ReasoningEffort::Low);
137                // A provider counts reasoning against the output cap: room for both.
138                change.max_output_tokens = Some(REASONING_ROOM);
139                if matches!(kind, TaskKind::Segment | TaskKind::Route) {
140                    change.votes = Some(3);
141                    change.on_disagreement = Some(Disagreement::Reread);
142                }
143                if kind == TaskKind::Verify {
144                    change.votes = Some(3);
145                    change.on_disagreement = Some(Disagreement::Reread);
146                }
147                changes = changes.with(kind, change);
148            }
149            let mut review = ProfileChange::default();
150            review.reasoning_effort = Some(ReasoningEffort::Low);
151            review.max_output_tokens = Some(REASONING_ROOM);
152            changes = changes.with(TaskKind::Review, review);
153        }
154        _ => {}
155    }
156    changes
157}
158
159/// Three times the calls and tokens, four more steps of depth, twice the wall clock.
160fn scaled(budget: Budget) -> Budget {
161    let mut scaled = budget;
162    scaled.max_model_calls = budget.max_model_calls.map(|calls| calls.saturating_mul(3));
163    scaled.max_prompt_tokens = budget
164        .max_prompt_tokens
165        .map(|tokens| tokens.saturating_mul(3));
166    scaled.max_chain_depth = budget.max_chain_depth.map(|depth| depth.saturating_add(4));
167    scaled.max_wall_clock_secs = budget
168        .max_wall_clock_secs
169        .map(|secs| secs.saturating_mul(2));
170    scaled
171}
172
173/// What a turn at `effort` runs under.
174#[must_use]
175pub fn resolve(config: &OrchestratorConfig, effort: Effort) -> EffortProfile {
176    let base = &config.understanding;
177    let mut settings = base.settings;
178    let mut budget = base.budget;
179    let mut steps = true;
180    match effort {
181        Effort::Low => {
182            settings = settings.with_transcript(2);
183            steps = false;
184        }
185        // One verify vote per act: a verdict finding fault is voted on twice more.
186        Effort::Medium => settings = settings.with_doubt_votes(2),
187        Effort::High => {
188            settings = settings
189                .with_transcript(6)
190                .with_reread_small_talk(true)
191                .with_cross_check_rounds(2);
192            budget = scaled(budget);
193        }
194        _ => {}
195    }
196    let overrides = config.effort.overrides(effort);
197    let tasks = overrides
198        .tasks
199        .apply(shipped(effort).apply(base.tasks.clone()));
200    EffortProfile {
201        effort,
202        tasks,
203        budget: overrides.budget.unwrap_or(budget),
204        reply_budget: overrides.reply_budget.unwrap_or(config.narration.budget),
205        settings: overrides.settings.unwrap_or(settings),
206        steps,
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use crate::config::OrchestratorConfig;
214    use turnframe_provider::request::ReasoningEffort;
215    use turnframe_tasks::{Disagreement, TaskKind};
216
217    #[test]
218    fn medium_votes_on_how_a_message_is_split_and_routed() {
219        let config = OrchestratorConfig::conservative();
220        let medium = resolve(&config, Effort::Medium);
221        for kind in [TaskKind::Segment, TaskKind::Route] {
222            let profile = medium.tasks.get(kind);
223            assert_eq!(profile.votes, 3, "{kind:?}");
224            assert_eq!(profile.on_disagreement, Disagreement::Reread, "{kind:?}");
225        }
226        assert_eq!(
227            medium.tasks.get(TaskKind::Extract),
228            config.understanding.tasks.get(TaskKind::Extract)
229        );
230        assert_eq!(
231            resolve(&config, Effort::Low)
232                .tasks
233                .get(TaskKind::Segment)
234                .votes,
235            1
236        );
237    }
238
239    #[test]
240    fn medium_is_the_configuration_as_it_is_beside_its_votes() {
241        let config = OrchestratorConfig::conservative();
242        let medium = resolve(&config, Effort::Medium);
243        assert_eq!(medium.budget, config.understanding.budget);
244        assert_eq!(medium.reply_budget, config.narration.budget);
245        assert_eq!(
246            medium.settings,
247            config.understanding.settings.with_doubt_votes(2)
248        );
249        assert!(medium.steps);
250    }
251
252    #[test]
253    fn medium_votes_again_on_a_verdict_finding_fault() {
254        let config = OrchestratorConfig::conservative();
255        assert_eq!(resolve(&config, Effort::Medium).settings.doubt_votes, 2);
256        assert_eq!(resolve(&config, Effort::Low).settings.doubt_votes, 0);
257        assert_eq!(resolve(&config, Effort::High).settings.doubt_votes, 0);
258    }
259
260    #[test]
261    fn high_buys_votes_reasoning_and_the_whole_turn_check() {
262        let config = OrchestratorConfig::conservative();
263        let high = resolve(&config, Effort::High);
264        let segment = high.tasks.get(TaskKind::Segment);
265        assert_eq!(segment.votes, 3);
266        assert_eq!(segment.on_disagreement, Disagreement::Reread);
267        assert_eq!(segment.reasoning_effort, Some(ReasoningEffort::Low));
268        assert_eq!(high.tasks.get(TaskKind::Verify).votes, 3);
269        assert_eq!(
270            high.tasks.get(TaskKind::Verify).on_disagreement,
271            Disagreement::Reread
272        );
273        assert_eq!(high.settings.cross_check_rounds, 2);
274        assert!(high.settings.reread_small_talk);
275        assert_eq!(high.settings.transcript, 6);
276        assert_eq!(
277            high.budget.max_model_calls,
278            config
279                .understanding
280                .budget
281                .max_model_calls
282                .map(|calls| calls * 3)
283        );
284    }
285
286    #[test]
287    fn high_copies_values_as_medium_does() {
288        let config = OrchestratorConfig::conservative();
289        let high = resolve(&config, Effort::High);
290        let medium = resolve(&config, Effort::Medium);
291        assert_eq!(
292            high.tasks.get(TaskKind::Extract).reasoning_effort,
293            medium.tasks.get(TaskKind::Extract).reasoning_effort
294        );
295    }
296
297    #[test]
298    fn high_leaves_room_for_reasoning_in_every_answer() {
299        let high = resolve(&OrchestratorConfig::conservative(), Effort::High);
300        for kind in READING.into_iter().chain([TaskKind::Review]) {
301            assert!(
302                high.tasks.get(kind).max_output_tokens >= Some(2_000),
303                "{kind:?}: reasoning is counted against the cap"
304            );
305        }
306    }
307
308    #[test]
309    fn low_drops_the_review_and_the_step_prose_and_keeps_verification() {
310        let config = OrchestratorConfig::conservative();
311        let low = resolve(&config, Effort::Low);
312        assert!(!low.tasks.get(TaskKind::Acknowledge).review);
313        assert!(!low.steps);
314        assert_eq!(low.settings.verify, config.understanding.settings.verify);
315        assert_eq!(low.settings.transcript, 2);
316    }
317
318    #[test]
319    fn a_deployment_changes_a_level_field_by_field() {
320        let effort: EffortConfig = toml::from_str(
321            r#"
322            default = "high"
323            [high.tasks.locate]
324            model = "large"
325            "#,
326        )
327        .unwrap();
328        let mut config = OrchestratorConfig::conservative();
329        config.effort = effort;
330        let high = resolve(&config, config.effort.default);
331        let locate = high.tasks.get(TaskKind::Locate);
332        assert_eq!(locate.model.as_deref(), Some("large"));
333        assert_eq!(
334            locate.reasoning_effort,
335            Some(ReasoningEffort::Low),
336            "the level's own change stays"
337        );
338    }
339
340    #[test]
341    fn a_level_naming_a_task_that_does_not_exist_is_refused() {
342        let refused =
343            toml::from_str::<EffortConfig>("[high.tasks.extrakt]\nvotes = 3\n").unwrap_err();
344        assert!(refused.to_string().contains("extrakt"), "{refused}");
345    }
346}