1use std::collections::{HashMap, HashSet, VecDeque};
4use std::sync::Arc;
5use std::time::Duration;
6
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9use tokio::sync::{Mutex, RwLock, Semaphore};
10use tracing::debug;
11
12use crate::{GraphRAGError, GraphRAGResult, Triple};
13
14#[derive(Error, Debug)]
20pub enum DistributedError {
21 #[error("Endpoint {endpoint} is unreachable: {reason}")]
22 EndpointUnreachable { endpoint: String, reason: String },
23
24 #[error("Authentication failed for endpoint {endpoint}")]
25 AuthFailed { endpoint: String },
26
27 #[error("SPARQL query timeout after {timeout_ms}ms on endpoint {endpoint}")]
28 QueryTimeout { endpoint: String, timeout_ms: u64 },
29
30 #[error("Entity resolution cycle detected for URI {uri}")]
31 SameAsCycle { uri: String },
32
33 #[error("No healthy endpoints available for query")]
34 NoHealthyEndpoints,
35
36 #[error("Merge conflict: cannot reconcile {uri} across endpoints")]
37 MergeConflict { uri: String },
38
39 #[error("Configuration invalid: {0}")]
40 InvalidConfig(String),
41}
42
43impl From<DistributedError> for GraphRAGError {
44 fn from(e: DistributedError) -> Self {
45 GraphRAGError::InternalError(e.to_string())
46 }
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
55#[serde(tag = "type", rename_all = "snake_case")]
56pub enum EndpointAuth {
57 None,
59 Bearer { token: String },
61 Basic { username: String, password: String },
63 ApiKey { header: String, key: String },
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct EndpointConfig {
74 pub name: String,
76 pub url: String,
78 pub auth: EndpointAuth,
80 pub timeout_ms: Option<u64>,
82 pub priority: f64,
84 pub enabled: bool,
86 pub graph_uri: Option<String>,
88 pub max_triples: usize,
90}
91
92impl Default for EndpointConfig {
93 fn default() -> Self {
94 Self {
95 name: String::new(),
96 url: String::new(),
97 auth: EndpointAuth::None,
98 timeout_ms: None,
99 priority: 1.0,
100 enabled: true,
101 graph_uri: None,
102 max_triples: 10_000,
103 }
104 }
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct FederatedGraphRAGConfig {
110 pub endpoints: Vec<EndpointConfig>,
112 pub global_timeout_ms: u64,
114 pub max_concurrency: usize,
116 pub same_as_max_depth: usize,
118 pub min_endpoint_priority: f64,
120 pub partial_results_ok: bool,
122 pub retry_count: usize,
124 pub retry_delay_ms: u64,
126}
127
128impl Default for FederatedGraphRAGConfig {
129 fn default() -> Self {
130 Self {
131 endpoints: vec![],
132 global_timeout_ms: 30_000,
133 max_concurrency: 8,
134 same_as_max_depth: 5,
135 min_endpoint_priority: 0.0,
136 partial_results_ok: true,
137 retry_count: 2,
138 retry_delay_ms: 500,
139 }
140 }
141}
142
143impl FederatedGraphRAGConfig {
144 pub fn validate(&self) -> Result<(), DistributedError> {
146 if self.global_timeout_ms == 0 {
147 return Err(DistributedError::InvalidConfig(
148 "global_timeout_ms must be > 0".into(),
149 ));
150 }
151 if self.max_concurrency == 0 {
152 return Err(DistributedError::InvalidConfig(
153 "max_concurrency must be > 0".into(),
154 ));
155 }
156 if self.same_as_max_depth == 0 {
157 return Err(DistributedError::InvalidConfig(
158 "same_as_max_depth must be > 0".into(),
159 ));
160 }
161 for ep in &self.endpoints {
162 if ep.url.is_empty() {
163 return Err(DistributedError::InvalidConfig(format!(
164 "Endpoint '{}' has an empty URL",
165 ep.name
166 )));
167 }
168 if ep.max_triples == 0 {
169 return Err(DistributedError::InvalidConfig(format!(
170 "Endpoint '{}' max_triples must be > 0",
171 ep.name
172 )));
173 }
174 }
175 Ok(())
176 }
177
178 pub fn active_endpoints(&self) -> Vec<&EndpointConfig> {
180 self.endpoints
181 .iter()
182 .filter(|ep| ep.enabled && ep.priority >= self.min_endpoint_priority)
183 .collect()
184 }
185}
186
187#[derive(Debug, Clone, Default)]
193pub struct KnowledgeGraph {
194 pub triples: Vec<Triple>,
196 pub provenance: Vec<String>,
198 pub equivalence_classes: Vec<HashSet<String>>,
200 pub canonical_uris: HashMap<String, String>,
202}
203
204impl KnowledgeGraph {
205 pub fn new() -> Self {
207 Self::default()
208 }
209
210 pub fn triple_count(&self) -> usize {
212 self.triples.len()
213 }
214
215 pub fn is_empty(&self) -> bool {
217 self.triples.is_empty()
218 }
219
220 pub fn canonical<'a>(&'a self, uri: &'a str) -> &'a str {
222 self.canonical_uris
223 .get(uri)
224 .map(|s| s.as_str())
225 .unwrap_or(uri)
226 }
227}
228
229#[async_trait::async_trait]
237pub trait EndpointExecutor: Send + Sync {
238 async fn construct(
240 &self,
241 endpoint: &EndpointConfig,
242 sparql: &str,
243 timeout: Duration,
244 ) -> GraphRAGResult<Vec<Triple>>;
245
246 async fn select(
248 &self,
249 endpoint: &EndpointConfig,
250 sparql: &str,
251 timeout: Duration,
252 ) -> GraphRAGResult<Vec<HashMap<String, String>>>;
253}
254
255pub struct DistributedEntityResolver<E: EndpointExecutor> {
264 pub(super) config: FederatedGraphRAGConfig,
265 pub(super) executor: Arc<E>,
266}
267
268impl<E: EndpointExecutor + 'static> DistributedEntityResolver<E> {
269 pub fn new(config: FederatedGraphRAGConfig, executor: Arc<E>) -> Self {
271 Self { config, executor }
272 }
273
274 pub async fn same_as_closure(
277 &self,
278 uris: &[String],
279 ) -> GraphRAGResult<HashMap<String, String>> {
280 if uris.is_empty() {
281 return Ok(HashMap::new());
282 }
283
284 let parent: Arc<RwLock<HashMap<String, String>>> = Arc::new(RwLock::new(HashMap::new()));
285
286 {
287 let mut p = parent.write().await;
288 for uri in uris {
289 p.insert(uri.clone(), uri.clone());
290 }
291 }
292
293 let mut frontier: VecDeque<String> = uris.iter().cloned().collect();
294 let mut visited: HashSet<String> = HashSet::from_iter(uris.iter().cloned());
295 let mut depth = 0usize;
296
297 while !frontier.is_empty() && depth < self.config.same_as_max_depth {
298 let batch: Vec<String> = frontier.drain(..).collect();
299 let batch_refs: Vec<&str> = batch.iter().map(|s| s.as_str()).collect();
300
301 let links = self.fetch_same_as_links(&batch_refs).await?;
302
303 let mut p = parent.write().await;
304 for (a, b) in links {
305 p.entry(a.clone()).or_insert_with(|| a.clone());
306 p.entry(b.clone()).or_insert_with(|| b.clone());
307
308 let root_a = find_root_path(&p, &a);
309 let root_b = find_root_path(&p, &b);
310 if root_a != root_b {
311 let canonical = if root_a <= root_b {
312 root_a.clone()
313 } else {
314 root_b.clone()
315 };
316 p.insert(root_a, canonical.clone());
317 p.insert(root_b, canonical);
318 }
319
320 if !visited.contains(&b) {
321 visited.insert(b.clone());
322 frontier.push_back(b);
323 }
324 }
325
326 depth += 1;
327 }
328
329 let p = parent.read().await;
330 let mut result = HashMap::new();
331 for uri in p.keys() {
332 let canonical = find_root_path(&p, uri);
333 result.insert(uri.clone(), canonical);
334 }
335 Ok(result)
336 }
337
338 async fn fetch_same_as_links(&self, uris: &[&str]) -> GraphRAGResult<Vec<(String, String)>> {
340 let active = self.config.active_endpoints();
341 let semaphore = Arc::new(Semaphore::new(self.config.max_concurrency));
342 let pairs: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
343 let mut handles = Vec::new();
344
345 for ep in active {
346 let ep = ep.clone();
347 let executor = Arc::clone(&self.executor);
348 let sem = Arc::clone(&semaphore);
349 let pairs = Arc::clone(&pairs);
350 let uris_owned: Vec<String> = uris.iter().map(|s| s.to_string()).collect();
351 let timeout_ms = ep.timeout_ms.unwrap_or(self.config.global_timeout_ms);
352 let timeout = Duration::from_millis(timeout_ms);
353
354 let handle = tokio::spawn(async move {
355 let _permit = match sem.acquire_owned().await {
356 Ok(p) => p,
357 Err(_) => return,
358 };
359
360 let sparql = build_same_as_sparql(
361 &uris_owned.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
362 ep.graph_uri.as_deref(),
363 );
364
365 match executor.select(&ep, &sparql, timeout).await {
366 Ok(rows) => {
367 let mut guard = pairs.lock().await;
368 for row in rows {
369 if let (Some(a), Some(b)) = (row.get("a"), row.get("b")) {
370 guard.push((a.clone(), b.clone()));
371 }
372 }
373 }
374 Err(e) => {
375 debug!(endpoint = %ep.name, error = %e, "sameAs fetch failed");
376 }
377 }
378 });
379
380 handles.push(handle);
381 }
382
383 for h in handles {
384 let _ = h.await;
385 }
386
387 let guard = Arc::try_unwrap(pairs)
388 .map_err(|_| GraphRAGError::InternalError("Arc unwrap failed".into()))?
389 .into_inner();
390
391 Ok(guard)
392 }
393
394 pub fn apply_to_graph(&self, kg: &mut KnowledgeGraph, canonical_map: &HashMap<String, String>) {
397 let canonicalize = |s: &str| -> String {
398 canonical_map
399 .get(s)
400 .cloned()
401 .unwrap_or_else(|| s.to_string())
402 };
403
404 let mut seen: HashSet<(String, String, String)> = HashSet::new();
405 let mut new_triples = Vec::new();
406 let mut new_provenance = Vec::new();
407
408 for (triple, prov) in kg.triples.iter().zip(kg.provenance.iter()) {
409 let s = canonicalize(&triple.subject);
410 let p = triple.predicate.clone();
411 let o = canonicalize(&triple.object);
412 let key = (s.clone(), p.clone(), o.clone());
413 if seen.insert(key) {
414 new_triples.push(Triple::new(s, p, o));
415 new_provenance.push(prov.clone());
416 }
417 }
418
419 kg.triples = new_triples;
420 kg.provenance = new_provenance;
421 kg.canonical_uris = canonical_map.clone();
422
423 let mut classes: HashMap<String, HashSet<String>> = HashMap::new();
424 for (uri, canonical) in canonical_map {
425 classes
426 .entry(canonical.clone())
427 .or_default()
428 .insert(uri.clone());
429 }
430 kg.equivalence_classes = classes.into_values().collect();
431 }
432}
433
434pub(super) fn find_root_path(parent: &HashMap<String, String>, uri: &str) -> String {
436 let mut current = uri.to_string();
437 let mut depth = 0usize;
438 loop {
439 let next = parent
440 .get(¤t)
441 .cloned()
442 .unwrap_or_else(|| current.clone());
443 if next == current || depth > 100 {
444 return current;
445 }
446 current = next;
447 depth += 1;
448 }
449}
450
451pub(super) fn build_same_as_sparql(uris: &[&str], graph_uri: Option<&str>) -> String {
453 let values: Vec<String> = uris.iter().map(|s| format!("<{}>", s)).collect();
454 let values_block = values.join(" ");
455
456 let from_clause = match graph_uri {
457 Some(g) => format!("FROM <{}>", g),
458 None => String::new(),
459 };
460
461 format!(
462 r#"SELECT DISTINCT ?a ?b
463{from}
464WHERE {{
465 VALUES ?a {{ {uris} }}
466 {{
467 ?a <http://www.w3.org/2002/07/owl#sameAs> ?b .
468 }} UNION {{
469 ?b <http://www.w3.org/2002/07/owl#sameAs> ?a .
470 }}
471}}
472"#,
473 from = from_clause,
474 uris = values_block,
475 )
476}
477
478#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
484pub enum ContextOrderingStrategy {
485 ByEndpointPriority,
487 ByLatency,
489 Insertion,
491}
492
493#[derive(Debug, Clone, Serialize, Deserialize)]
495pub struct FederatedContextConfig {
496 pub max_context_triples: usize,
498 pub max_context_chars: usize,
500 pub ordering: ContextOrderingStrategy,
502 pub include_provenance: bool,
504 pub min_endpoint_priority: f64,
506 pub include_equivalences: bool,
508}
509
510impl Default for FederatedContextConfig {
511 fn default() -> Self {
512 Self {
513 max_context_triples: 500,
514 max_context_chars: 50_000,
515 ordering: ContextOrderingStrategy::ByEndpointPriority,
516 include_provenance: false,
517 include_equivalences: false,
518 min_endpoint_priority: 0.0,
519 }
520 }
521}
522
523pub struct FederatedContextBuilder {
525 config: FederatedContextConfig,
526 endpoint_priorities: HashMap<String, f64>,
528 endpoint_latencies: Arc<RwLock<HashMap<String, u64>>>,
530}
531
532impl FederatedContextBuilder {
533 pub fn new(config: FederatedContextConfig, graphrag_config: &FederatedGraphRAGConfig) -> Self {
535 let endpoint_priorities: HashMap<String, f64> = graphrag_config
536 .endpoints
537 .iter()
538 .map(|ep| (ep.name.clone(), ep.priority))
539 .collect();
540
541 Self {
542 config,
543 endpoint_priorities,
544 endpoint_latencies: Arc::new(RwLock::new(HashMap::new())),
545 }
546 }
547
548 pub async fn record_latency(&self, endpoint_name: &str, latency_ms: u64) {
550 let mut lats = self.endpoint_latencies.write().await;
551 lats.insert(endpoint_name.to_string(), latency_ms);
552 }
553
554 pub async fn build_context(&self, kg: &KnowledgeGraph, query: &str) -> GraphRAGResult<String> {
556 if kg.is_empty() {
557 return Ok(String::new());
558 }
559
560 let latencies = self.endpoint_latencies.read().await;
561 let mut indexed: Vec<(usize, f64)> = kg
562 .triples
563 .iter()
564 .enumerate()
565 .map(|(i, _)| {
566 let ep = kg.provenance.get(i).map(|s| s.as_str()).unwrap_or("");
567 let sort_key = match self.config.ordering {
568 ContextOrderingStrategy::ByEndpointPriority => {
569 self.endpoint_priorities.get(ep).copied().unwrap_or(1.0)
570 }
571 ContextOrderingStrategy::ByLatency => {
572 let lat = latencies.get(ep).copied().unwrap_or(u64::MAX);
573 1.0 / (lat as f64 + 1.0)
574 }
575 ContextOrderingStrategy::Insertion => i as f64,
576 };
577 (i, sort_key)
578 })
579 .filter(|(i, _)| {
580 let ep = kg.provenance.get(*i).map(|s| s.as_str()).unwrap_or("");
581 let prio = self.endpoint_priorities.get(ep).copied().unwrap_or(1.0);
582 prio >= self.config.min_endpoint_priority
583 })
584 .collect();
585
586 match self.config.ordering {
587 ContextOrderingStrategy::ByEndpointPriority | ContextOrderingStrategy::ByLatency => {
588 indexed.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
589 }
590 ContextOrderingStrategy::Insertion => {
591 indexed.sort_by_key(|(i, _)| *i);
592 }
593 }
594
595 let mut context = format!("## Knowledge Graph Context\n\nQuery: {}\n\n", query);
596
597 if self.config.include_equivalences && !kg.equivalence_classes.is_empty() {
598 context.push_str("### Entity Equivalences\n");
599 for class in &kg.equivalence_classes {
600 if class.len() > 1 {
601 let mut members: Vec<&str> = class.iter().map(|s| s.as_str()).collect();
602 members.sort();
603 context.push_str(&format!("- {}\n", members.join(" ≡ ")));
604 }
605 }
606 context.push('\n');
607 }
608
609 context.push_str("### Facts\n\n");
610
611 for (triple_count, (idx, _)) in indexed.iter().enumerate() {
612 if triple_count >= self.config.max_context_triples {
613 break;
614 }
615 if context.len() >= self.config.max_context_chars {
616 break;
617 }
618
619 let triple = &kg.triples[*idx];
620 let line = if self.config.include_provenance {
621 let ep = kg.provenance.get(*idx).map(|s| s.as_str()).unwrap_or("?");
622 format!(
623 "- {} → {} → {} [{}]\n",
624 triple.subject, triple.predicate, triple.object, ep
625 )
626 } else {
627 format!(
628 "- {} → {} → {}\n",
629 triple.subject, triple.predicate, triple.object
630 )
631 };
632
633 context.push_str(&line);
634 }
635
636 Ok(context)
637 }
638}