1use 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#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
16#[serde(default, deny_unknown_fields)]
17#[non_exhaustive]
18pub struct EffortOverrides {
19 pub tasks: ProfileChanges,
21 pub budget: Option<Budget>,
23 pub reply_budget: Option<Budget>,
25 pub settings: Option<Settings>,
27}
28
29#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
31#[serde(default, deny_unknown_fields)]
32#[non_exhaustive]
33pub struct EffortConfig {
34 pub default: Effort,
36 pub low: EffortOverrides,
38 pub medium: EffortOverrides,
40 pub high: EffortOverrides,
42}
43
44impl EffortOverrides {
45 #[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 #[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 #[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#[derive(Debug, Clone, PartialEq)]
82#[non_exhaustive]
83pub struct EffortProfile {
84 pub effort: Effort,
86 pub tasks: TaskProfiles,
88 pub budget: Budget,
90 pub reply_budget: Budget,
92 pub settings: Settings,
94 pub steps: bool,
96}
97
98const 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
112const REASONING_ROOM: u32 = 2_000;
114
115fn 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 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 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
160fn 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#[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 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}