use ahash::AHashMap;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use anyhow::Result;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use crate::schema::content::PageContentRecord;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DiscoverySource {
Seed,
Sitemap,
LlmsTxt,
LinkCrawl,
ExternalLink,
LiveSearch,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UrlNode {
pub url: String,
pub domain: String,
pub source: DiscoverySource,
pub depth: u32,
pub priority: f32,
pub lastmod: Option<DateTime<Utc>>,
pub changefreq: Option<String>,
pub discovered_at: DateTime<Utc>,
pub crawled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LinkEdge {
pub from: String,
pub to: String,
pub anchor_text: Option<String>,
#[serde(default)]
pub edge_type: EdgeType,
#[serde(default = "default_edge_weight")]
pub weight: f64,
#[serde(default)]
pub created_at: Option<DateTime<Utc>>,
}
fn default_edge_weight() -> f64 {
1.0
}
impl Default for LinkEdge {
fn default() -> Self {
Self {
from: String::new(),
to: String::new(),
anchor_text: None,
edge_type: EdgeType::Hyperlink,
weight: default_edge_weight(),
created_at: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum EdgeType {
#[default]
Hyperlink,
Cooccurrence,
Contains,
References,
ParentOf,
Cites,
}
impl EdgeType {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
EdgeType::Hyperlink => "hyperlink",
EdgeType::Cooccurrence => "cooccurrence",
EdgeType::Contains => "contains",
EdgeType::References => "references",
EdgeType::ParentOf => "parent_of",
EdgeType::Cites => "cites",
}
}
#[must_use]
pub fn parse(s: &str) -> Self {
match s {
"cooccurrence" => EdgeType::Cooccurrence,
"contains" => EdgeType::Contains,
"references" => EdgeType::References,
"parent_of" => EdgeType::ParentOf,
"cites" => EdgeType::Cites,
_ => EdgeType::Hyperlink,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrawlJob {
pub id: String,
pub url: String,
pub status: String,
pub attempts: i32,
pub error: Option<String>,
}
#[async_trait]
pub trait CrawlGraphStore: Send + Sync {
async fn record_url(&self, node: UrlNode);
async fn record_link(&self, edge: LinkEdge);
async fn get_url(&self, url: &str) -> Option<UrlNode>;
async fn get_urls(&self) -> Vec<UrlNode>;
async fn get_links_from(&self, url: &str) -> Vec<LinkEdge>;
async fn get_links_to(&self, url: &str) -> Vec<LinkEdge>;
async fn get_all_links(&self) -> Vec<LinkEdge>;
async fn graph_version(&self) -> String;
async fn record_page_content(&self, content: PageContentRecord) -> Result<()>;
async fn record_embedding(&self, url: &str, embedding: Vec<f32>) -> Result<()>;
async fn enqueue_crawl_job(&self, url: &str) -> Result<String>;
async fn dequeue_crawl_jobs(&self, limit: usize) -> Result<Vec<CrawlJob>>;
async fn mark_crawl_job_status(
&self,
id: &str,
status: &str,
error: Option<&str>,
) -> Result<()>;
async fn find_duplicate(&self, url: &str, content_text: &str) -> Result<Option<String>> {
let _ = (url, content_text);
Ok(None)
}
async fn traverse_with_depth(
&self,
start: &str,
max_depth: u32,
direction: TraversalDirection,
) -> Vec<TraversalNode> {
let mut depth_map: HashMap<String, u32> = HashMap::new();
let mut current = vec![start.to_string()];
depth_map.insert(start.to_string(), 0);
for d in 1..=max_depth {
let mut next = Vec::new();
for url in ¤t {
let edges: Vec<LinkEdge> = match direction {
TraversalDirection::Inbound => self.get_links_to(url).await,
TraversalDirection::Outbound => self.get_links_from(url).await,
TraversalDirection::Both => {
let mut e = self.get_links_from(url).await;
e.extend(self.get_links_to(url).await);
e
}
};
for edge in edges {
let neighbor = match direction {
TraversalDirection::Inbound => edge.from,
TraversalDirection::Outbound => edge.to,
TraversalDirection::Both => {
if edge.from == *url {
edge.to
} else {
edge.from
}
}
};
if !depth_map.contains_key(&neighbor) {
depth_map.insert(neighbor.clone(), d);
next.push(neighbor);
}
}
}
if next.is_empty() {
break;
}
current = next;
}
let mut nodes: Vec<TraversalNode> = depth_map
.into_iter()
.map(|(url, depth)| TraversalNode { url, depth })
.collect();
nodes.sort_by(|a, b| a.depth.cmp(&b.depth).then_with(|| a.url.cmp(&b.url)));
nodes
}
async fn traverse(
&self,
start: &str,
max_depth: u32,
direction: TraversalDirection,
) -> Vec<String> {
self.traverse_with_depth(start, max_depth, direction)
.await
.into_iter()
.map(|n| n.url)
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TraversalNode {
pub url: String,
pub depth: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TraversalDirection {
Inbound,
Outbound,
Both,
}
pub async fn traverse_graph(
store: Arc<dyn CrawlGraphStore>,
start: &str,
max_depth: u32,
direction: TraversalDirection,
) -> Vec<String> {
store.traverse(start, max_depth, direction).await
}
pub async fn traverse_graph_with_depth(
store: Arc<dyn CrawlGraphStore>,
start: &str,
max_depth: u32,
direction: TraversalDirection,
) -> Vec<TraversalNode> {
store.traverse_with_depth(start, max_depth, direction).await
}
pub async fn compute_pagerank(
store: &dyn CrawlGraphStore,
iterations: usize,
damping: f64,
) -> HashMap<String, f64> {
let nodes: Vec<String> = store.get_urls().await.into_iter().map(|n| n.url).collect();
let edges = store.get_all_links().await;
let n = nodes.len();
if n == 0 {
return HashMap::new();
}
let mut out_degree: AHashMap<String, usize> = AHashMap::new();
let mut incoming: AHashMap<String, Vec<String>> = AHashMap::new();
for edge in edges {
*out_degree.entry(edge.from.clone()).or_insert(0) += 1;
incoming.entry(edge.to.clone()).or_default().push(edge.from);
}
let base = (1.0 - damping) / n as f64;
let mut scores: AHashMap<String, f64> = nodes.iter().map(|u| (u.clone(), base)).collect();
let active: Vec<&String> = nodes.iter().filter(|u| incoming.contains_key(*u)).collect();
for _ in 0..iterations.max(1) {
let mut new_scores = AHashMap::with_capacity(active.len());
for url in &active {
let mut rank = base;
if let Some(in_nodes) = incoming.get(*url) {
for in_url in in_nodes {
if let Some(s) = scores.get(in_url) {
let out_deg = out_degree.get(in_url).copied().unwrap_or(1).max(1);
rank += damping * s / out_deg as f64;
}
}
}
new_scores.insert((*url).clone(), rank);
}
for (url, rank) in new_scores {
scores.insert(url, rank);
}
}
scores.into_iter().collect()
}
pub async fn compute_pagerank_incremental(
store: &dyn CrawlGraphStore,
previous_scores: &HashMap<String, f64>,
changed_urls: &[String],
iterations: usize,
damping: f64,
max_propagation_depth: usize,
) -> HashMap<String, f64> {
let nodes: Vec<String> = store.get_urls().await.into_iter().map(|n| n.url).collect();
let edges = store.get_all_links().await;
let n = nodes.len();
if n == 0 {
return HashMap::new();
}
let mut out_degree: AHashMap<String, usize> = AHashMap::new();
let mut incoming: AHashMap<String, Vec<String>> = AHashMap::new();
for edge in edges {
*out_degree.entry(edge.from.clone()).or_insert(0) += 1;
incoming.entry(edge.to.clone()).or_default().push(edge.from);
}
let base = (1.0 - damping) / n as f64;
let mut scores = previous_scores.clone();
for url in &nodes {
scores.entry(url.clone()).or_insert(base);
}
let mut affected: AHashMap<String, usize> = AHashMap::new();
let mut queue: std::collections::VecDeque<(String, usize)> = std::collections::VecDeque::new();
for url in changed_urls {
if !affected.contains_key(url) {
affected.insert(url.clone(), 0);
queue.push_back((url.clone(), 0));
}
}
while let Some((url, depth)) = queue.pop_front() {
if depth >= max_propagation_depth {
continue;
}
if let Some(out_nodes) = incoming.get(&url) {
for in_url in out_nodes {
if !affected.contains_key(in_url) {
affected.insert(in_url.clone(), depth + 1);
queue.push_back((in_url.clone(), depth + 1));
}
}
}
}
let active: Vec<&String> = affected.keys().collect();
for _ in 0..iterations.max(1) {
let mut new_scores = AHashMap::with_capacity(active.len());
for url in &active {
let mut rank = base;
if let Some(in_nodes) = incoming.get(*url) {
for in_url in in_nodes {
if let Some(s) = scores.get(in_url) {
let out_deg = out_degree.get(in_url).copied().unwrap_or(1).max(1);
rank += damping * s / out_deg as f64;
}
}
}
new_scores.insert((*url).clone(), rank);
}
for (url, rank) in new_scores {
scores.insert(url, rank);
}
}
scores
}
pub struct InMemoryCrawlGraph {
urls: DashMap<String, UrlNode>,
edges_from: DashMap<String, Vec<LinkEdge>>,
edges_to: DashMap<String, Vec<LinkEdge>>,
page_content: DashMap<String, PageContentRecord>,
embeddings: DashMap<String, Vec<f32>>,
jobs: DashMap<String, CrawlJob>,
version: AtomicU64,
}
impl InMemoryCrawlGraph {
pub fn new() -> Self {
Self {
urls: DashMap::new(),
edges_from: DashMap::new(),
edges_to: DashMap::new(),
page_content: DashMap::new(),
embeddings: DashMap::new(),
jobs: DashMap::new(),
version: AtomicU64::new(1),
}
}
fn bump_version(&self) {
self.version.fetch_add(1, Ordering::SeqCst);
}
}
impl Default for InMemoryCrawlGraph {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl CrawlGraphStore for InMemoryCrawlGraph {
async fn record_url(&self, node: UrlNode) {
self.urls.insert(node.url.clone(), node);
self.bump_version();
}
async fn record_link(&self, edge: LinkEdge) {
self.edges_from
.entry(edge.from.clone())
.or_default()
.push(edge.clone());
self.edges_to.entry(edge.to.clone()).or_default().push(edge);
self.bump_version();
}
async fn get_url(&self, url: &str) -> Option<UrlNode> {
self.urls.get(url).map(|r| r.clone())
}
async fn get_urls(&self) -> Vec<UrlNode> {
self.urls.iter().map(|r| r.value().clone()).collect()
}
async fn get_links_from(&self, url: &str) -> Vec<LinkEdge> {
self.edges_from
.get(url)
.map(|r| r.clone())
.unwrap_or_default()
}
async fn get_links_to(&self, url: &str) -> Vec<LinkEdge> {
self.edges_to
.get(url)
.map(|r| r.clone())
.unwrap_or_default()
}
async fn get_all_links(&self) -> Vec<LinkEdge> {
self.edges_from
.iter()
.flat_map(|r| r.value().clone())
.collect()
}
async fn graph_version(&self) -> String {
self.version.load(Ordering::SeqCst).to_string()
}
async fn record_page_content(&self, content: PageContentRecord) -> Result<()> {
self.page_content.insert(content.url_node.clone(), content);
Ok(())
}
async fn record_embedding(&self, url: &str, embedding: Vec<f32>) -> Result<()> {
self.embeddings.insert(url.to_string(), embedding);
Ok(())
}
async fn enqueue_crawl_job(&self, url: &str) -> Result<String> {
let id = format!("job-{}", self.jobs.len() + 1);
self.jobs.insert(
id.clone(),
CrawlJob {
id: id.clone(),
url: url.to_string(),
status: "pending".to_string(),
attempts: 0,
error: None,
},
);
Ok(id)
}
async fn dequeue_crawl_jobs(&self, _limit: usize) -> Result<Vec<CrawlJob>> {
Ok(self
.jobs
.iter()
.filter(|j| j.status == "pending")
.map(|j| j.value().clone())
.collect())
}
async fn mark_crawl_job_status(
&self,
id: &str,
status: &str,
error: Option<&str>,
) -> Result<()> {
if let Some(mut job) = self.jobs.get_mut(id) {
job.status = status.to_string();
job.error = error.map(|s| s.to_string());
job.attempts += 1;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_in_memory_graph_records_urls_and_links() {
let graph = InMemoryCrawlGraph::new();
graph
.record_url(UrlNode {
url: "https://example.com/".to_string(),
domain: "example.com".to_string(),
source: DiscoverySource::Seed,
depth: 0,
priority: 1.0,
lastmod: None,
changefreq: None,
discovered_at: Utc::now(),
crawled: false,
})
.await;
graph
.record_url(UrlNode {
url: "https://example.com/a".to_string(),
domain: "example.com".to_string(),
source: DiscoverySource::LinkCrawl,
depth: 1,
priority: 0.5,
lastmod: None,
changefreq: None,
discovered_at: Utc::now(),
crawled: false,
})
.await;
graph
.record_link(hyperlink("https://example.com/", "https://example.com/a", Some("link")))
.await;
assert_eq!(graph.get_urls().await.len(), 2);
assert_eq!(graph.get_links_from("https://example.com/").await.len(), 1);
assert_eq!(graph.get_links_to("https://example.com/a").await.len(), 1);
}
fn hyperlink(from: &str, to: &str, anchor: Option<&str>) -> LinkEdge {
LinkEdge {
from: from.to_string(),
to: to.to_string(),
anchor_text: anchor.map(str::to_string),
..Default::default()
}
}
#[tokio::test]
async fn test_traverse_graph_outbound() {
let graph = Arc::new(InMemoryCrawlGraph::new());
for url in [
"https://example.com/",
"https://example.com/a",
"https://example.com/b",
"https://example.com/c",
] {
graph
.record_url(UrlNode {
url: url.to_string(),
domain: "example.com".to_string(),
source: DiscoverySource::LinkCrawl,
depth: 1,
priority: 0.5,
lastmod: None,
changefreq: None,
discovered_at: Utc::now(),
crawled: false,
})
.await;
}
graph
.record_link(hyperlink("https://example.com/", "https://example.com/a", None))
.await;
graph
.record_link(hyperlink("https://example.com/a", "https://example.com/b", None))
.await;
graph
.record_link(hyperlink("https://example.com/", "https://example.com/c", None))
.await;
let visited = traverse_graph(
graph.clone(),
"https://example.com/",
2,
TraversalDirection::Outbound,
)
.await;
let urls: Vec<&str> = visited.iter().map(|s| s.as_str()).collect();
assert!(urls.contains(&"https://example.com/"));
assert!(urls.contains(&"https://example.com/a"));
assert!(urls.contains(&"https://example.com/b"));
assert!(urls.contains(&"https://example.com/c"));
}
#[tokio::test]
async fn test_traverse_graph_depth_zero() {
let graph = Arc::new(InMemoryCrawlGraph::new());
graph
.record_url(UrlNode {
url: "https://example.com/".to_string(),
domain: "example.com".to_string(),
source: DiscoverySource::Seed,
depth: 0,
priority: 1.0,
lastmod: None,
changefreq: None,
discovered_at: Utc::now(),
crawled: false,
})
.await;
graph
.record_link(hyperlink("https://example.com/", "https://example.com/a", None))
.await;
let visited = traverse_graph(
graph.clone(),
"https://example.com/",
0,
TraversalDirection::Outbound,
)
.await;
assert_eq!(visited, vec!["https://example.com/".to_string()]);
}
#[tokio::test]
async fn test_compute_pagerank_star_graph() {
let graph = InMemoryCrawlGraph::new();
for url in [
"https://example.com/",
"https://example.com/a",
"https://example.com/b",
] {
graph
.record_url(UrlNode {
url: url.to_string(),
domain: "example.com".to_string(),
source: DiscoverySource::LinkCrawl,
depth: 1,
priority: 0.5,
lastmod: None,
changefreq: None,
discovered_at: Utc::now(),
crawled: false,
})
.await;
}
graph
.record_link(hyperlink("https://example.com/a", "https://example.com/", None))
.await;
graph
.record_link(hyperlink("https://example.com/b", "https://example.com/", None))
.await;
let scores = compute_pagerank(&graph, 20, 0.85).await;
let root = scores.get("https://example.com/").copied().unwrap();
let leaf = scores.get("https://example.com/a").copied().unwrap();
assert!(
root > leaf,
"root should have higher PageRank than leaves: {} vs {}",
root,
leaf
);
}
#[tokio::test]
async fn test_compute_pagerank_empty_graph() {
let graph = InMemoryCrawlGraph::new();
let scores = compute_pagerank(&graph, 10, 0.85).await;
assert!(scores.is_empty());
}
#[test]
fn edge_type_round_trips_through_its_string_form() {
for edge_type in [
EdgeType::Hyperlink,
EdgeType::Cooccurrence,
EdgeType::Contains,
EdgeType::References,
EdgeType::ParentOf,
EdgeType::Cites,
] {
assert_eq!(
EdgeType::parse(edge_type.as_str()),
edge_type,
"{edge_type:?} must survive as_str -> parse"
);
}
}
#[test]
fn edge_type_defaults_to_hyperlink() {
assert_eq!(EdgeType::default(), EdgeType::Hyperlink);
assert_eq!(EdgeType::parse("hyperlink"), EdgeType::Hyperlink);
assert_eq!(EdgeType::parse("some_future_relation"), EdgeType::Hyperlink);
assert_eq!(EdgeType::parse(""), EdgeType::Hyperlink);
}
#[test]
fn link_edge_default_is_a_weight_one_hyperlink() {
let edge = LinkEdge::default();
assert_eq!(edge.edge_type, EdgeType::Hyperlink);
assert!((edge.weight - 1.0).abs() < f64::EPSILON);
assert!(edge.created_at.is_none());
}
#[tokio::test]
async fn typed_weighted_edges_preserve_type_and_weight() {
let graph = InMemoryCrawlGraph::new();
for url in ["https://example.com/a", "https://example.com/b"] {
graph
.record_url(UrlNode {
url: url.to_string(),
domain: "example.com".to_string(),
source: DiscoverySource::LinkCrawl,
depth: 1,
priority: 0.5,
lastmod: None,
changefreq: None,
discovered_at: Utc::now(),
crawled: false,
})
.await;
}
graph
.record_link(LinkEdge {
from: "https://example.com/a".to_string(),
to: "https://example.com/b".to_string(),
anchor_text: None,
edge_type: EdgeType::Cooccurrence,
weight: 5.0,
created_at: None,
})
.await;
let edges = graph.get_links_from("https://example.com/a").await;
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].edge_type, EdgeType::Cooccurrence);
assert!((edges[0].weight - 5.0).abs() < f64::EPSILON);
}
}