1use futures::StreamExt;
23
24use crate::provider::{Brain, BrainEvent, BrainRequest, ContentBlock, Msg, PromptCacheConfig};
25use crate::router::TaskTier;
26
27const CRITIC_SYSTEM: &str = "You are a ruthless senior reviewer. Find the single most important flaw in the answer below: a wrong claim, a missing case, an unproven assertion, a violated constraint, or an unclear step. Be concrete and specific. If — and only if — the answer is genuinely correct, complete, and well-justified, reply with exactly the word APPROVED and nothing else.";
28
29const JUDGE_SYSTEM: &str = "You are an impartial judge. You will see a task and several candidate answers. Pick the single best one: most correct, complete, and well-justified. Reply with ONLY its index number, nothing else.";
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct ReasoningBudget {
35 pub samples: usize,
37 pub refine_rounds: usize,
39}
40
41impl ReasoningBudget {
42 pub fn for_tier(tier: &TaskTier) -> Self {
45 match tier {
46 TaskTier::Trivial | TaskTier::Small => Self {
47 samples: 1,
48 refine_rounds: 0,
49 },
50 TaskTier::Medium | TaskTier::Vision => Self {
51 samples: 1,
52 refine_rounds: 1,
53 },
54 TaskTier::Hard => Self {
55 samples: 3,
56 refine_rounds: 2,
57 },
58 }
59 }
60
61 pub fn scaled_for_capability(self, band: u8) -> Self {
66 match band {
67 0..=2 => Self {
68 samples: (self.samples + 2).min(6),
69 refine_rounds: (self.refine_rounds + 1).min(4),
70 },
71 3 => Self {
72 samples: (self.samples + 1).min(6),
73 refine_rounds: self.refine_rounds,
74 },
75 _ => self,
77 }
78 }
79
80 pub fn max_calls(&self) -> usize {
82 let draft_calls = self.samples + if self.samples > 1 { 1 } else { 0 };
83 draft_calls + self.refine_rounds * 2
84 }
85}
86
87fn user_req(system: &str, user: &str, temperature: f32, max_tokens: u32) -> BrainRequest {
88 BrainRequest {
89 system: Some(system.to_string()),
90 messages: vec![Msg {
91 role: "user".into(),
92 content: vec![ContentBlock::Text {
93 text: user.to_string(),
94 }],
95 }],
96 tools: vec![],
97 max_tokens,
98 temperature,
99 stop: vec![],
100 cache: PromptCacheConfig::disabled(),
101 }
102}
103
104pub async fn collect_text(brain: &dyn Brain, req: BrainRequest) -> Option<String> {
106 let mut stream = brain.complete(req).await.ok()?;
107 let mut out = String::new();
108 while let Some(ev) = stream.next().await {
109 match ev {
110 BrainEvent::TextDelta(t) => out.push_str(&t),
111 BrainEvent::Done(_) => break,
112 BrainEvent::Error(_) => return None,
113 _ => {}
114 }
115 }
116 Some(out)
117}
118
119fn is_approved(critique: &str) -> bool {
120 let t = critique.trim().to_ascii_uppercase();
121 t == "APPROVED" || t.starts_with("APPROVED")
122}
123
124fn parse_index(s: &str, n: usize) -> usize {
126 let digits: String = s
127 .chars()
128 .skip_while(|c| !c.is_ascii_digit())
129 .take_while(|c| c.is_ascii_digit())
130 .collect();
131 digits.parse::<usize>().ok().filter(|&i| i < n).unwrap_or(0)
132}
133
134pub async fn select_best(brain: &dyn Brain, task: &str, candidates: &[String]) -> usize {
137 if candidates.len() <= 1 {
138 return 0;
139 }
140 let mut listing = String::new();
141 for (i, c) in candidates.iter().enumerate() {
142 listing.push_str(&format!("[{i}]\n{}\n---\n", c.trim()));
143 }
144 let prompt = format!(
145 "Task:\n{task}\n\nCandidate answers:\n{listing}\nReply with ONLY the index number of the single best candidate."
146 );
147 match collect_text(brain, user_req(JUDGE_SYSTEM, &prompt, 0.0, 8)).await {
148 Some(out) => parse_index(&out, candidates.len()),
149 None => 0,
150 }
151}
152
153pub async fn best_of_n(brain: &dyn Brain, system: &str, task: &str, n: usize) -> Option<String> {
156 let n = n.max(1);
157 let mut candidates = Vec::with_capacity(n);
158 for i in 0..n {
159 let temperature = if n == 1 { 0.0 } else { 0.2 + 0.2 * i as f32 };
160 if let Some(c) = collect_text(brain, user_req(system, task, temperature, 2048)).await {
161 if !c.trim().is_empty() {
162 candidates.push(c);
163 }
164 }
165 }
166 match candidates.len() {
167 0 => None,
168 1 => candidates.pop(),
169 _ => {
170 let idx = select_best(brain, task, &candidates).await;
171 candidates.into_iter().nth(idx)
172 }
173 }
174}
175
176pub async fn self_refine_from(
179 brain: &dyn Brain,
180 system: &str,
181 task: &str,
182 mut answer: String,
183 rounds: usize,
184) -> Option<String> {
185 for _ in 0..rounds {
186 let critique_prompt =
187 format!("Task:\n{task}\n\nAnswer to review:\n{answer}\n\nYour critique:");
188 let critique =
189 collect_text(brain, user_req(CRITIC_SYSTEM, &critique_prompt, 0.0, 512)).await?;
190 if is_approved(&critique) {
191 break;
192 }
193 let revise_prompt = format!(
194 "Task:\n{task}\n\nYour previous answer:\n{answer}\n\nA reviewer raised this issue:\n{critique}\n\nProduce an improved answer that fully fixes it. Output only the improved answer.",
195 );
196 answer = collect_text(brain, user_req(system, &revise_prompt, 0.2, 2048)).await?;
197 }
198 Some(answer)
199}
200
201pub async fn reason_max(
204 brain: &dyn Brain,
205 system: &str,
206 task: &str,
207 budget: ReasoningBudget,
208) -> Option<String> {
209 let draft = if budget.samples > 1 {
210 best_of_n(brain, system, task, budget.samples).await?
211 } else {
212 collect_text(brain, user_req(system, task, 0.2, 2048)).await?
213 };
214 if budget.refine_rounds == 0 {
215 return Some(draft);
216 }
217 self_refine_from(brain, system, task, draft, budget.refine_rounds).await
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223 use crate::event::StopReason;
224 use crate::provider::{BrainStream, ModelCaps};
225 use std::collections::VecDeque;
226 use std::sync::Mutex;
227 use std::sync::atomic::{AtomicUsize, Ordering};
228
229 struct ScriptBrain {
232 responses: Mutex<VecDeque<String>>,
233 calls: AtomicUsize,
234 }
235 impl ScriptBrain {
236 fn new(responses: &[&str]) -> Self {
237 Self {
238 responses: Mutex::new(responses.iter().map(|s| s.to_string()).collect()),
239 calls: AtomicUsize::new(0),
240 }
241 }
242 fn calls(&self) -> usize {
243 self.calls.load(Ordering::SeqCst)
244 }
245 }
246 #[async_trait::async_trait]
247 impl Brain for ScriptBrain {
248 fn id(&self) -> &str {
249 "mock:script"
250 }
251 fn caps(&self) -> ModelCaps {
252 ModelCaps::default()
253 }
254 async fn complete(&self, _req: BrainRequest) -> anyhow::Result<BrainStream> {
255 self.calls.fetch_add(1, Ordering::SeqCst);
256 let resp = self
257 .responses
258 .lock()
259 .unwrap()
260 .pop_front()
261 .unwrap_or_else(|| "DEFAULT".into());
262 let events = vec![
263 BrainEvent::TextDelta(resp),
264 BrainEvent::Done(StopReason::EndTurn),
265 ];
266 Ok(Box::pin(futures::stream::iter(events)))
267 }
268 }
269
270 #[test]
271 fn budget_scales_with_tier_and_capability() {
272 assert_eq!(
273 ReasoningBudget::for_tier(&TaskTier::Trivial),
274 ReasoningBudget {
275 samples: 1,
276 refine_rounds: 0
277 }
278 );
279 let hard = ReasoningBudget::for_tier(&TaskTier::Hard);
280 assert_eq!(
281 hard,
282 ReasoningBudget {
283 samples: 3,
284 refine_rounds: 2
285 }
286 );
287 assert!(hard.scaled_for_capability(1).max_calls() > hard.max_calls());
289 assert_eq!(hard.scaled_for_capability(5), hard);
290 }
291
292 #[test]
293 fn parse_index_is_robust() {
294 assert_eq!(parse_index("2", 3), 2);
295 assert_eq!(parse_index("The best is [1].", 3), 1);
296 assert_eq!(parse_index("99", 3), 0); assert_eq!(parse_index("none", 3), 0);
298 }
299
300 #[tokio::test]
301 async fn self_refine_revises_then_stops_on_approval() {
302 let b = ScriptBrain::new(&["ISSUE: missing empty-input case", "answer v2", "APPROVED"]);
304 let out = self_refine_from(&b, "sys", "task", "answer v1".into(), 3)
305 .await
306 .unwrap();
307 assert_eq!(out, "answer v2");
308 assert_eq!(b.calls(), 3, "critique + revise + critique");
309 }
310
311 #[tokio::test]
312 async fn self_refine_stops_immediately_when_approved() {
313 let b = ScriptBrain::new(&["APPROVED"]);
314 let out = self_refine_from(&b, "sys", "task", "good answer".into(), 3)
315 .await
316 .unwrap();
317 assert_eq!(out, "good answer", "unchanged");
318 assert_eq!(b.calls(), 1, "only the critique");
319 }
320
321 #[tokio::test]
322 async fn best_of_n_samples_then_judges() {
323 let b = ScriptBrain::new(&["cand A", "cand B", "cand C", "1"]);
325 let out = best_of_n(&b, "sys", "task", 3).await.unwrap();
326 assert_eq!(out, "cand B");
327 assert_eq!(b.calls(), 4, "3 drafts + 1 judge");
328 }
329
330 #[tokio::test]
331 async fn best_of_one_skips_the_judge() {
332 let b = ScriptBrain::new(&["only candidate"]);
333 let out = best_of_n(&b, "sys", "task", 1).await.unwrap();
334 assert_eq!(out, "only candidate");
335 assert_eq!(b.calls(), 1, "no judge call for n=1");
336 }
337
338 #[tokio::test]
339 async fn reason_max_runs_the_full_pipeline() {
340 let b = ScriptBrain::new(&["d0", "d1", "1", "ISSUE: x", "refined answer"]);
343 let budget = ReasoningBudget {
344 samples: 2,
345 refine_rounds: 1,
346 };
347 let out = reason_max(&b, "sys", "task", budget).await.unwrap();
348 assert_eq!(out, "refined answer");
349 assert_eq!(b.calls(), 5, "2 drafts + judge + critique + revise");
350 }
351}