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; 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
111const REASONING_ROOM: u32 = 2_000;
113
114fn 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 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 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
159fn 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#[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 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}