use std::collections::{HashMap, HashSet};
use anyhow::{Context, Result};
use crate::engine::crawl_graph::CrawlGraphStore;
use crate::engine::search_engine::SearchEngine;
use crate::engine::web_index::{EngineOptions, LiveIndex, extract_keywords};
const MIN_RELEVANCE: f64 = 0.55;
const FALLBACK_RELEVANCE: f64 = 0.25;
const MAX_SEEDS: usize = 5;
const DISCOVERY_TIMEOUT_SECS: u64 = 25;
pub async fn discover_seeds(
indexer: &(dyn SearchEngine + Send + Sync),
graph: Option<&(dyn CrawlGraphStore + Send + Sync)>,
query: &str,
) -> Result<Vec<String>> {
let mut seen: HashSet<String> = HashSet::new();
let mut final_seeds: Vec<DiscoveredSeed> = Vec::new();
let generated = match tokio::time::timeout(
std::time::Duration::from_secs(DISCOVERY_TIMEOUT_SECS),
discover_from_live(query, &seen),
)
.await
{
Ok(Ok(seeds)) => seeds,
Ok(Err(e)) => {
tracing::warn!("live seed discovery failed: {}", e);
Vec::new()
}
Err(_) => {
tracing::warn!(
"live seed discovery timed out after {}s",
DISCOVERY_TIMEOUT_SECS
);
Vec::new()
}
};
for seed in generated {
if seen.insert(seed.url.clone()) {
final_seeds.push(seed);
}
}
let mut offline: Vec<DiscoveredSeed> = Vec::new();
offline.extend(discover_from_catalog(query));
offline.extend(discover_from_index(indexer, query).await?);
if let Some(graph) = graph {
offline.extend(discover_from_graph(graph, query).await);
}
offline.sort_by(|a, b| {
b.relevance
.partial_cmp(&a.relevance)
.unwrap_or(std::cmp::Ordering::Equal)
});
for seed in offline {
if seen.insert(seed.url.clone()) {
final_seeds.push(seed);
if final_seeds.len() >= MAX_SEEDS {
break;
}
}
}
let high_quality: Vec<DiscoveredSeed> = final_seeds
.iter()
.filter(|s| s.relevance >= MIN_RELEVANCE)
.cloned()
.collect();
if !high_quality.is_empty() {
final_seeds = high_quality;
} else {
let mut best: Vec<DiscoveredSeed> = final_seeds
.iter()
.filter(|s| s.relevance >= FALLBACK_RELEVANCE)
.cloned()
.collect();
if !best.is_empty() {
best.sort_by(|a, b| {
b.relevance
.partial_cmp(&a.relevance)
.unwrap_or(std::cmp::Ordering::Equal)
});
best.truncate(MAX_SEEDS);
final_seeds = best;
}
}
if final_seeds.is_empty() {
anyhow::bail!(
"No reachable seeds discovered for query '{}'. The index/graph is empty and no candidate URLs could be validated. Provide a specific seed URL.",
query,
);
}
let sanitized: Vec<String> = final_seeds
.into_iter()
.filter_map(|s| crate::engine::security_gate::sanitize_url(&s.url).ok())
.collect();
if sanitized.is_empty() {
anyhow::bail!(
"All candidate seeds for query '{}' were rejected by the security gate (SSRF or malformed URL).",
query,
);
}
Ok(sanitized)
}
#[derive(Debug, Clone)]
struct DiscoveredSeed {
url: String,
relevance: f64,
}
fn discover_from_catalog(query: &str) -> Vec<DiscoveredSeed> {
let keywords = extract_keywords(query);
if keywords.is_empty() {
return Vec::new();
}
let mut seeds: Vec<DiscoveredSeed> = Vec::new();
for domain in crate::engine::seed_catalog::DOMAINS {
let domain_matched = domain
.topics
.iter()
.any(|t| keywords.iter().any(|k| topics_match(k, t)));
if !domain_matched {
continue;
}
for source in domain.sources {
let url = source.url.to_lowercase();
let url_keyword_hits = keywords.iter().filter(|k| url.contains(k.as_str())).count();
let relevance = if url_keyword_hits > 0 {
let base = 0.95 + 0.05 * ((url_keyword_hits - 1) as f64).min(1.0);
if crate::engine::reddit::is_reddit_url(source.url) {
(base - 0.03).max(0.75)
} else {
base
}
} else {
0.75
};
seeds.push(DiscoveredSeed {
url: source.url.to_string(),
relevance,
});
}
}
let mut by_url: HashMap<String, f64> = HashMap::new();
for s in seeds {
by_url
.entry(s.url)
.and_modify(|r| *r = (*r).max(s.relevance))
.or_insert(s.relevance);
}
let mut seeds: Vec<DiscoveredSeed> = by_url
.into_iter()
.map(|(url, relevance)| DiscoveredSeed { url, relevance })
.collect();
seeds.sort_by(|a, b| {
b.relevance
.partial_cmp(&a.relevance)
.unwrap_or(std::cmp::Ordering::Equal)
});
seeds.truncate(MAX_SEEDS);
seeds
}
async fn discover_from_index(
indexer: &(dyn SearchEngine + Send + Sync),
query: &str,
) -> Result<Vec<DiscoveredSeed>> {
let mut results = Vec::new();
let bm25 = indexer
.search_bm25(query, MAX_SEEDS * 3)
.await
.context("index BM25 search failed")?;
for r in bm25 {
let relevance = (r.score / (r.score + 1.0)).clamp(0.0, 1.0);
results.push(DiscoveredSeed {
url: r.url,
relevance,
});
}
let vector = indexer
.search_vector(query, MAX_SEEDS * 3)
.await
.unwrap_or_default();
for (url, score) in vector {
let relevance = (score / (score + 1.0)).clamp(0.0, 1.0);
results.push(DiscoveredSeed { url, relevance });
}
let mut by_url: HashMap<String, DiscoveredSeed> = HashMap::new();
for seed in results {
by_url
.entry(seed.url.clone())
.and_modify(|e| {
if seed.relevance > e.relevance {
*e = seed.clone();
}
})
.or_insert(seed);
}
let mut seeds: Vec<DiscoveredSeed> = by_url.into_values().collect();
seeds.sort_by(|a, b| {
b.relevance
.partial_cmp(&a.relevance)
.unwrap_or(std::cmp::Ordering::Equal)
});
seeds.truncate(MAX_SEEDS * 2);
Ok(seeds)
}
async fn discover_from_graph(graph: &dyn CrawlGraphStore, query: &str) -> Vec<DiscoveredSeed> {
let keywords = extract_keywords(query);
let nodes = graph.get_urls().await;
let links = graph.get_all_links().await;
let mut inbound: HashMap<String, usize> = HashMap::new();
let mut anchors: HashMap<String, String> = HashMap::new();
for edge in &links {
*inbound.entry(edge.to.clone()).or_insert(0) += 1;
if let Some(text) = &edge.anchor_text {
anchors
.entry(edge.to.clone())
.and_modify(|t| {
t.push(' ');
t.push_str(text);
})
.or_insert_with(|| text.clone());
}
}
let mut seeds: Vec<DiscoveredSeed> = nodes
.into_iter()
.filter_map(|node| {
let anchor_text = anchors.get(&node.url).map(String::as_str).unwrap_or("");
let text = format!("{} {} {}", node.url, node.domain, anchor_text).to_lowercase();
let relevance = keyword_relevance(&keywords, &text);
if relevance > 0.0 {
let authority =
(*inbound.get(&node.url).unwrap_or(&0) as f64) / (links.len().max(1) as f64);
let combined = (relevance * 0.7 + authority * 0.3).clamp(0.0, 1.0);
Some(DiscoveredSeed {
url: node.url,
relevance: combined,
})
} else {
None
}
})
.collect();
seeds.sort_by(|a, b| {
b.relevance
.partial_cmp(&a.relevance)
.unwrap_or(std::cmp::Ordering::Equal)
});
seeds.truncate(MAX_SEEDS * 2);
seeds
}
async fn discover_from_live(query: &str, exclude: &HashSet<String>) -> Result<Vec<DiscoveredSeed>> {
let keywords = extract_keywords(query);
if keywords.is_empty() {
return Ok(Vec::new());
}
let index = LiveIndex::new().context("failed to build live search index")?;
let opts = EngineOptions {
max_results: 10,
..EngineOptions::default()
};
let outcome = index.search(query, &opts).await;
if outcome.engines_failed > 0 {
tracing::warn!(
"live seed discovery: {}/{} engines failed",
outcome.engines_failed,
outcome.total_engines
);
}
if outcome.fused.is_empty() {
return Ok(Vec::new());
}
let mut seeds: Vec<DiscoveredSeed> = Vec::new();
for hit in &outcome.fused {
if exclude.contains(&hit.url) {
continue;
}
let relevance =
live_hit_relevance(&keywords, &hit.title, &hit.snippet, hit.score, opts.rrf_k);
if relevance <= 0.0 {
continue;
}
seeds.push(DiscoveredSeed {
url: hit.url.clone(),
relevance,
});
}
seeds.sort_by(|a, b| {
b.relevance
.partial_cmp(&a.relevance)
.unwrap_or(std::cmp::Ordering::Equal)
});
seeds.truncate(MAX_SEEDS);
Ok(seeds)
}
fn live_hit_relevance(
keywords: &[String],
title: &str,
snippet: &str,
fused_score: f64,
rrf_k: u32,
) -> f64 {
let text = format!("{} {}", title, snippet).to_lowercase();
let keyword_rel = keyword_relevance(keywords, &text);
if keyword_rel <= 0.0 {
return 0.0;
}
let fused_norm = (fused_score * f64::from(rrf_k.max(1))).clamp(0.0, 1.0);
(keyword_rel * 0.8 + fused_norm * 0.2).clamp(0.0, 1.0)
}
fn topics_match(keyword: &str, topic: &str) -> bool {
if keyword.contains(topic) || topic.contains(keyword) {
return true;
}
let min = keyword.len().min(topic.len());
let prefix = (0..min)
.take_while(|&i| keyword.as_bytes()[i] == topic.as_bytes()[i])
.count();
prefix >= 4
}
fn keyword_relevance(keywords: &[String], text: &str) -> f64 {
if keywords.is_empty() {
return 0.0;
}
let text_lower = text.to_lowercase();
let matches = keywords
.iter()
.filter(|k| text_lower.contains(&k.to_lowercase()))
.count();
(matches as f64 / keywords.len() as f64).clamp(0.0, 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_keywords() {
let kw = extract_keywords("What is the latest Rust async runtime in 2026?");
assert!(kw.contains(&"rust".to_string()));
assert!(kw.contains(&"async".to_string()));
assert!(kw.contains(&"runtime".to_string()));
}
#[test]
fn test_live_hit_relevance_blends_keywords_and_fusion() {
let keywords = vec![
"rust".to_string(),
"async".to_string(),
"runtime".to_string(),
];
let high = live_hit_relevance(
&keywords,
"Rust async runtime",
"Tokio is a Rust async runtime",
0.0328, 60,
);
assert!(
high >= 0.55,
"full keyword match must clear the bar: {high}"
);
let zero = live_hit_relevance(&keywords, "Cooking recipes", "Pasta and sauce", 0.5, 60);
assert_eq!(zero, 0.0);
let shared = live_hit_relevance(&keywords, "Rust async", "runtime", 0.0328, 60);
let single = live_hit_relevance(&keywords, "Rust async", "runtime", 0.0164, 60);
assert!(shared > single);
}
#[test]
fn test_discover_from_catalog_prefers_authoritative_sources() {
let seeds = discover_from_catalog("rust programming language 2026");
assert!(
!seeds.is_empty(),
"catalog should return seeds for a programming query"
);
assert!(
seeds.iter().any(|s| s.url.contains("rust")),
"expected a rust-specific authoritative source, got: {:?}",
seeds
);
assert!(
seeds
.first()
.map(|s| s.url.contains("rust"))
.unwrap_or(false),
"rust-specific source should be first, got: {:?}",
seeds.first()
);
assert!(
seeds.iter().all(|s| s.url.starts_with("https://")),
"all catalog seeds must be https"
);
assert!(
seeds.iter().all(|s| s.relevance >= MIN_RELEVANCE),
"catalog seeds must clear the high-quality threshold"
);
}
#[test]
fn test_discover_from_catalog_ranks_exact_url_matches_first() {
let seeds = discover_from_catalog("rust async runtime tokio");
assert!(
seeds
.first()
.map(|s| s.url.contains("rust"))
.unwrap_or(false),
"rust URL match must rank first: {:?}",
seeds.first()
);
}
#[test]
fn test_discover_from_catalog_ignores_unrelated_topics() {
let seeds = discover_from_catalog("interest rate central bank");
assert!(
seeds.iter().all(|s| !s.url.contains("rust.org")),
"unrelated catalog domains should not match"
);
}
#[test]
fn test_discover_from_catalog_catches_security_query() {
let seeds = discover_from_catalog("secure software supply chain practices 2026");
assert!(
seeds.iter().any(|s| s.url.contains("securelist")
|| s.url.contains("krebsonsecurity")
|| s.url.contains("nvd.nist.gov")
|| s.url.contains("cloudflare.com")
|| s.url.contains("mandiant")),
"expected cybersecurity sources for a security query, got: {:?}",
seeds
);
}
}