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            })
576        }
577
578        async fn generate_with_tools(
579            &self,
580            _: &str,
581            _: &[ToolDefinition],
582        ) -> Result<LLMResponse> {
583            Ok(LLMResponse {
584                content: String::new(),
585                tool_calls: vec![],
586                finish_reason: "stop".to_string(),
587                usage: None,
588            })
589        }
590
591        async fn generate_with_tools_and_history(
592            &self,
593            _: &[ares_llm::coordinator::ConversationMessage],
594            _: &[ToolDefinition],
595        ) -> Result<LLMResponse> {
596            Ok(LLMResponse {
597                content: String::new(),
598                tool_calls: vec![],
599                finish_reason: "stop".to_string(),
600                usage: None,
601            })
602        }
603
604        async fn stream(
605            &self,
606            _: &str,
607        ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
608            Ok(Box::new(futures::stream::empty()))
609        }
610
611        async fn stream_with_system(
612            &self,
613            _: &str,
614            _: &str,
615        ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
616            Ok(Box::new(futures::stream::empty()))
617        }
618
619        async fn stream_with_history(
620            &self,
621            _: &[(String, String)],
622        ) -> Result<Box<dyn futures::Stream<Item = Result<String>> + Send + Unpin>> {
623            Ok(Box::new(futures::stream::empty()))
624        }
625    }
626
627
628    fn sample_sub_queries() -> Vec<String> {
629        vec![
630            "What is the core technology?".to_string(),
631            "Who are the main vendors?".to_string(),
632            "What is the market size?".to_string(),
633        ]
634    }
635
636    #[test]
637    fn research_plan_serde_roundtrip() {
638        let plan = plan_research_phases("quantum networking", &sample_sub_queries(), true);
639        let json = serde_json::to_string(&plan).expect("serialize plan");
640        let parsed: ResearchPlan = serde_json::from_str(&json).expect("deserialize plan");
641        assert_eq!(parsed, plan);
642    }
643
644    #[test]
645    fn research_phase_serde_roundtrip() {
646        let phase = ResearchPhase {
647            id: "phase-1".into(),
648            query: "vendors".into(),
649            mode: PhaseExecutionMode::Sequential,
650            depends_on: vec![],
651        };
652        let json = serde_json::to_string(&phase).expect("serialize phase");
653        let parsed: ResearchPhase = serde_json::from_str(&json).expect("deserialize phase");
654        assert_eq!(parsed, phase);
655    }
656
657    #[test]
658    fn research_result_serde_roundtrip() {
659        let result = ResearchResult {
660            findings: vec![ResearchFinding {
661                phase_id: "phase-1".into(),
662                content: "finding".into(),
663                relevance_score: 0.9,
664            }],
665            sources: vec![Source {
666                title: "Paper".into(),
667                url: Some("https://example.edu/paper".into()),
668                relevance_score: 0.9,
669            }],
670            failed_phase_ids: vec!["phase-2".into()],
671            partial: true,
672        };
673        let json = serde_json::to_string(&result).expect("serialize result");
674        let parsed: ResearchResult = serde_json::from_str(&json).expect("deserialize result");
675        assert_eq!(parsed.findings, result.findings);
676        assert_eq!(parsed.failed_phase_ids, result.failed_phase_ids);
677        assert_eq!(parsed.partial, result.partial);
678        assert_eq!(parsed.sources.len(), result.sources.len());
679    }
680
681    #[test]
682    fn phase_execution_mode_serde_roundtrip() {
683        for mode in [PhaseExecutionMode::Sequential, PhaseExecutionMode::Parallel] {
684            let json = serde_json::to_string(&mode).expect("serialize mode");
685            let parsed: PhaseExecutionMode = serde_json::from_str(&json).expect("deserialize mode");
686            assert_eq!(parsed, mode);
687        }
688    }
689
690    #[test]
691    fn plan_research_phases_parallel_has_no_dependencies() {
692        let plan = plan_research_phases("root", &sample_sub_queries(), true);
693        assert!(plan.phases.iter().all(|phase| phase.depends_on.is_empty()));
694        assert!(plan.phases.iter().all(|phase| phase.mode == PhaseExecutionMode::Parallel));
695    }
696
697    #[test]
698    fn plan_research_phases_sequential_builds_dependency_chain() {
699        let plan = plan_research_phases("root", &sample_sub_queries(), false);
700        assert_eq!(plan.phases[0].depends_on, Vec::<String>::new());
701        assert_eq!(plan.phases[1].depends_on, vec!["phase-1".to_string()]);
702        assert_eq!(plan.phases[2].depends_on, vec!["phase-2".to_string()]);
703        assert!(plan.phases.iter().all(|phase| phase.mode == PhaseExecutionMode::Sequential));
704    }
705
706    #[test]
707    fn plan_research_phases_preserves_sub_queries() {
708        let queries = sample_sub_queries();
709        let plan = plan_research_phases("root", &queries, true);
710        let planned: Vec<_> = plan.phases.iter().map(|phase| phase.query.clone()).collect();
711        assert_eq!(planned, queries);
712    }
713
714    #[test]
715    fn execute_phase_success_scores_relevance() {
716        let phase = ResearchPhase {
717            id: "phase-1".into(),
718            query: "quantum error correction".into(),
719            mode: PhaseExecutionMode::Parallel,
720            depends_on: vec![],
721        };
722        let mut executor = |_: &ResearchPhase| {
723            Ok("quantum error correction advances published:1700000000 https://arxiv.org/abs/123".into())
724        };
725        let finding = execute_phase(&phase, &mut executor).expect("phase should succeed");
726        assert_eq!(finding.phase_id, "phase-1");
727        assert!(finding.relevance_score > 0.5);
728    }
729
730    #[test]
731    fn execute_phase_failure_returns_error() {
732        let phase = ResearchPhase {
733            id: "phase-1".into(),
734            query: "vendors".into(),
735            mode: PhaseExecutionMode::Parallel,
736            depends_on: vec![],
737        };
738        let mut executor = |_: &ResearchPhase| Err("upstream search unavailable".to_string());
739        let err = execute_phase(&phase, &mut executor).expect_err("phase should fail");
740        assert!(err.contains("unavailable"));
741    }
742
743    #[test]
744    fn aggregate_findings_deduplicates_normalized_content() {
745        let findings = vec![
746            ResearchFinding { phase_id: "a".into(), content: "Same   content here".into(), relevance_score: 0.4 },
747            ResearchFinding { phase_id: "b".into(), content: "same content here".into(), relevance_score: 0.9 },
748        ];
749        let aggregated = aggregate_findings(&findings);
750        assert_eq!(aggregated.len(), 1);
751        assert_eq!(aggregated[0].phase_id, "b");
752    }
753
754    #[test]
755    fn aggregate_findings_sorts_by_relevance_descending() {
756        let findings = vec![
757            ResearchFinding { phase_id: "low".into(), content: "alpha".into(), relevance_score: 0.2 },
758            ResearchFinding { phase_id: "high".into(), content: "beta".into(), relevance_score: 0.95 },
759        ];
760        let aggregated = aggregate_findings(&findings);
761        assert_eq!(aggregated[0].phase_id, "high");
762        assert_eq!(aggregated[1].phase_id, "low");
763    }
764
765    #[test]
766    fn aggregate_findings_empty_input_returns_empty() {
767        assert!(aggregate_findings(&[]).is_empty());
768    }
769
770    #[test]
771    fn score_content_relevance_handles_no_tokens() {
772        assert_eq!(score_content_relevance("a", "beta gamma delta"), 0.0);
773    }
774
775    #[test]
776    fn rank_sources_prefers_higher_authority() {
777        let now = 1_700_000_000;
778        let candidates = vec![
779            SourceCandidate {
780                source: Source { title: "Blog".into(), url: Some("https://example.com/post".into()), relevance_score: 0.0 },
781                published_epoch_secs: Some(now), authority_score: 0.55, content_match_score: 0.5,
782            },
783            SourceCandidate {
784                source: Source { title: "Government report".into(), url: Some("https://agency.gov/report".into()), relevance_score: 0.0 },
785                published_epoch_secs: Some(now), authority_score: 1.0, content_match_score: 0.5,
786            },
787        ];
788        let ranked = rank_sources(&candidates, "report", now);
789        assert!(ranked[0].url.as_deref().unwrap().contains(".gov"));
790    }
791
792    #[test]
793    fn rank_sources_prefers_more_recent_publication() {
794        let now = 1_700_000_000;
795        let candidates = vec![
796            SourceCandidate {
797                source: Source { title: "Old".into(), url: Some("https://example.com/old".into()), relevance_score: 0.0 },
798                published_epoch_secs: Some(now - 400 * 86_400), authority_score: 0.55, content_match_score: 0.6,
799            },
800            SourceCandidate {
801                source: Source { title: "New".into(), url: Some("https://example.com/new".into()), relevance_score: 0.0 },
802                published_epoch_secs: Some(now - 7 * 86_400), authority_score: 0.55, content_match_score: 0.6,
803            },
804        ];
805        let ranked = rank_sources(&candidates, "example", now);
806        assert_eq!(ranked[0].title, "New");
807    }
808
809    #[test]
810    fn rank_sources_prefers_better_content_match() {
811        let now = 1_700_000_000;
812        let candidates = vec![
813            SourceCandidate {
814                source: Source { title: "Unrelated".into(), url: None, relevance_score: 0.0 },
815                published_epoch_secs: Some(now), authority_score: 0.55, content_match_score: 0.1,
816            },
817            SourceCandidate {
818                source: Source { title: "Quantum networking overview".into(), url: None, relevance_score: 0.0 },
819                published_epoch_secs: Some(now), authority_score: 0.55, content_match_score: 0.95,
820            },
821        ];
822        let ranked = rank_sources(&candidates, "quantum networking", now);
823        assert!(ranked[0].title.contains("Quantum"));
824    }
825
826    #[test]
827    fn run_research_plan_parallel_collects_all_phases() {
828        let plan = plan_research_phases("root", &sample_sub_queries(), true);
829        let mut executor = |phase: &ResearchPhase| Ok(format!("answer for {}", phase.id));
830        let result = run_research_plan(&plan, &mut executor);
831        assert_eq!(result.findings.len(), 3);
832        assert!(!result.partial);
833        assert!(result.failed_phase_ids.is_empty());
834    }
835
836    #[test]
837    fn run_research_plan_sequential_stops_after_failure() {
838        let plan = plan_research_phases("root", &["first".into(), "second".into(), "third".into()], false);
839        let mut executor = |phase: &ResearchPhase| {
840            if phase.id == "phase-2" { Err("phase two failed".into()) } else { Ok(format!("ok {}", phase.id)) }
841        };
842        let result = run_research_plan(&plan, &mut executor);
843        assert_eq!(result.findings.len(), 1);
844        assert!(result.partial);
845        assert_eq!(result.failed_phase_ids, vec!["phase-2".to_string()]);
846    }
847
848    #[test]
849    fn run_research_plan_partial_success_flag() {
850        let plan = plan_research_phases("root", &["only".into()], true);
851        let mut ok = |_: &ResearchPhase| Ok("content".into());
852        assert!(!run_research_plan(&plan, &mut ok).partial);
853        let mut fail = |_: &ResearchPhase| Err("boom".into());
854        let failure = run_research_plan(&plan, &mut fail);
855        assert!(!failure.partial);
856        assert!(failure.findings.is_empty());
857    }
858
859    #[test]
860    fn research_plan_display_includes_phases() {
861        let plan = plan_research_phases("quantum", &["vendors".into()], false);
862        let text = plan.to_string();
863        assert!(text.contains("ResearchPlan: quantum"));
864        assert!(text.contains("phase-1"));
865    }
866
867    #[test]
868    fn research_phase_display_formats_id_and_query() {
869        let phase = ResearchPhase { id: "phase-9".into(), query: "market size".into(), mode: PhaseExecutionMode::Parallel, depends_on: vec![] };
870        let text = phase.to_string();
871        assert!(text.contains("phase-9"));
872        assert!(text.contains("market size"));
873    }
874
875    #[test]
876    fn research_result_display_lists_findings() {
877        let result = ResearchResult {
878            findings: vec![ResearchFinding { phase_id: "phase-1".into(), content: "data".into(), relevance_score: 0.5 }],
879            sources: vec![], failed_phase_ids: vec![], partial: false,
880        };
881        let text = result.to_string();
882        assert!(text.contains("1 finding(s)"));
883        assert!(text.contains("phase-1"));
884    }
885
886    #[test]
887    fn research_plan_clone_and_debug() {
888        let plan = plan_research_phases("root", &["q".into()], true);
889        let cloned = plan.clone();
890        assert_eq!(format!("{plan:?}"), format!("{cloned:?}"));
891    }
892
893    #[test]
894    fn research_usage_debug_clone() {
895        let usage = ResearchUsage { input_tokens: 3, output_tokens: 7 };
896        let cloned = usage.clone();
897        assert_eq!(format!("{usage:?}"), format!("{cloned:?}"));
898    }
899
900    #[test]
901    fn score_authority_boosts_edu_and_gov_domains() {
902        assert_eq!(score_authority(Some("https://university.edu/paper")), 1.0);
903        assert!(score_authority(Some("https://blog.example.com")) < 1.0);
904    }
905
906    #[test]
907    fn findings_to_source_candidates_extracts_url_and_epoch() {
908        let findings = vec![ResearchFinding {
909            phase_id: "phase-1".into(),
910            content: "quantum paper published:1700000000 https://arxiv.org/abs/123".into(),
911            relevance_score: 0.8,
912        }];
913        let candidates = findings_to_source_candidates(&findings, "quantum paper");
914        assert_eq!(candidates[0].source.url.as_deref(), Some("https://arxiv.org/abs/123"));
915        assert_eq!(candidates[0].published_epoch_secs, Some(1_700_000_000));
916        assert!(candidates[0].authority_score >= 0.85);
917    }
918
919    #[tokio::test]
920    async fn test_research_coordinator_end_to_end() {
921        let coordinator = ResearchCoordinator::new(Box::new(ResearchMockLlm::new()), 2, 2);
922        let (report, sources) = coordinator
923            .research("quantum networking trends")
924            .await
925            .expect("research should succeed");
926
927        assert!(report.contains("Comprehensive synthesized answer"));
928        assert_eq!(sources.len(), 2);
929        assert_eq!(sources[0].title, "Research Finding 1");
930        assert!(sources[0].relevance_score > 0.0);
931    }
932
933    #[tokio::test]
934    async fn test_research_with_usage_accumulates_tokens() {
935        let coordinator = ResearchCoordinator::new(Box::new(ResearchMockLlm::new()), 2, 2);
936        let (_, _, usage) = coordinator
937            .research_with_usage("market landscape")
938            .await
939            .expect("research_with_usage");
940
941        assert_eq!(usage.input_tokens, 100);
942        assert_eq!(usage.output_tokens, 45);
943    }
944
945    #[tokio::test]
946    async fn test_research_propagates_llm_errors() {
947        let coordinator = ResearchCoordinator::new(Box::new(ResearchMockLlm::failing()), 1, 1);
948        let err = coordinator
949            .research("anything")
950            .await
951            .expect_err("expected llm failure");
952        assert!(matches!(err, AppError::Internal(_)));
953    }
954
955    #[test]
956    fn test_token_usage_serde_roundtrip() {
957        let usage = TokenUsage::new(11, 7);
958        let json = serde_json::to_string(&usage).expect("serialize TokenUsage");
959        let parsed: TokenUsage = serde_json::from_str(&json).expect("deserialize TokenUsage");
960        assert_eq!(parsed, usage);
961    }
962
963    #[test]
964    fn test_research_usage_default_is_zero() {
965        let usage = ResearchUsage::default();
966        assert_eq!(usage.input_tokens, 0);
967        assert_eq!(usage.output_tokens, 0);
968    }
969
970    #[tokio::test]
971    async fn test_generate_followup_questions_empty_findings() {
972        let coordinator = ResearchCoordinator::new(Box::new(ResearchMockLlm::new()), 2, 2);
973        let (questions, usage) = coordinator
974            .generate_followup_questions("original query", &[])
975            .await
976            .expect("empty findings should succeed");
977        assert!(questions.is_empty());
978        assert!(usage.is_none());
979    }
980}
981