Skip to main content

ares_agent/research/
coordinator.rs

1use std::collections::HashSet;
2use std::fmt;
3
4use ares_llm::{client::TokenUsage, LLMClient};
5use ares_types::types::{Result, Source};
6use serde::{Deserialize, Serialize};
7use tokio::task::JoinSet;
8
9/// How sub-queries within a research plan are scheduled.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum PhaseExecutionMode {
13    Sequential,
14    Parallel,
15}
16
17/// A single research step derived from a parent query.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct ResearchPhase {
20    pub id: String,
21    pub query: String,
22    pub mode: PhaseExecutionMode,
23    #[serde(default, skip_serializing_if = "Vec::is_empty")]
24    pub depends_on: Vec<String>,
25}
26
27/// Ordered research work units for a root question.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct ResearchPlan {
30    pub root_query: String,
31    pub phases: Vec<ResearchPhase>,
32}
33
34/// One phase's output before aggregation.
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36pub struct ResearchFinding {
37    pub phase_id: String,
38    pub content: String,
39    pub relevance_score: f32,
40}
41
42/// Aggregated outcome of executing a [`ResearchPlan`].
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct ResearchResult {
45    pub findings: Vec<ResearchFinding>,
46    pub sources: Vec<Source>,
47    pub failed_phase_ids: Vec<String>,
48    pub partial: bool,
49}
50
51/// Inputs for [`rank_sources`] (recency, authority, and query match).
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct SourceCandidate {
54    pub source: Source,
55    pub published_epoch_secs: Option<u64>,
56    pub authority_score: f32,
57    pub content_match_score: f32,
58}
59
60impl fmt::Display for ResearchPlan {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        writeln!(f, "ResearchPlan: {}", self.root_query)?;
63        for phase in &self.phases {
64            let deps = if phase.depends_on.is_empty() {
65                String::new()
66            } else {
67                format!(" (after {})", phase.depends_on.join(", "))
68            };
69            writeln!(f, "  [{} / {:?}] {}{}", phase.id, phase.mode, phase.query, deps)?;
70        }
71        Ok(())
72    }
73}
74
75impl fmt::Display for ResearchPhase {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        write!(f, "ResearchPhase {} [{:?}]: {}", self.id, self.mode, self.query)
78    }
79}
80
81impl fmt::Display for ResearchResult {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        writeln!(f, "ResearchResult: {} finding(s), {} source(s), partial={}", self.findings.len(), self.sources.len(), self.partial)?;
84        if !self.failed_phase_ids.is_empty() {
85            writeln!(f, "  failed phases: {}", self.failed_phase_ids.join(", "))?;
86        }
87        for finding in &self.findings {
88            writeln!(f, "  - {} (relevance {:.2}): {}", finding.phase_id, finding.relevance_score, finding.content)?;
89        }
90        Ok(())
91    }
92}
93
94pub fn plan_research_phases(root_query: &str, sub_queries: &[String], parallel: bool) -> ResearchPlan {
95    let mode = if parallel { PhaseExecutionMode::Parallel } else { PhaseExecutionMode::Sequential };
96    let phases = sub_queries.iter().enumerate().map(|(index, query)| {
97        let id = format!("phase-{}", index + 1);
98        let depends_on = if parallel || index == 0 { Vec::new() } else { vec![format!("phase-{index}")] };
99        ResearchPhase { id, query: query.clone(), mode, depends_on }
100    }).collect();
101    ResearchPlan { root_query: root_query.to_string(), phases }
102}
103
104pub fn execute_phase<F>(phase: &ResearchPhase, executor: &mut F) -> std::result::Result<ResearchFinding, String>
105where
106    F: FnMut(&ResearchPhase) -> std::result::Result<String, String>,
107{
108    let content = executor(phase)?;
109    Ok(ResearchFinding {
110        phase_id: phase.id.clone(),
111        relevance_score: score_content_relevance(&phase.query, &content),
112        content,
113    })
114}
115
116pub fn run_research_plan<F>(plan: &ResearchPlan, executor: &mut F) -> ResearchResult
117where
118    F: FnMut(&ResearchPhase) -> std::result::Result<String, String>,
119{
120    let mut completed = HashSet::new();
121    let mut raw_findings = Vec::new();
122    let mut failed_phase_ids = Vec::new();
123    loop {
124        let ready: Vec<&ResearchPhase> = plan.phases.iter().filter(|phase| {
125            !completed.contains(&phase.id)
126                    && !failed_phase_ids.contains(&phase.id)
127                    && phase.depends_on.iter().all(|dep| completed.contains(dep))
128        }).collect();
129        if ready.is_empty() { break; }
130        let parallel = ready[0].mode == PhaseExecutionMode::Parallel;
131        let batch: Vec<&ResearchPhase> = if parallel { ready } else { vec![ready[0]] };
132        for phase in batch {
133            match execute_phase(phase, executor) {
134                Ok(finding) => { completed.insert(phase.id.clone()); raw_findings.push(finding); }
135                Err(_) => { failed_phase_ids.push(phase.id.clone()); }
136            }
137        }
138    }
139    let findings = aggregate_findings(&raw_findings);
140    let candidates = findings_to_source_candidates(&findings, &plan.root_query);
141    let sources = rank_sources(&candidates, &plan.root_query, current_epoch_secs());
142    let partial = !failed_phase_ids.is_empty() && !findings.is_empty();
143
144    ResearchResult {
145        findings,
146        sources,
147        failed_phase_ids,
148        partial,
149    }
150}
151
152pub fn aggregate_findings(findings: &[ResearchFinding]) -> Vec<ResearchFinding> {
153    use std::collections::HashMap;
154
155    let mut by_content: HashMap<String, ResearchFinding> = HashMap::new();
156    for finding in findings {
157        let key = normalize_content(&finding.content);
158        if key.is_empty() {
159            continue;
160        }
161        by_content
162            .entry(key)
163            .and_modify(|existing| {
164                if finding.relevance_score > existing.relevance_score {
165                    *existing = finding.clone();
166                }
167            })
168            .or_insert_with(|| finding.clone());
169    }
170
171    let mut unique: Vec<_> = by_content.into_values().collect();
172    unique.sort_by(|left, right| {
173        right
174            .relevance_score
175            .partial_cmp(&left.relevance_score)
176            .unwrap_or(std::cmp::Ordering::Equal)
177    });
178    unique
179}
180
181pub fn rank_sources(candidates: &[SourceCandidate], query: &str, now_epoch_secs: u64) -> Vec<Source> {
182    let mut scored: Vec<(f32, &SourceCandidate)> = candidates.iter().map(|candidate| {
183        let recency = recency_score(candidate.published_epoch_secs, now_epoch_secs);
184        let content = candidate.content_match_score.max(score_content_relevance(query, &candidate.source.title));
185        let composite = 0.45 * candidate.authority_score + 0.30 * recency + 0.25 * content;
186        (composite, candidate)
187    }).collect();
188    scored.sort_by(|(left, _), (right, _)| right.partial_cmp(left).unwrap_or(std::cmp::Ordering::Equal));
189    scored.into_iter().map(|(score, candidate)| {
190        let mut source = candidate.source.clone();
191        source.relevance_score = score.clamp(0.0, 1.0);
192        source
193    }).collect()
194}
195
196pub fn score_content_relevance(query: &str, content: &str) -> f32 {
197    let query_tokens = tokenize(query);
198    if query_tokens.is_empty() { return 0.0; }
199    let content_tokens = tokenize(content);
200    let overlap = query_tokens.iter().filter(|token| content_tokens.contains(*token)).count();
201    (overlap as f32 / query_tokens.len() as f32).clamp(0.0, 1.0)
202}
203
204fn recency_score(published_epoch_secs: Option<u64>, now_epoch_secs: u64) -> f32 {
205    published_epoch_secs.map(|published| {
206        let age_days = now_epoch_secs.saturating_sub(published) / 86_400;
207        (1.0 - (age_days as f32 / 365.0)).clamp(0.0, 1.0)
208    }).unwrap_or(0.5)
209}
210
211fn score_authority(url: Option<&str>) -> f32 {
212    let Some(url) = url else { return 0.3; };
213    let lower = url.to_ascii_lowercase();
214    if lower.contains(".gov") || lower.contains(".edu") { 1.0 }
215    else if lower.contains("arxiv.org") || lower.contains("doi.org") || lower.contains(".org") { 0.85 }
216    else { 0.55 }
217}
218
219fn normalize_content(content: &str) -> String {
220    content.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase()
221}
222
223fn tokenize(text: &str) -> HashSet<String> {
224    text.split(|c: char| !c.is_alphanumeric()).filter(|token| token.len() >= 3).map(|token| token.to_ascii_lowercase()).collect()
225}
226
227fn extract_url(text: &str) -> Option<String> {
228    text.split_whitespace().find(|word| word.starts_with("http://") || word.starts_with("https://")).map(str::to_string)
229}
230
231fn extract_published_epoch(text: &str) -> Option<u64> {
232    text.split_whitespace().find_map(|word| word.strip_prefix("published:").and_then(|value| value.parse().ok()))
233}
234
235fn findings_to_source_candidates(findings: &[ResearchFinding], root_query: &str) -> Vec<SourceCandidate> {
236    findings.iter().enumerate().map(|(index, finding)| {
237        let url = extract_url(&finding.content);
238        let authority_score = score_authority(url.as_deref());
239        let content_match_score = score_content_relevance(root_query, &finding.content).max(finding.relevance_score);
240        SourceCandidate {
241            source: Source { title: format!("Research Finding {}", index + 1), url, relevance_score: content_match_score },
242            published_epoch_secs: extract_published_epoch(&finding.content),
243            authority_score,
244            content_match_score,
245        }
246    }).collect()
247}
248
249fn current_epoch_secs() -> u64 {
250    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|duration| duration.as_secs()).unwrap_or(0)
251}
252
253/// Token usage accumulated across the LLM calls made by a research run.
254#[derive(Debug, Clone, Default)]
255pub struct ResearchUsage {
256    pub input_tokens: u32,
257    pub output_tokens: u32,
258}
259
260impl ResearchUsage {
261    fn add(&mut self, usage: Option<&TokenUsage>) {
262        if let Some(usage) = usage {
263            self.input_tokens += usage.prompt_tokens;
264            self.output_tokens += usage.completion_tokens;
265        }
266    }
267}
268
269/// Coordinates multi-step research tasks across multiple queries.
270///
271/// Decomposes research questions, executes parallel searches,
272/// and synthesizes findings into a coherent report.
273pub struct ResearchCoordinator {
274    llm: Box<dyn LLMClient>,
275    depth: u8,
276    max_iterations: u8,
277}
278
279impl ResearchCoordinator {
280    /// Creates a new ResearchCoordinator.
281    pub fn new(llm: Box<dyn LLMClient>, depth: u8, max_iterations: u8) -> Self {
282        Self {
283            llm,
284            depth,
285            max_iterations,
286        }
287    }
288
289    /// Execute deep research on a query
290    pub async fn research(&self, query: &str) -> Result<(String, Vec<Source>)> {
291        let (synthesis, sources, _) = self.research_with_usage(query).await?;
292        Ok((synthesis, sources))
293    }
294
295    /// Execute deep research and return provider-reported token usage.
296    pub async fn research_with_usage(
297        &self,
298        query: &str,
299    ) -> Result<(String, Vec<Source>, ResearchUsage)> {
300        let mut all_findings = Vec::new();
301        let mut usage = ResearchUsage::default();
302
303        // Generate initial research questions
304        let (questions, question_usage) = self.generate_research_questions(query).await?;
305        usage.add(question_usage.as_ref());
306
307        // Execute breadth-first parallel search
308        for iteration in 0..self.max_iterations {
309            tracing::info!(
310                "Research iteration {}/{}",
311                iteration + 1,
312                self.max_iterations
313            );
314
315            let findings = self.parallel_research(&questions).await?;
316            all_findings.extend(findings);
317
318            // Check if we have enough information
319            if all_findings.len() >= (self.depth as usize * 3) {
320                break;
321            }
322
323            // Generate follow-up questions based on findings
324            if iteration < self.max_iterations - 1 {
325                let (follow_ups, followup_usage) = self
326                    .generate_followup_questions(query, &all_findings)
327                    .await?;
328                usage.add(followup_usage.as_ref());
329
330                if follow_ups.is_empty() {
331                    break;
332                }
333            }
334        }
335
336        // Synthesize findings
337        let (synthesis, synthesis_usage) = self.synthesize_findings(query, &all_findings).await?;
338        usage.add(synthesis_usage.as_ref());
339
340        // Extract sources
341        let all_sources = self.extract_sources(query, &all_findings);
342
343        Ok((synthesis, all_sources, usage))
344    }
345
346    async fn generate_research_questions(
347        &self,
348        query: &str,
349    ) -> Result<(Vec<String>, Option<TokenUsage>)> {
350        let prompt = format!(
351            r#"Generate {} focused research questions to comprehensively answer: {}
352
353Return only the questions, one per line, numbered 1-{}.
354
355Example:
356
3571. [QUESTION 1]
3582. [QUESTION 2]
3593. [QUESTION 3]
360..."#,
361            self.depth, query, self.depth
362        );
363
364        let response = self
365            .llm
366            .generate_with_history(&[("user".to_string(), prompt)])
367            .await?;
368
369        let questions = response
370            .content
371            .lines()
372            .filter(|line| !line.trim().is_empty())
373            .map(|line| {
374                // Remove numbering
375                line.trim()
376                    .trim_start_matches(|c: char| c.is_numeric() || c == '.' || c == ')')
377                    .trim()
378                    .to_string()
379            })
380            .collect();
381
382        Ok((questions, response.usage))
383    }
384
385    async fn parallel_research(&self, questions: &[String]) -> Result<Vec<String>> {
386        let mut set = JoinSet::new();
387
388        for question in questions.iter().take(self.depth as usize) {
389            let question = question.clone();
390            let _llm_clone = self.llm.model_name().to_string(); // Simplified for example
391
392            set.spawn(async move {
393                // Simplified research - in production, this would call web search tools
394                format!("Research findings for: {}", question)
395            });
396        }
397
398        let mut results = Vec::new();
399        while let Some(res) = set.join_next().await {
400            if let Ok(finding) = res {
401                results.push(finding);
402            }
403        }
404
405        Ok(results)
406    }
407
408    async fn generate_followup_questions(
409        &self,
410        _original_query: &str,
411        findings: &[String],
412    ) -> Result<(Vec<String>, Option<TokenUsage>)> {
413        if findings.is_empty() {
414            return Ok((vec![], None));
415        }
416
417        let prompt = format!(
418            r#"Based on these findings:
419    {}
420
421    Generate 2-3 follow-up research questions.
422
423    ONLY output the questions and nothing else, like this:
424
425    <question1>
426    <question2>
427    <question3>
428
429    "#,
430            findings.join("\n")
431        );
432
433        let response = self
434            .llm
435            .generate_with_history(&[("user".to_string(), prompt)])
436            .await?;
437
438        let questions = response
439            .content
440            .lines()
441            .filter(|line| !line.trim().is_empty())
442            .take(3)
443            .map(|s| s.to_string())
444            .collect();
445
446        Ok((questions, response.usage))
447    }
448
449    async fn synthesize_findings(
450        &self,
451        query: &str,
452        findings: &[String],
453    ) -> Result<(String, Option<TokenUsage>)> {
454        let prompt = format!(
455            r#"Original query: {}
456
457      Research findings:
458      {}
459
460      Synthesize these findings into a comprehensive, well-structured answer. Include:
461      1. Direct answer to the question
462      2. Key insights
463      3. Supporting evidence
464      4. Caveats or limitations if any
465
466      Provide a clear, professional response."#,
467            query,
468            findings.join("\n\n")
469        );
470
471        let response = self
472            .llm
473            .generate_with_history(&[("user".to_string(), prompt)])
474            .await?;
475        Ok((response.content, response.usage))
476    }
477
478    fn extract_sources(&self, query: &str, findings: &[String]) -> Vec<Source> {
479        let research_findings: Vec<ResearchFinding> = findings
480            .iter()
481            .enumerate()
482            .map(|(index, finding)| ResearchFinding {
483                phase_id: format!("finding-{}", index + 1),
484                content: finding.clone(),
485                relevance_score: score_content_relevance(query, finding),
486            })
487            .collect();
488
489        let aggregated = aggregate_findings(&research_findings);
490        let candidates = findings_to_source_candidates(&aggregated, query);
491        rank_sources(&candidates, query, current_epoch_secs())
492    }
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use ares_llm::{client::TokenUsage, LLMClient, LLMResponse};
499    use ares_types::types::{AppError, ToolDefinition};
500    use async_trait::async_trait;
501    use std::sync::atomic::{AtomicUsize, Ordering};
502    use std::sync::Arc;
503
504    struct ResearchMockLlm {
505        calls: Arc<AtomicUsize>,
506        fail: bool,
507    }
508
509    impl ResearchMockLlm {
510        fn new() -> Self {
511            Self {
512                calls: Arc::new(AtomicUsize::new(0)),
513                fail: false,
514            }
515        }
516
517        fn failing() -> Self {
518            Self {
519                calls: Arc::new(AtomicUsize::new(0)),
520                fail: true,
521            }
522        }
523    }
524
525    #[async_trait]
526    impl LLMClient for ResearchMockLlm {
527        fn model_name(&self) -> &str {
528            "research-mock"
529        }
530
531        async fn generate(&self, _: &str) -> Result<String> {
532            Ok(String::new())
533        }
534
535        async fn generate_with_system(&self, _: &str, _: &str) -> Result<String> {
536            Ok(String::new())
537        }
538
539        async fn generate_with_history(
540            &self,
541            messages: &[(String, String)],
542        ) -> Result<LLMResponse> {
543            if self.fail {
544                return Err(AppError::Internal("mock llm failure".into()));
545            }
546
547            let _n = self.calls.fetch_add(1, Ordering::SeqCst);
548            let prompt = messages
549                .last()
550                .map(|(_, content)| content.as_str())
551                .unwrap_or_default();
552
553            let (content, usage) = if prompt.contains("follow-up research questions") {
554                (
555                    "What is the regulatory timeline?\nWhat are adoption barriers?".to_string(),
556                    TokenUsage::new(30, 12),
557                )
558            } else if prompt.contains("Synthesize these findings") {
559                (
560                    "Comprehensive synthesized answer.".to_string(),
561                    TokenUsage::new(50, 25),
562                )
563            } else {
564                (
565                    "1. What is the core technology?\n2. Who are the main vendors?".to_string(),
566                    TokenUsage::new(20, 8),
567                )
568            };
569
570            Ok(LLMResponse {
571                content,
572                tool_calls: vec![],
573                finish_reason: "stop".to_string(),
574                usage: Some(usage),
575                reasoning_content: None,
576                response_id: None,
577            })
578        }
579
580        async fn generate_with_tools(
581            &self,
582            _: &str,
583            _: &[ToolDefinition],
584        ) -> Result<LLMResponse> {
585            Ok(LLMResponse {
586                content: String::new(),
587                tool_calls: vec![],
588                finish_reason: "stop".to_string(),
589                usage: None,
590                reasoning_content: None,
591                response_id: None,
592            })
593        }
594
595        async fn generate_with_tools_and_history(
596            &self,
597            _: &[ares_llm::coordinator::ConversationMessage],
598            _: &[ToolDefinition],
599        ) -> Result<LLMResponse> {
600            Ok(LLMResponse {
601                content: String::new(),
602                tool_calls: vec![],
603                finish_reason: "stop".to_string(),
604                usage: None,
605                reasoning_content: None,
606                response_id: None,
607            })
608        }
609
610        async fn stream(
611            &self,
612            _: &str,
613        ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
614            Ok(Box::new(futures::stream::empty()))
615        }
616
617        async fn stream_with_system(
618            &self,
619            _: &str,
620            _: &str,
621        ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
622            Ok(Box::new(futures::stream::empty()))
623        }
624
625        async fn stream_with_history(
626            &self,
627            _: &[(String, String)],
628        ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
629            Ok(Box::new(futures::stream::empty()))
630        }
631    }
632
633
634    fn sample_sub_queries() -> Vec<String> {
635        vec![
636            "What is the core technology?".to_string(),
637            "Who are the main vendors?".to_string(),
638            "What is the market size?".to_string(),
639        ]
640    }
641
642    #[test]
643    fn research_plan_serde_roundtrip() {
644        let plan = plan_research_phases("quantum networking", &sample_sub_queries(), true);
645        let json = serde_json::to_string(&plan).expect("serialize plan");
646        let parsed: ResearchPlan = serde_json::from_str(&json).expect("deserialize plan");
647        assert_eq!(parsed, plan);
648    }
649
650    #[test]
651    fn research_phase_serde_roundtrip() {
652        let phase = ResearchPhase {
653            id: "phase-1".into(),
654            query: "vendors".into(),
655            mode: PhaseExecutionMode::Sequential,
656            depends_on: vec![],
657        };
658        let json = serde_json::to_string(&phase).expect("serialize phase");
659        let parsed: ResearchPhase = serde_json::from_str(&json).expect("deserialize phase");
660        assert_eq!(parsed, phase);
661    }
662
663    #[test]
664    fn research_result_serde_roundtrip() {
665        let result = ResearchResult {
666            findings: vec![ResearchFinding {
667                phase_id: "phase-1".into(),
668                content: "finding".into(),
669                relevance_score: 0.9,
670            }],
671            sources: vec![Source {
672                title: "Paper".into(),
673                url: Some("https://example.edu/paper".into()),
674                relevance_score: 0.9,
675            }],
676            failed_phase_ids: vec!["phase-2".into()],
677            partial: true,
678        };
679        let json = serde_json::to_string(&result).expect("serialize result");
680        let parsed: ResearchResult = serde_json::from_str(&json).expect("deserialize result");
681        assert_eq!(parsed.findings, result.findings);
682        assert_eq!(parsed.failed_phase_ids, result.failed_phase_ids);
683        assert_eq!(parsed.partial, result.partial);
684        assert_eq!(parsed.sources.len(), result.sources.len());
685    }
686
687    #[test]
688    fn phase_execution_mode_serde_roundtrip() {
689        for mode in [PhaseExecutionMode::Sequential, PhaseExecutionMode::Parallel] {
690            let json = serde_json::to_string(&mode).expect("serialize mode");
691            let parsed: PhaseExecutionMode = serde_json::from_str(&json).expect("deserialize mode");
692            assert_eq!(parsed, mode);
693        }
694    }
695
696    #[test]
697    fn plan_research_phases_parallel_has_no_dependencies() {
698        let plan = plan_research_phases("root", &sample_sub_queries(), true);
699        assert!(plan.phases.iter().all(|phase| phase.depends_on.is_empty()));
700        assert!(plan.phases.iter().all(|phase| phase.mode == PhaseExecutionMode::Parallel));
701    }
702
703    #[test]
704    fn plan_research_phases_sequential_builds_dependency_chain() {
705        let plan = plan_research_phases("root", &sample_sub_queries(), false);
706        assert_eq!(plan.phases[0].depends_on, Vec::<String>::new());
707        assert_eq!(plan.phases[1].depends_on, vec!["phase-1".to_string()]);
708        assert_eq!(plan.phases[2].depends_on, vec!["phase-2".to_string()]);
709        assert!(plan.phases.iter().all(|phase| phase.mode == PhaseExecutionMode::Sequential));
710    }
711
712    #[test]
713    fn plan_research_phases_preserves_sub_queries() {
714        let queries = sample_sub_queries();
715        let plan = plan_research_phases("root", &queries, true);
716        let planned: Vec<_> = plan.phases.iter().map(|phase| phase.query.clone()).collect();
717        assert_eq!(planned, queries);
718    }
719
720    #[test]
721    fn execute_phase_success_scores_relevance() {
722        let phase = ResearchPhase {
723            id: "phase-1".into(),
724            query: "quantum error correction".into(),
725            mode: PhaseExecutionMode::Parallel,
726            depends_on: vec![],
727        };
728        let mut executor = |_: &ResearchPhase| {
729            Ok("quantum error correction advances published:1700000000 https://arxiv.org/abs/123".into())
730        };
731        let finding = execute_phase(&phase, &mut executor).expect("phase should succeed");
732        assert_eq!(finding.phase_id, "phase-1");
733        assert!(finding.relevance_score > 0.5);
734    }
735
736    #[test]
737    fn execute_phase_failure_returns_error() {
738        let phase = ResearchPhase {
739            id: "phase-1".into(),
740            query: "vendors".into(),
741            mode: PhaseExecutionMode::Parallel,
742            depends_on: vec![],
743        };
744        let mut executor = |_: &ResearchPhase| Err("upstream search unavailable".to_string());
745        let err = execute_phase(&phase, &mut executor).expect_err("phase should fail");
746        assert!(err.contains("unavailable"));
747    }
748
749    #[test]
750    fn aggregate_findings_deduplicates_normalized_content() {
751        let findings = vec![
752            ResearchFinding { phase_id: "a".into(), content: "Same   content here".into(), relevance_score: 0.4 },
753            ResearchFinding { phase_id: "b".into(), content: "same content here".into(), relevance_score: 0.9 },
754        ];
755        let aggregated = aggregate_findings(&findings);
756        assert_eq!(aggregated.len(), 1);
757        assert_eq!(aggregated[0].phase_id, "b");
758    }
759
760    #[test]
761    fn aggregate_findings_sorts_by_relevance_descending() {
762        let findings = vec![
763            ResearchFinding { phase_id: "low".into(), content: "alpha".into(), relevance_score: 0.2 },
764            ResearchFinding { phase_id: "high".into(), content: "beta".into(), relevance_score: 0.95 },
765        ];
766        let aggregated = aggregate_findings(&findings);
767        assert_eq!(aggregated[0].phase_id, "high");
768        assert_eq!(aggregated[1].phase_id, "low");
769    }
770
771    #[test]
772    fn aggregate_findings_empty_input_returns_empty() {
773        assert!(aggregate_findings(&[]).is_empty());
774    }
775
776    #[test]
777    fn score_content_relevance_handles_no_tokens() {
778        assert_eq!(score_content_relevance("a", "beta gamma delta"), 0.0);
779    }
780
781    #[test]
782    fn rank_sources_prefers_higher_authority() {
783        let now = 1_700_000_000;
784        let candidates = vec![
785            SourceCandidate {
786                source: Source { title: "Blog".into(), url: Some("https://example.com/post".into()), relevance_score: 0.0 },
787                published_epoch_secs: Some(now), authority_score: 0.55, content_match_score: 0.5,
788            },
789            SourceCandidate {
790                source: Source { title: "Government report".into(), url: Some("https://agency.gov/report".into()), relevance_score: 0.0 },
791                published_epoch_secs: Some(now), authority_score: 1.0, content_match_score: 0.5,
792            },
793        ];
794        let ranked = rank_sources(&candidates, "report", now);
795        assert!(ranked[0].url.as_deref().unwrap().contains(".gov"));
796    }
797
798    #[test]
799    fn rank_sources_prefers_more_recent_publication() {
800        let now = 1_700_000_000;
801        let candidates = vec![
802            SourceCandidate {
803                source: Source { title: "Old".into(), url: Some("https://example.com/old".into()), relevance_score: 0.0 },
804                published_epoch_secs: Some(now - 400 * 86_400), authority_score: 0.55, content_match_score: 0.6,
805            },
806            SourceCandidate {
807                source: Source { title: "New".into(), url: Some("https://example.com/new".into()), relevance_score: 0.0 },
808                published_epoch_secs: Some(now - 7 * 86_400), authority_score: 0.55, content_match_score: 0.6,
809            },
810        ];
811        let ranked = rank_sources(&candidates, "example", now);
812        assert_eq!(ranked[0].title, "New");
813    }
814
815    #[test]
816    fn rank_sources_prefers_better_content_match() {
817        let now = 1_700_000_000;
818        let candidates = vec![
819            SourceCandidate {
820                source: Source { title: "Unrelated".into(), url: None, relevance_score: 0.0 },
821                published_epoch_secs: Some(now), authority_score: 0.55, content_match_score: 0.1,
822            },
823            SourceCandidate {
824                source: Source { title: "Quantum networking overview".into(), url: None, relevance_score: 0.0 },
825                published_epoch_secs: Some(now), authority_score: 0.55, content_match_score: 0.95,
826            },
827        ];
828        let ranked = rank_sources(&candidates, "quantum networking", now);
829        assert!(ranked[0].title.contains("Quantum"));
830    }
831
832    #[test]
833    fn run_research_plan_parallel_collects_all_phases() {
834        let plan = plan_research_phases("root", &sample_sub_queries(), true);
835        let mut executor = |phase: &ResearchPhase| Ok(format!("answer for {}", phase.id));
836        let result = run_research_plan(&plan, &mut executor);
837        assert_eq!(result.findings.len(), 3);
838        assert!(!result.partial);
839        assert!(result.failed_phase_ids.is_empty());
840    }
841
842    #[test]
843    fn run_research_plan_sequential_stops_after_failure() {
844        let plan = plan_research_phases("root", &["first".into(), "second".into(), "third".into()], false);
845        let mut executor = |phase: &ResearchPhase| {
846            if phase.id == "phase-2" { Err("phase two failed".into()) } else { Ok(format!("ok {}", phase.id)) }
847        };
848        let result = run_research_plan(&plan, &mut executor);
849        assert_eq!(result.findings.len(), 1);
850        assert!(result.partial);
851        assert_eq!(result.failed_phase_ids, vec!["phase-2".to_string()]);
852    }
853
854    #[test]
855    fn run_research_plan_partial_success_flag() {
856        let plan = plan_research_phases("root", &["only".into()], true);
857        let mut ok = |_: &ResearchPhase| Ok("content".into());
858        assert!(!run_research_plan(&plan, &mut ok).partial);
859        let mut fail = |_: &ResearchPhase| Err("boom".into());
860        let failure = run_research_plan(&plan, &mut fail);
861        assert!(!failure.partial);
862        assert!(failure.findings.is_empty());
863    }
864
865    #[test]
866    fn research_plan_display_includes_phases() {
867        let plan = plan_research_phases("quantum", &["vendors".into()], false);
868        let text = plan.to_string();
869        assert!(text.contains("ResearchPlan: quantum"));
870        assert!(text.contains("phase-1"));
871    }
872
873    #[test]
874    fn research_phase_display_formats_id_and_query() {
875        let phase = ResearchPhase { id: "phase-9".into(), query: "market size".into(), mode: PhaseExecutionMode::Parallel, depends_on: vec![] };
876        let text = phase.to_string();
877        assert!(text.contains("phase-9"));
878        assert!(text.contains("market size"));
879    }
880
881    #[test]
882    fn research_result_display_lists_findings() {
883        let result = ResearchResult {
884            findings: vec![ResearchFinding { phase_id: "phase-1".into(), content: "data".into(), relevance_score: 0.5 }],
885            sources: vec![], failed_phase_ids: vec![], partial: false,
886        };
887        let text = result.to_string();
888        assert!(text.contains("1 finding(s)"));
889        assert!(text.contains("phase-1"));
890    }
891
892    #[test]
893    fn research_plan_clone_and_debug() {
894        let plan = plan_research_phases("root", &["q".into()], true);
895        let cloned = plan.clone();
896        assert_eq!(format!("{plan:?}"), format!("{cloned:?}"));
897    }
898
899    #[test]
900    fn research_usage_debug_clone() {
901        let usage = ResearchUsage { input_tokens: 3, output_tokens: 7 };
902        let cloned = usage.clone();
903        assert_eq!(format!("{usage:?}"), format!("{cloned:?}"));
904    }
905
906    #[test]
907    fn score_authority_boosts_edu_and_gov_domains() {
908        assert_eq!(score_authority(Some("https://university.edu/paper")), 1.0);
909        assert!(score_authority(Some("https://blog.example.com")) < 1.0);
910    }
911
912    #[test]
913    fn findings_to_source_candidates_extracts_url_and_epoch() {
914        let findings = vec![ResearchFinding {
915            phase_id: "phase-1".into(),
916            content: "quantum paper published:1700000000 https://arxiv.org/abs/123".into(),
917            relevance_score: 0.8,
918        }];
919        let candidates = findings_to_source_candidates(&findings, "quantum paper");
920        assert_eq!(candidates[0].source.url.as_deref(), Some("https://arxiv.org/abs/123"));
921        assert_eq!(candidates[0].published_epoch_secs, Some(1_700_000_000));
922        assert!(candidates[0].authority_score >= 0.85);
923    }
924
925    #[tokio::test]
926    async fn test_research_coordinator_end_to_end() {
927        let coordinator = ResearchCoordinator::new(Box::new(ResearchMockLlm::new()), 2, 2);
928        let (report, sources) = coordinator
929            .research("quantum networking trends")
930            .await
931            .expect("research should succeed");
932
933        assert!(report.contains("Comprehensive synthesized answer"));
934        assert_eq!(sources.len(), 2);
935        assert_eq!(sources[0].title, "Research Finding 1");
936        assert!(sources[0].relevance_score > 0.0);
937    }
938
939    #[tokio::test]
940    async fn test_research_with_usage_accumulates_tokens() {
941        let coordinator = ResearchCoordinator::new(Box::new(ResearchMockLlm::new()), 2, 2);
942        let (_, _, usage) = coordinator
943            .research_with_usage("market landscape")
944            .await
945            .expect("research_with_usage");
946
947        assert_eq!(usage.input_tokens, 100);
948        assert_eq!(usage.output_tokens, 45);
949    }
950
951    #[tokio::test]
952    async fn test_research_propagates_llm_errors() {
953        let coordinator = ResearchCoordinator::new(Box::new(ResearchMockLlm::failing()), 1, 1);
954        let err = coordinator
955            .research("anything")
956            .await
957            .expect_err("expected llm failure");
958        assert!(matches!(err, AppError::Internal(_)));
959    }
960
961    #[test]
962    fn test_token_usage_serde_roundtrip() {
963        let usage = TokenUsage::new(11, 7);
964        let json = serde_json::to_string(&usage).expect("serialize TokenUsage");
965        let parsed: TokenUsage = serde_json::from_str(&json).expect("deserialize TokenUsage");
966        assert_eq!(parsed, usage);
967    }
968
969    #[test]
970    fn test_research_usage_default_is_zero() {
971        let usage = ResearchUsage::default();
972        assert_eq!(usage.input_tokens, 0);
973        assert_eq!(usage.output_tokens, 0);
974    }
975
976    #[tokio::test]
977    async fn test_generate_followup_questions_empty_findings() {
978        let coordinator = ResearchCoordinator::new(Box::new(ResearchMockLlm::new()), 2, 2);
979        let (questions, usage) = coordinator
980            .generate_followup_questions("original query", &[])
981            .await
982            .expect("empty findings should succeed");
983        assert!(questions.is_empty());
984        assert!(usage.is_none());
985    }
986}
987