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; 9] = [
101    TaskKind::Segment,
102    TaskKind::Coverage,
103    TaskKind::TakeUp,
104    TaskKind::Route,
105    TaskKind::Locate,
106    TaskKind::Verify,
107    TaskKind::QuestionFrame,
108    TaskKind::CrossCheck,
109    TaskKind::Respects,
110];
111
112/// The output cap of a task that reasons: billed per token used, so room costs nothing.
113const REASONING_ROOM: u32 = 2_000;
114
115/// The level's own changes, before a deployment's.
116fn shipped(effort: Effort) -> ProfileChanges {
117    let mut changes = ProfileChanges::default();
118    match effort {
119        Effort::Low => {
120            let mut review = ProfileChange::default();
121            review.review = Some(false);
122            changes = changes.with(TaskKind::Acknowledge, review);
123        }
124        // How a message is split and routed decides every act after it: three readings,
125        // and a split vote read once more, shown the answers that disagreed.
126        Effort::Medium => {
127            for kind in [TaskKind::Segment, TaskKind::Route] {
128                let mut change = ProfileChange::default();
129                change.votes = Some(3);
130                change.on_disagreement = Some(Disagreement::Reread);
131                changes = changes.with(kind, change);
132            }
133        }
134        Effort::High => {
135            for kind in READING {
136                let mut change = ProfileChange::default();
137                change.reasoning_effort = Some(ReasoningEffort::Low);
138                // A provider counts reasoning against the output cap: room for both.
139                change.max_output_tokens = Some(REASONING_ROOM);
140                if matches!(kind, TaskKind::Segment | TaskKind::Route) {
141                    change.votes = Some(3);
142                    change.on_disagreement = Some(Disagreement::Reread);
143                }
144                if kind == TaskKind::Verify {
145                    change.votes = Some(3);
146                    change.on_disagreement = Some(Disagreement::Reread);
147                }
148                changes = changes.with(kind, change);
149            }
150            let mut review = ProfileChange::default();
151            review.reasoning_effort = Some(ReasoningEffort::Low);
152            review.max_output_tokens = Some(REASONING_ROOM);
153            changes = changes.with(TaskKind::Review, review);
154        }
155        _ => {}
156    }
157    changes
158}
159
160/// Three times the calls and tokens, four more steps of depth, twice the wall clock.
161fn scaled(budget: Budget) -> Budget {
162    let mut scaled = budget;
163    scaled.max_model_calls = budget.max_model_calls.map(|calls| calls.saturating_mul(3));
164    scaled.max_prompt_tokens = budget
165        .max_prompt_tokens
166        .map(|tokens| tokens.saturating_mul(3));
167    scaled.max_chain_depth = budget.max_chain_depth.map(|depth| depth.saturating_add(4));
168    scaled.max_wall_clock_secs = budget
169        .max_wall_clock_secs
170        .map(|secs| secs.saturating_mul(2));
171    scaled
172}
173
174/// What a turn at `effort` runs under.
175#[must_use]
176pub fn resolve(config: &OrchestratorConfig, effort: Effort) -> EffortProfile {
177    let base = &config.understanding;
178    let mut settings = base.settings;
179    let mut budget = base.budget;
180    let mut steps = true;
181    match effort {
182        Effort::Low => {
183            settings = settings.with_transcript(2);
184            steps = false;
185        }
186        // One verify vote per act: a verdict finding fault is voted on twice more.
187        Effort::Medium => settings = settings.with_doubt_votes(2),
188        Effort::High => {
189            settings = settings
190                .with_transcript(6)
191                .with_reread_small_talk(true)
192                .with_cross_check_rounds(2);
193            budget = scaled(budget);
194        }
195        _ => {}
196    }
197    let overrides = config.effort.overrides(effort);
198    let tasks = overrides
199        .tasks
200        .apply(shipped(effort).apply(base.tasks.clone()));
201    EffortProfile {
202        effort,
203        tasks,
204        budget: overrides.budget.unwrap_or(budget),
205        reply_budget: overrides.reply_budget.unwrap_or(config.narration.budget),
206        settings: overrides.settings.unwrap_or(settings),
207        steps,
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::config::OrchestratorConfig;
215    use turnframe_provider::request::ReasoningEffort;
216    use turnframe_tasks::{Disagreement, TaskKind};
217
218    #[test]
219    fn medium_votes_on_how_a_message_is_split_and_routed() {
220        let config = OrchestratorConfig::conservative();
221        let medium = resolve(&config, Effort::Medium);
222        for kind in [TaskKind::Segment, TaskKind::Route] {
223            let profile = medium.tasks.get(kind);
224            assert_eq!(profile.votes, 3, "{kind:?}");
225            assert_eq!(profile.on_disagreement, Disagreement::Reread, "{kind:?}");
226        }
227        assert_eq!(
228            medium.tasks.get(TaskKind::Extract),
229            config.understanding.tasks.get(TaskKind::Extract)
230        );
231        assert_eq!(
232            resolve(&config, Effort::Low)
233                .tasks
234                .get(TaskKind::Segment)
235                .votes,
236            1
237        );
238    }
239
240    #[test]
241    fn medium_is_the_configuration_as_it_is_beside_its_votes() {
242        let config = OrchestratorConfig::conservative();
243        let medium = resolve(&config, Effort::Medium);
244        assert_eq!(medium.budget, config.understanding.budget);
245        assert_eq!(medium.reply_budget, config.narration.budget);
246        assert_eq!(
247            medium.settings,
248            config.understanding.settings.with_doubt_votes(2)
249        );
250        assert!(medium.steps);
251    }
252
253    #[test]
254    fn medium_votes_again_on_a_verdict_finding_fault() {
255        let config = OrchestratorConfig::conservative();
256        assert_eq!(resolve(&config, Effort::Medium).settings.doubt_votes, 2);
257        assert_eq!(resolve(&config, Effort::Low).settings.doubt_votes, 0);
258        assert_eq!(resolve(&config, Effort::High).settings.doubt_votes, 0);
259    }
260
261    #[test]
262    fn high_buys_votes_reasoning_and_the_whole_turn_check() {
263        let config = OrchestratorConfig::conservative();
264        let high = resolve(&config, Effort::High);
265        let segment = high.tasks.get(TaskKind::Segment);
266        assert_eq!(segment.votes, 3);
267        assert_eq!(segment.on_disagreement, Disagreement::Reread);
268        assert_eq!(segment.reasoning_effort, Some(ReasoningEffort::Low));
269        assert_eq!(high.tasks.get(TaskKind::Verify).votes, 3);
270        assert_eq!(
271            high.tasks.get(TaskKind::Verify).on_disagreement,
272            Disagreement::Reread
273        );
274        assert_eq!(high.settings.cross_check_rounds, 2);
275        assert!(high.settings.reread_small_talk);
276        assert_eq!(high.settings.transcript, 6);
277        assert_eq!(
278            high.budget.max_model_calls,
279            config
280                .understanding
281                .budget
282                .max_model_calls
283                .map(|calls| calls * 3)
284        );
285    }
286
287    #[test]
288    fn high_copies_values_as_medium_does() {
289        let config = OrchestratorConfig::conservative();
290        let high = resolve(&config, Effort::High);
291        let medium = resolve(&config, Effort::Medium);
292        assert_eq!(
293            high.tasks.get(TaskKind::Extract).reasoning_effort,
294            medium.tasks.get(TaskKind::Extract).reasoning_effort
295        );
296    }
297
298    #[test]
299    fn high_leaves_room_for_reasoning_in_every_answer() {
300        let high = resolve(&OrchestratorConfig::conservative(), Effort::High);
301        for kind in READING.into_iter().chain([TaskKind::Review]) {
302            assert!(
303                high.tasks.get(kind).max_output_tokens >= Some(2_000),
304                "{kind:?}: reasoning is counted against the cap"
305            );
306        }
307    }
308
309    #[test]
310    fn low_drops_the_review_and_the_step_prose_and_keeps_verification() {
311        let config = OrchestratorConfig::conservative();
312        let low = resolve(&config, Effort::Low);
313        assert!(!low.tasks.get(TaskKind::Acknowledge).review);
314        assert!(!low.steps);
315        assert_eq!(low.settings.verify, config.understanding.settings.verify);
316        assert_eq!(low.settings.transcript, 2);
317    }
318
319    #[test]
320    fn a_deployment_changes_a_level_field_by_field() {
321        let effort: EffortConfig = toml::from_str(
322            r#"
323            default = "high"
324            [high.tasks.locate]
325            model = "large"
326            "#,
327        )
328        .unwrap();
329        let mut config = OrchestratorConfig::conservative();
330        config.effort = effort;
331        let high = resolve(&config, config.effort.default);
332        let locate = high.tasks.get(TaskKind::Locate);
333        assert_eq!(locate.model.as_deref(), Some("large"));
334        assert_eq!(
335            locate.reasoning_effort,
336            Some(ReasoningEffort::Low),
337            "the level's own change stays"
338        );
339    }
340
341    #[test]
342    fn a_level_naming_a_task_that_does_not_exist_is_refused() {
343        let refused =
344            toml::from_str::<EffortConfig>("[high.tasks.extrakt]\nvotes = 3\n").unwrap_err();
345        assert!(refused.to_string().contains("extrakt"), "{refused}");
346    }
347}