oxirs_graphrag/lib.rs
1//! # OxiRS GraphRAG
2//!
3//! **GraphRAG** (Graph Retrieval-Augmented Generation) is a production-ready
4//! Rust library that combines **knowledge-graph topology traversal** with
5//! **vector similarity search** to deliver context-rich answers for LLM
6//! pipelines — without any network dependencies at query time.
7//!
8//! It is the JVM-free, pure-Rust counterpart of Microsoft's GraphRAG and
9//! LangChain's knowledge-graph QA stack, integrated directly with the OxiRS
10//! semantic-web engine.
11//!
12//! ## Data-flow overview
13//!
14//! ```text
15//! Natural-Language Query
16//! │
17//! ▼
18//! ┌───────────────────┐
19//! │ Query Embedding │ (oxirs-embed / Node2Vec / TransE)
20//! └────────┬──────────┘
21//! │
22//! ┌──────┴──────┐
23//! │ │
24//! ▼ ▼
25//! Vector Keyword
26//! KNN BM25
27//! Search Search
28//! │ │
29//! └──────┬──────┘
30//! │
31//! ▼
32//! ┌───────────────┐
33//! │ RRF Fusion │ Reciprocal Rank Fusion → Seed Entities
34//! └───────┬───────┘
35//! │
36//! ▼
37//! ┌────────────────────────┐
38//! │ SPARQL N-hop Expansion│ Graph traversal (up to 500 triples)
39//! └────────────┬───────────┘
40//! │
41//! ▼
42//! ┌────────────────────────┐
43//! │ Community Detection │ Louvain / Leiden clustering
44//! └────────────┬───────────┘
45//! │
46//! ▼
47//! ┌────────────────────────┐
48//! │ Context Building │ Subgraph → natural-language context
49//! └────────────┬───────────┘
50//! │
51//! ▼
52//! ┌────────────────────────┐
53//! │ LLM Generation │ Answer + citations
54//! └────────────────────────┘
55//! ```
56//!
57//! ## Key modules
58//!
59//! | Module | Purpose |
60//! |--------|---------|
61//! [`triple_extractor`] | Rule-based NLP → RDF triple extraction |
62//! [`community_detector`] | Greedy label-propagation community detection |
63//! [`path_finder`] | BFS / DFS shortest-path retrieval in KGs |
64//! [`graph_embedder`] | Node2Vec-style random-walk structural embeddings |
65//! [`summarizer`] | Cluster-based subgraph summarization for LLM context |
66//! [`path_ranker`] | Predicate-weighted path ranking |
67//! [`context_builder`] | N-hop subgraph extraction and truncation |
68//! [`knowledge_fusion`] | Multi-source KG fusion with provenance |
69//! [`graph_summarization`] | PageRank-style community summary generation |
70//! [`entity_linking`] | Entity linking and disambiguation |
71//! [`explainability`] | Attention weights, path explanation, provenance |
72//! [`feedback`] | Session-scoped user-feedback weight adaptation |
73//! [`graph`] | Core community detection and graph traversal primitives |
74//! [`retrieval`] | Hybrid vector + keyword retrieval with RRF fusion |
75//! [`generation`] | Prompt templates and LLM context building |
76//! [`temporal`] | Temporal knowledge graph retrieval |
77//!
78//! ## Quickstart — standalone pipeline (no network, no LLM)
79//!
80//! The example below runs an end-to-end mini-pipeline entirely in memory on a
81//! synthetic 8-node knowledge graph: extract triples from text, detect
82//! communities, find paths, and summarize the result.
83//!
84//! ```rust
85//! use oxirs_graphrag::triple_extractor::{ExtractionConfig, TripleExtractor};
86//! use oxirs_graphrag::community_detector::{CommunityGraph, CommunityDetector};
87//! use oxirs_graphrag::path_finder::{KnowledgeEdge, PathFinder, PathFinderConfig};
88//! use oxirs_graphrag::summarizer::{KgEdge, KgNode, KgSubgraph, SubgraphSummarizer};
89//!
90//! // ── Step 1: Extract triples from natural language ─────────────────────────
91//! let corpus = [
92//! "Alice is a data scientist.",
93//! "Bob works at ACME.",
94//! "Carol is a software engineer.",
95//! "Dave is part of the AI team.",
96//! "ACME has a research division.",
97//! ];
98//! let extractor = TripleExtractor::with_defaults(ExtractionConfig::default());
99//! let all_triples: Vec<_> = corpus
100//! .iter()
101//! .flat_map(|sentence| extractor.extract(sentence))
102//! .collect();
103//! assert!(!all_triples.is_empty(), "at least one triple extracted");
104//!
105//! // ── Step 2: Build community graph and detect clusters ─────────────────────
106//! let mut cg = CommunityGraph::new();
107//! // 8 synthetic nodes
108//! for (id, label) in [
109//! (1u64, "Alice"), (2, "Bob"), (3, "Carol"), (4, "Dave"),
110//! (5, "ACME"), (6, "AI-Team"), (7, "Research"), (8, "Berlin"),
111//! ] {
112//! cg.add_node(id, label);
113//! }
114//! for (a, b) in [(1,5),(2,5),(3,6),(4,6),(5,7),(6,7),(7,8),(1,2)] {
115//! cg.add_edge(a, b, 1.0);
116//! }
117//! let detector = CommunityDetector::new(2, 50);
118//! let detection = detector.detect(&mut cg);
119//! assert!(!detection.communities.is_empty(), "at least one community");
120//!
121//! // ── Step 3: Graph path retrieval ──────────────────────────────────────────
122//! let edges = vec![
123//! KnowledgeEdge::new("Alice", "works_at", "ACME"),
124//! KnowledgeEdge::new("ACME", "located_in", "Berlin"),
125//! KnowledgeEdge::new("Bob", "knows", "Alice"),
126//! KnowledgeEdge::new("Alice", "member_of", "AI-Team"),
127//! KnowledgeEdge::new("AI-Team", "part_of", "ACME"),
128//! KnowledgeEdge::new("Carol", "works_at", "ACME"),
129//! KnowledgeEdge::new("Dave", "leads", "AI-Team"),
130//! KnowledgeEdge::new("Research", "division_of", "ACME"),
131//! ];
132//! let finder = PathFinder::new(edges, PathFinderConfig::default());
133//! let paths = finder.bfs_paths("Bob", "Berlin", 4);
134//! assert!(!paths.is_empty(), "path Bob→Berlin found");
135//!
136//! // ── Step 4: Summarize subgraph for LLM context ────────────────────────────
137//! let mut subgraph = KgSubgraph::new();
138//! for (id, label, ty) in [
139//! ("alice", "Alice", "Person"),
140//! ("bob", "Bob", "Person"),
141//! ("carol", "Carol", "Person"),
142//! ("acme", "ACME", "Organization"),
143//! ("berlin", "Berlin", "Place"),
144//! ("ai_team", "AI-Team", "Team"),
145//! ("research", "Research", "Department"),
146//! ("dave", "Dave", "Person"),
147//! ] {
148//! subgraph.add_node(KgNode::simple(id, label, ty));
149//! }
150//! subgraph.add_edge(KgEdge::unweighted("alice", "acme", "works_at"));
151//! subgraph.add_edge(KgEdge::unweighted("acme", "berlin","located_in"));
152//!
153//! let summarizer = SubgraphSummarizer::new();
154//! let clusters = summarizer.summarize(&subgraph, 10);
155//! assert!(!clusters.is_empty(), "at least one cluster");
156//! let text_summary = summarizer.generate_text_summary(&clusters);
157//! assert!(!text_summary.is_empty(), "non-empty summary text");
158//! ```
159//!
160//! ## Full engine usage (async, requires trait impls)
161//!
162//! For production usage with a real vector index, embedding model, SPARQL engine,
163//! and LLM client:
164//!
165//! ```rust,ignore
166//! use oxirs_graphrag::{GraphRAGEngine, GraphRAGConfig};
167//! use std::sync::Arc;
168//!
169//! let config = GraphRAGConfig {
170//! top_k: 20,
171//! expansion_hops: 2,
172//! enable_communities: true,
173//! ..Default::default()
174//! };
175//!
176//! // Provide your own implementations of VectorIndexTrait, EmbeddingModelTrait,
177//! // SparqlEngineTrait, and LlmClientTrait:
178//! let engine = GraphRAGEngine::new(
179//! Arc::new(my_vec_index),
180//! Arc::new(my_embedder),
181//! Arc::new(my_sparql),
182//! Arc::new(my_llm),
183//! config,
184//! );
185//!
186//! let result = engine.query("What safety issues affect battery cells?").await?;
187//! println!("Answer: {}", result.answer);
188//! println!("Confidence: {:.2}", result.confidence);
189//! ```
190//!
191//! See [`docs/tutorial.md`](https://github.com/cool-japan/oxirs/blob/master/ai/oxirs-graphrag/docs/tutorial.md)
192//! for a step-by-step walkthrough.
193
194pub mod cache;
195pub mod config;
196pub mod distributed;
197// v1.1.0: Graph summarization for RAG
198pub mod embeddings;
199pub mod federation;
200pub mod fusion;
201pub mod generation;
202pub mod graph;
203pub mod graph_summarization;
204pub mod query;
205pub mod reasoning;
206pub mod retrieval;
207pub mod sparql;
208pub mod streaming;
209pub mod temporal;
210
211// v1.1.0 TransE knowledge graph embedding model
212pub mod transe_model;
213
214// v1.1.0: Entity linking and disambiguation for knowledge graphs
215pub mod entity_linking;
216
217// v1.1.0 round 5: Community detection (Louvain-inspired greedy label propagation)
218pub mod community_detector;
219
220// v1.1.0 round 6: Knowledge graph path ranking (DFS + Dijkstra + scoring)
221pub mod path_ranker;
222
223// v1.1.0 round 7: String-to-RDF entity linking (mention detection + candidate ranking)
224pub mod entity_linker;
225
226// v1.1.0 round 11: Node2Vec-inspired graph embedding and structural node representations
227pub mod graph_embedder;
228
229// v1.1.0 round 12: Graph partitioning using greedy / label-propagation / bisection methods
230pub mod graph_partitioner;
231
232// v1.1.0 round 13: Rule-based knowledge triple extraction from natural language text
233pub mod triple_extractor;
234
235// v1.1.0 round 11: Multi-source knowledge fusion with provenance tracking
236pub mod knowledge_fusion;
237
238// v1.1.0 round 12: Context building for graph-based RAG (N-hop, ranking, truncation, formatting)
239pub mod context_builder;
240
241// v1.1.0 round 13: Graph path finding for RAG (BFS/DFS, shortest path, predicate filtering, scoring)
242pub mod path_finder;
243
244// v1.1.0 round 14: KG subgraph summarization via cluster-based abstraction
245pub mod summarizer;
246
247// v1.1.0 round 15: Entity type classification for knowledge graph nodes
248pub mod entity_classifier;
249
250// v1.1.0 round 16: Explainability — attention weights, path explanation, provenance
251pub mod explainability;
252
253// v1.1.0 round 17: Interactive refinement with user feedback
254pub mod feedback;
255
256// v0.4.0: Re-export new GraphSummarizer + GraphSummary types
257pub use summarizer::{GraphSummarizer, GraphSummary};
258// v0.4.0: Re-export new TripleRelevanceFeedback + Relevance types
259pub use feedback::{Relevance, TripleId, TripleRelevanceFeedback};
260
261// v0.3.0 / block-5: GNN encoder — phase a: GraphSAGE over the knowledge graph
262pub mod gnn_encoder;
263
264// v0.3.1: GNN encoder new components
265pub use gnn_encoder::{
266 AdjacencyGraph, EdgeList, GnnEncoder, GnnEncoderConfig, ScaledDotProductAttention,
267};
268
269// v0.3.0 / block-6: Hybrid GNN+LLM — phase b/c: LLM head with frozen GNN soft-prompt
270pub mod hybrid;
271
272// v0.3.0 / block-8: Hybrid GNN+LLM phase d — GGUF model loader + LoRA adapter
273#[cfg(feature = "gguf-loader")]
274pub mod model_loader;
275
276// v0.3.0 / block-7: Neuro-symbolic fusion — PINN-driven physics-informed entity scoring
277pub mod neuro_symbolic;
278
279use std::collections::HashMap;
280use std::sync::atomic::{AtomicU64, Ordering};
281use std::sync::Arc;
282use std::time::{Duration, SystemTime};
283
284use async_trait::async_trait;
285use chrono::{DateTime, Utc};
286use serde::{Deserialize, Serialize};
287use thiserror::Error;
288use tokio::sync::RwLock;
289
290// Re-exports
291pub use cache::query_cache::{CacheEntry, CacheStats, QueryCache, QueryCacheConfig};
292pub use config::{CacheConfiguration, GraphRAGConfig};
293pub use embeddings::node2vec::{
294 Node2VecConfig, Node2VecEmbedder, Node2VecEmbeddings, Node2VecWalkConfig,
295};
296pub use graph::community::{CommunityAlgorithm, CommunityConfig, CommunityDetector};
297pub use graph::embeddings::{CommunityAwareEmbeddings, CommunityStructure, EmbeddingConfig};
298pub use graph::traversal::GraphTraversal;
299pub use hybrid::lora::{LoraAdapter, LoraTrainer};
300pub use query::planner::QueryPlanner;
301pub use retrieval::fusion::FusionStrategy;
302
303// Feature-gated re-exports for GGUF model loader.
304#[cfg(feature = "gguf-loader")]
305pub use model_loader::{
306 GgufMetadata, GgufModelArch, GgufParseError, GgufParser, GgufTensorInfo, GgufValue,
307 ModelHandle, ModelInfo, ModelRegistry, RegistryError,
308};
309
310/// GraphRAG error types
311#[derive(Error, Debug)]
312pub enum GraphRAGError {
313 #[error("Vector search failed: {0}")]
314 VectorSearchError(String),
315
316 #[error("Graph traversal failed: {0}")]
317 GraphTraversalError(String),
318
319 #[error("Community detection failed: {0}")]
320 CommunityDetectionError(String),
321
322 #[error("LLM generation failed: {0}")]
323 GenerationError(String),
324
325 #[error("Embedding failed: {0}")]
326 EmbeddingError(String),
327
328 #[error("SPARQL query failed: {0}")]
329 SparqlError(String),
330
331 #[error("Configuration error: {0}")]
332 ConfigError(String),
333
334 #[error("Internal error: {0}")]
335 InternalError(String),
336}
337
338pub type GraphRAGResult<T> = Result<T, GraphRAGError>;
339
340/// Triple representation for RDF data
341#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
342pub struct Triple {
343 pub subject: String,
344 pub predicate: String,
345 pub object: String,
346}
347
348impl Triple {
349 pub fn new(
350 subject: impl Into<String>,
351 predicate: impl Into<String>,
352 object: impl Into<String>,
353 ) -> Self {
354 Self {
355 subject: subject.into(),
356 predicate: predicate.into(),
357 object: object.into(),
358 }
359 }
360}
361
362/// Entity with relevance score
363#[derive(Debug, Clone, Serialize, Deserialize)]
364pub struct ScoredEntity {
365 /// Entity URI
366 pub uri: String,
367 /// Relevance score (0.0 - 1.0)
368 pub score: f64,
369 /// Source of the score (vector, keyword, or fused)
370 pub source: ScoreSource,
371 /// Additional metadata
372 pub metadata: HashMap<String, String>,
373}
374
375/// Source of entity score
376#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
377pub enum ScoreSource {
378 /// Score from vector similarity search
379 Vector,
380 /// Score from keyword/BM25 search
381 Keyword,
382 /// Fused score from multiple sources
383 Fused,
384 /// Score from graph traversal (path-based)
385 Graph,
386}
387
388/// Community summary for hierarchical retrieval
389#[derive(Debug, Clone, Serialize, Deserialize)]
390pub struct CommunitySummary {
391 /// Community identifier
392 pub id: String,
393 /// Human-readable summary of the community
394 pub summary: String,
395 /// Member entities in this community
396 pub entities: Vec<String>,
397 /// Representative triples from this community
398 pub representative_triples: Vec<Triple>,
399 /// Community level in hierarchy (0 = leaf, higher = more abstract)
400 pub level: u32,
401 /// Modularity score
402 pub modularity: f64,
403}
404
405/// Query provenance for attribution
406#[derive(Debug, Clone, Serialize, Deserialize)]
407pub struct QueryProvenance {
408 /// Query timestamp
409 pub timestamp: DateTime<Utc>,
410 /// Original query text
411 pub original_query: String,
412 /// Expanded query (if any)
413 pub expanded_query: Option<String>,
414 /// Seed entities used
415 pub seed_entities: Vec<String>,
416 /// Triples contributing to the answer
417 pub source_triples: Vec<Triple>,
418 /// Community summaries used (if hierarchical)
419 pub community_sources: Vec<String>,
420 /// Processing time in milliseconds
421 pub processing_time_ms: u64,
422}
423
424/// GraphRAG query result
425#[derive(Debug, Clone, Serialize, Deserialize)]
426pub struct GraphRAGResult2 {
427 /// Natural language answer
428 pub answer: String,
429 /// Source subgraph (RDF triples)
430 pub subgraph: Vec<Triple>,
431 /// Seed entities with scores
432 pub seeds: Vec<ScoredEntity>,
433 /// Community summaries (if enabled)
434 pub communities: Vec<CommunitySummary>,
435 /// Provenance information
436 pub provenance: QueryProvenance,
437 /// Confidence score (0.0 - 1.0)
438 pub confidence: f64,
439}
440
441/// Trait for vector index operations
442#[async_trait]
443pub trait VectorIndexTrait: Send + Sync {
444 /// Search for k nearest neighbors
445 async fn search_knn(
446 &self,
447 query_vector: &[f32],
448 k: usize,
449 ) -> GraphRAGResult<Vec<(String, f32)>>;
450
451 /// Search with similarity threshold
452 async fn search_threshold(
453 &self,
454 query_vector: &[f32],
455 threshold: f32,
456 ) -> GraphRAGResult<Vec<(String, f32)>>;
457}
458
459/// Trait for embedding model operations
460#[async_trait]
461pub trait EmbeddingModelTrait: Send + Sync {
462 /// Embed text into vector
463 async fn embed(&self, text: &str) -> GraphRAGResult<Vec<f32>>;
464
465 /// Embed multiple texts in batch
466 async fn embed_batch(&self, texts: &[&str]) -> GraphRAGResult<Vec<Vec<f32>>>;
467}
468
469/// Trait for SPARQL engine operations
470#[async_trait]
471pub trait SparqlEngineTrait: Send + Sync {
472 /// Execute SELECT query
473 async fn select(&self, query: &str) -> GraphRAGResult<Vec<HashMap<String, String>>>;
474
475 /// Execute ASK query
476 async fn ask(&self, query: &str) -> GraphRAGResult<bool>;
477
478 /// Execute CONSTRUCT query
479 async fn construct(&self, query: &str) -> GraphRAGResult<Vec<Triple>>;
480}
481
482/// Trait for LLM client operations
483#[async_trait]
484pub trait LlmClientTrait: Send + Sync {
485 /// Generate response from context and query
486 async fn generate(&self, context: &str, query: &str) -> GraphRAGResult<String>;
487
488 /// Generate with streaming response
489 async fn generate_stream(
490 &self,
491 context: &str,
492 query: &str,
493 callback: Box<dyn Fn(&str) + Send + Sync>,
494 ) -> GraphRAGResult<String>;
495}
496
497/// Cached result with metadata
498#[derive(Debug, Clone)]
499struct CachedResult {
500 result: GraphRAGResult2,
501 timestamp: SystemTime,
502 ttl: Duration,
503}
504
505impl CachedResult {
506 /// Check if the cached result is still fresh
507 fn is_fresh(&self) -> bool {
508 self.timestamp
509 .elapsed()
510 .map(|elapsed| elapsed < self.ttl)
511 .unwrap_or(false)
512 }
513}
514
515/// Cache configuration
516#[derive(Debug, Clone)]
517pub struct CacheConfig {
518 /// Base TTL in seconds (default: 3600 = 1 hour)
519 pub base_ttl_seconds: u64,
520 /// Minimum TTL in seconds (default: 300 = 5 minutes)
521 pub min_ttl_seconds: u64,
522 /// Maximum TTL in seconds (default: 86400 = 24 hours)
523 pub max_ttl_seconds: u64,
524 /// Enable adaptive TTL based on update frequency
525 pub adaptive: bool,
526}
527
528impl Default for CacheConfig {
529 fn default() -> Self {
530 Self {
531 base_ttl_seconds: 3600,
532 min_ttl_seconds: 300,
533 max_ttl_seconds: 86400,
534 adaptive: true,
535 }
536 }
537}
538
539/// Main GraphRAG engine
540pub struct GraphRAGEngine<V, E, S, L>
541where
542 V: VectorIndexTrait,
543 E: EmbeddingModelTrait,
544 S: SparqlEngineTrait,
545 L: LlmClientTrait,
546{
547 /// Vector index for similarity search
548 vec_index: Arc<V>,
549 /// Embedding model for query vectorization
550 embedding_model: Arc<E>,
551 /// SPARQL engine for graph traversal
552 sparql_engine: Arc<S>,
553 /// LLM client for answer generation
554 llm_client: Arc<L>,
555 /// Configuration
556 config: GraphRAGConfig,
557 /// Query result cache with adaptive TTL
558 cache: Arc<RwLock<lru::LruCache<String, CachedResult>>>,
559 /// Cache configuration
560 cache_config: CacheConfig,
561 /// Graph update counter for adaptive TTL
562 graph_update_count: Arc<AtomicU64>,
563 /// Community detector, built from `config.community_algorithm` at
564 /// construction time and used by [`Self::detect_communities`]. Always
565 /// `Some` once the engine is constructed via `new` / `with_cache_config`
566 /// — `Option` only because `detect_communities` needs a borrow-checker
567 /// friendly way to report the (unreachable in practice) uninitialized
568 /// case via `GraphRAGResult` rather than panicking.
569 community_detector: Option<Arc<CommunityDetector>>,
570}
571
572impl<V, E, S, L> GraphRAGEngine<V, E, S, L>
573where
574 V: VectorIndexTrait,
575 E: EmbeddingModelTrait,
576 S: SparqlEngineTrait,
577 L: LlmClientTrait,
578{
579 /// Create a new GraphRAG engine
580 pub fn new(
581 vec_index: Arc<V>,
582 embedding_model: Arc<E>,
583 sparql_engine: Arc<S>,
584 llm_client: Arc<L>,
585 config: GraphRAGConfig,
586 ) -> Self {
587 let cache_config = CacheConfig {
588 base_ttl_seconds: config.cache_config.base_ttl_seconds,
589 min_ttl_seconds: config.cache_config.min_ttl_seconds,
590 max_ttl_seconds: config.cache_config.max_ttl_seconds,
591 adaptive: config.cache_config.adaptive,
592 };
593
594 Self::with_cache_config(
595 vec_index,
596 embedding_model,
597 sparql_engine,
598 llm_client,
599 config,
600 cache_config,
601 )
602 }
603
604 /// Create a new GraphRAG engine with custom cache configuration
605 pub fn with_cache_config(
606 vec_index: Arc<V>,
607 embedding_model: Arc<E>,
608 sparql_engine: Arc<S>,
609 llm_client: Arc<L>,
610 config: GraphRAGConfig,
611 cache_config: CacheConfig,
612 ) -> Self {
613 const DEFAULT_CACHE_SIZE: std::num::NonZeroUsize = match std::num::NonZeroUsize::new(1000) {
614 Some(size) => size,
615 None => panic!("constant is non-zero"),
616 };
617
618 let cache_size = config
619 .cache_size
620 .and_then(std::num::NonZeroUsize::new)
621 .unwrap_or(DEFAULT_CACHE_SIZE);
622
623 // Build the real community detector up front from
624 // `config.community_algorithm` so `detect_communities` always has a
625 // genuine Louvain/Leiden/label-propagation/connected-components
626 // implementation to delegate to (see that method's doc comment).
627 // `min_community_size: 2` (rather than the detector's own default of
628 // 3) matches this engine's historical "communities of >= 2 entities"
629 // behavior for the typically-small subgraphs a single query expands.
630 let community_config = CommunityConfig {
631 algorithm: map_community_algorithm(config.community_algorithm),
632 min_community_size: 2,
633 ..CommunityConfig::default()
634 };
635 let community_detector = Some(Arc::new(CommunityDetector::new(community_config)));
636
637 Self {
638 vec_index,
639 embedding_model,
640 sparql_engine,
641 llm_client,
642 config,
643 cache: Arc::new(RwLock::new(lru::LruCache::new(cache_size))),
644 cache_config,
645 graph_update_count: Arc::new(AtomicU64::new(0)),
646 community_detector,
647 }
648 }
649
650 /// Calculate adaptive TTL based on graph update frequency
651 fn calculate_ttl(&self) -> Duration {
652 if !self.cache_config.adaptive {
653 return Duration::from_secs(self.cache_config.base_ttl_seconds);
654 }
655
656 let updates_per_hour = self.graph_update_count.load(Ordering::Relaxed) as f64;
657
658 // More updates = shorter TTL
659 let ttl_secs = if updates_per_hour > 100.0 {
660 self.cache_config.min_ttl_seconds // High update rate: 5 min TTL
661 } else if updates_per_hour > 10.0 {
662 self.cache_config.base_ttl_seconds / 2 // Medium: 30 min TTL
663 } else {
664 self.cache_config.max_ttl_seconds // Low update rate: 24 hour TTL
665 };
666
667 Duration::from_secs(ttl_secs)
668 }
669
670 /// Record graph update for adaptive TTL calculation
671 pub fn record_graph_update(&self) {
672 self.graph_update_count.fetch_add(1, Ordering::Relaxed);
673 }
674
675 /// Get current cache hit rate for monitoring
676 pub async fn get_cache_stats(&self) -> (usize, usize) {
677 let cache = self.cache.read().await;
678 (cache.len(), cache.cap().get())
679 }
680
681 /// Execute a GraphRAG query
682 pub async fn query(&self, query: &str) -> GraphRAGResult<GraphRAGResult2> {
683 let start_time = std::time::Instant::now();
684
685 // Check cache with freshness validation
686 {
687 let cache = self.cache.read().await;
688 if let Some(cached) = cache.peek(&query.to_string()) {
689 if cached.is_fresh() {
690 return Ok(cached.result.clone());
691 }
692 }
693 }
694
695 // 0. Consult the query planner. `QueryPlanner::plan` computes stage
696 // ordering, per-stage dependencies, and an estimated cost; here we
697 // actually act on it rather than letting it sit unused: `parallel`
698 // (true whenever vector search and keyword search have no
699 // dependency on each other, which is always for this fixed
700 // pipeline) determines whether steps 2 and 3 below run concurrently
701 // via `tokio::join!` or sequentially.
702 let parsed_query = query::parser::QueryParser::new().parse(query)?;
703 let plan = query::planner::QueryPlanner::new(self.config.clone()).plan(&parsed_query)?;
704 tracing::debug!(
705 estimated_cost = plan.estimated_cost,
706 parallel = plan.parallel,
707 stages = plan.stages.len(),
708 "GraphRAG query execution plan"
709 );
710
711 // 1. Embed query
712 let query_vec = self.embedding_model.embed(query).await?;
713
714 // 2 + 3. Vector retrieval (Top-K) and keyword retrieval (BM25).
715 // These two stages have no dependency on each other in the plan, so
716 // when `plan.parallel` is set they genuinely run concurrently
717 // instead of one `.await` after another.
718 let (vector_results, keyword_results) = if plan.parallel {
719 let vector_fut = self.vec_index.search_knn(&query_vec, self.config.top_k);
720 let keyword_fut = self.keyword_search(query);
721 let (vector_results, keyword_results) = tokio::join!(vector_fut, keyword_fut);
722 (vector_results?, keyword_results?)
723 } else {
724 let vector_results = self
725 .vec_index
726 .search_knn(&query_vec, self.config.top_k)
727 .await?;
728 let keyword_results = self.keyword_search(query).await?;
729 (vector_results, keyword_results)
730 };
731
732 // 4. Fusion (RRF)
733 let seeds = self.fuse_results(&vector_results, &keyword_results)?;
734
735 // 5. Graph expansion (SPARQL)
736 let subgraph = self.expand_graph(&seeds).await?;
737
738 // 6. Community detection (optional)
739 let communities = if self.config.enable_communities {
740 self.detect_communities(&subgraph)?
741 } else {
742 vec![]
743 };
744
745 // 7. Build context
746 let context = self.build_context(&subgraph, &communities, query)?;
747
748 // 8. Generate answer
749 let answer = self.llm_client.generate(&context, query).await?;
750
751 // Calculate confidence based on seed scores and graph coverage
752 let confidence = self.calculate_confidence(&seeds, &subgraph);
753
754 let result = GraphRAGResult2 {
755 answer,
756 subgraph: subgraph.clone(),
757 seeds: seeds.clone(),
758 communities,
759 provenance: QueryProvenance {
760 timestamp: Utc::now(),
761 original_query: query.to_string(),
762 expanded_query: None,
763 seed_entities: seeds.iter().map(|s| s.uri.clone()).collect(),
764 source_triples: subgraph,
765 community_sources: vec![],
766 processing_time_ms: start_time.elapsed().as_millis() as u64,
767 },
768 confidence,
769 };
770
771 // Update cache with adaptive TTL
772 let ttl = self.calculate_ttl();
773 let cached = CachedResult {
774 result: result.clone(),
775 timestamp: SystemTime::now(),
776 ttl,
777 };
778 self.cache.write().await.put(query.to_string(), cached);
779
780 Ok(result)
781 }
782
783 /// Keyword search using BM25 (simplified)
784 async fn keyword_search(&self, query: &str) -> GraphRAGResult<Vec<(String, f32)>> {
785 // Build SPARQL query with text matching
786 let terms: Vec<&str> = query.split_whitespace().collect();
787 if terms.is_empty() {
788 return Ok(vec![]);
789 }
790
791 // Create SPARQL FILTER with regex for each term
792 let filters: Vec<String> = terms
793 .iter()
794 .map(|term| format!("REGEX(STR(?label), \"{}\", \"i\")", term))
795 .collect();
796
797 let sparql = format!(
798 r#"
799 SELECT DISTINCT ?entity (COUNT(*) AS ?score) WHERE {{
800 ?entity rdfs:label|schema:name|dc:title ?label .
801 FILTER({})
802 }}
803 GROUP BY ?entity
804 ORDER BY DESC(?score)
805 LIMIT {}
806 "#,
807 filters.join(" || "),
808 self.config.top_k
809 );
810
811 let results = self.sparql_engine.select(&sparql).await?;
812
813 Ok(results
814 .into_iter()
815 .filter_map(|row| {
816 let entity = row.get("entity")?.clone();
817 let score = row.get("score")?.parse::<f32>().ok()?;
818 Some((entity, score))
819 })
820 .collect())
821 }
822
823 /// Fuse vector and keyword results using Reciprocal Rank Fusion
824 fn fuse_results(
825 &self,
826 vector_results: &[(String, f32)],
827 keyword_results: &[(String, f32)],
828 ) -> GraphRAGResult<Vec<ScoredEntity>> {
829 let k = 60.0; // RRF constant
830
831 let mut scores: HashMap<String, (f64, ScoreSource)> = HashMap::new();
832
833 // Add vector scores
834 for (rank, (uri, score)) in vector_results.iter().enumerate() {
835 let rrf_score = 1.0 / (k + rank as f64 + 1.0);
836 scores.insert(
837 uri.clone(),
838 (
839 rrf_score * self.config.vector_weight as f64,
840 ScoreSource::Vector,
841 ),
842 );
843 }
844
845 // Add keyword scores
846 for (rank, (uri, _score)) in keyword_results.iter().enumerate() {
847 let rrf_score = 1.0 / (k + rank as f64 + 1.0);
848 let keyword_contribution = rrf_score * self.config.keyword_weight as f64;
849
850 match scores.get(uri).cloned() {
851 Some((existing_score, _)) => {
852 let new_score = existing_score + keyword_contribution;
853 scores.insert(uri.clone(), (new_score, ScoreSource::Fused));
854 }
855 None => {
856 scores.insert(uri.clone(), (keyword_contribution, ScoreSource::Keyword));
857 }
858 }
859 }
860
861 // Sort by score and take top results
862 let mut entities: Vec<ScoredEntity> = scores
863 .into_iter()
864 .map(|(uri, (score, source))| ScoredEntity {
865 uri,
866 score,
867 source,
868 metadata: HashMap::new(),
869 })
870 .collect();
871
872 entities.sort_by(|a, b| {
873 b.score
874 .partial_cmp(&a.score)
875 .unwrap_or(std::cmp::Ordering::Equal)
876 });
877 entities.truncate(self.config.max_seeds);
878
879 Ok(entities)
880 }
881
882 /// Expand graph from seed entities using SPARQL
883 async fn expand_graph(&self, seeds: &[ScoredEntity]) -> GraphRAGResult<Vec<Triple>> {
884 if seeds.is_empty() {
885 return Ok(vec![]);
886 }
887
888 let seed_uris: Vec<String> = seeds.iter().map(|s| format!("<{}>", s.uri)).collect();
889 let sparql = build_expand_graph_query(
890 &seed_uris,
891 self.config.expansion_hops,
892 self.config.max_subgraph_size,
893 );
894
895 self.sparql_engine.construct(&sparql).await
896 }
897
898 /// Detect communities in the subgraph using the configured algorithm
899 /// (`self.config.community_algorithm`: Louvain / Leiden / Label
900 /// Propagation / Connected Components), delegating to the real
901 /// [`graph::community::CommunityDetector`] so callers get genuine,
902 /// resolution-parameterised community structure and an honest
903 /// Newman-Girvan modularity score instead of a fixed `0.0`.
904 fn detect_communities(&self, subgraph: &[Triple]) -> GraphRAGResult<Vec<CommunitySummary>> {
905 if subgraph.is_empty() {
906 return Ok(vec![]);
907 }
908
909 let detector = self.community_detector.as_ref().ok_or_else(|| {
910 GraphRAGError::CommunityDetectionError(
911 "community detector was not initialized".to_string(),
912 )
913 })?;
914
915 detector.detect(subgraph)
916 }
917
918 /// Build context string for LLM from subgraph and communities
919 fn build_context(
920 &self,
921 subgraph: &[Triple],
922 communities: &[CommunitySummary],
923 _query: &str,
924 ) -> GraphRAGResult<String> {
925 let mut context = String::new();
926
927 // Add community summaries if available
928 if !communities.is_empty() {
929 context.push_str("## Community Context\n\n");
930 for community in communities {
931 context.push_str(&format!("### {}\n", community.id));
932 context.push_str(&format!("{}\n", community.summary));
933 context.push_str(&format!("Entities: {}\n\n", community.entities.join(", ")));
934 }
935 }
936
937 // Add relevant triples
938 context.push_str("## Knowledge Graph Facts\n\n");
939 for triple in subgraph.iter().take(self.config.max_context_triples) {
940 context.push_str(&format!(
941 "- {} → {} → {}\n",
942 triple.subject, triple.predicate, triple.object
943 ));
944 }
945
946 Ok(context)
947 }
948
949 /// Calculate confidence score based on retrieval quality
950 fn calculate_confidence(&self, seeds: &[ScoredEntity], subgraph: &[Triple]) -> f64 {
951 if seeds.is_empty() {
952 return 0.0;
953 }
954
955 // Average seed score
956 let avg_seed_score: f64 = seeds.iter().map(|s| s.score).sum::<f64>() / seeds.len() as f64;
957
958 // Graph coverage (how many seeds appear in subgraph)
959 let seed_uris: std::collections::HashSet<_> = seeds.iter().map(|s| &s.uri).collect();
960 let covered: usize = subgraph
961 .iter()
962 .filter(|t| seed_uris.contains(&t.subject) || seed_uris.contains(&t.object))
963 .count();
964 let coverage = if subgraph.is_empty() {
965 0.0
966 } else {
967 (covered as f64 / subgraph.len() as f64).min(1.0)
968 };
969
970 // Combined confidence
971 (avg_seed_score * 0.6 + coverage * 0.4).min(1.0)
972 }
973}
974
975/// Build the CONSTRUCT query used by [`GraphRAGEngine::expand_graph`] for
976/// N-hop neighbor expansion from a set of seed IRI terms (already
977/// `<...>`-wrapped).
978///
979/// Pulled out as a free function so it can be exercised (and, in tests,
980/// round-tripped through the real `oxirs-arq` parser) without needing a
981/// live `SparqlEngineTrait` implementation.
982///
983/// Two formatting quirks below are load-bearing, not stylistic (both
984/// verified against the real `oxirs-arq` parser while writing this query
985/// builder — see the round-trip regression tests):
986///
987/// - `CONSTRUCT { ... }` is kept on a single physical line, immediately
988/// followed by `WHERE {` on that *same* line: the parser does not skip a
989/// bare newline between the template's closing `}` and the `WHERE`
990/// keyword, and fails with "Expected LeftBrace, found Some(Newline)" if
991/// one is present.
992/// - The `WHERE` clause's closing `}` is immediately followed by `LIMIT` on
993/// the same line, for the same reason (a newline there instead yields
994/// "Unexpected trailing tokens after query: Some(Limit)" — the modifier
995/// parser doesn't skip a leading newline before checking for `LIMIT`).
996///
997/// The `WHERE` clause body itself has no such restriction and is formatted
998/// multi-line for readability.
999fn build_expand_graph_query(seed_uris: &[String], hops: usize, max_subgraph_size: usize) -> String {
1000 let values = seed_uris.join(" ");
1001
1002 // N-hop neighbor expansion. See `sparql::hop_pattern` for why this is an
1003 // explicit UNION of path-free BGP chains rather than a SPARQL property
1004 // path: the previous `(:|!:){1,hops}` referenced an undeclared empty
1005 // prefix and made every real `query()` call fail at this step for the
1006 // (default) multi-hop case.
1007 let hop_pattern = crate::sparql::hop_pattern::build_forward_hop_pattern(
1008 "?seed",
1009 "?neighbor",
1010 hops,
1011 "hp",
1012 "hn",
1013 );
1014
1015 format!(
1016 r#"
1017 CONSTRUCT {{ ?seed ?p ?o . ?s ?p2 ?seed . ?neighbor ?p3 ?o2 . }} WHERE {{
1018 VALUES ?seed {{ {values} }}
1019 {{
1020 ?seed ?p ?o .
1021 }} UNION {{
1022 ?s ?p2 ?seed .
1023 }} UNION {{
1024 {hop_pattern}
1025 ?neighbor ?p3 ?o2 .
1026 }}
1027 }} LIMIT {max_subgraph_size}
1028 "#
1029 )
1030}
1031
1032/// Map the crate's public [`config::CommunityAlgorithm`] configuration enum
1033/// onto the [`graph::community::CommunityAlgorithm`] the real detector
1034/// implementation understands. Kept as an explicit mapping (rather than
1035/// reusing one enum for both) because `config::CommunityAlgorithm` is a
1036/// stable, `serde`-versioned user-facing config surface while
1037/// `graph::community::CommunityAlgorithm` also has an internal-only
1038/// `Hierarchical` variant that is not (yet) exposed as a top-level engine
1039/// config choice.
1040fn map_community_algorithm(algorithm: config::CommunityAlgorithm) -> CommunityAlgorithm {
1041 match algorithm {
1042 config::CommunityAlgorithm::Louvain => CommunityAlgorithm::Louvain,
1043 config::CommunityAlgorithm::Leiden => CommunityAlgorithm::Leiden,
1044 config::CommunityAlgorithm::LabelPropagation => CommunityAlgorithm::LabelPropagation,
1045 config::CommunityAlgorithm::ConnectedComponents => CommunityAlgorithm::ConnectedComponents,
1046 }
1047}
1048
1049#[cfg(test)]
1050mod tests {
1051 use super::*;
1052
1053 #[test]
1054 fn test_triple_creation() {
1055 let triple = Triple::new(
1056 "http://example.org/s",
1057 "http://example.org/p",
1058 "http://example.org/o",
1059 );
1060 assert_eq!(triple.subject, "http://example.org/s");
1061 assert_eq!(triple.predicate, "http://example.org/p");
1062 assert_eq!(triple.object, "http://example.org/o");
1063 }
1064
1065 #[test]
1066 fn test_scored_entity() {
1067 let entity = ScoredEntity {
1068 uri: "http://example.org/entity".to_string(),
1069 score: 0.85,
1070 source: ScoreSource::Fused,
1071 metadata: HashMap::new(),
1072 };
1073 assert_eq!(entity.score, 0.85);
1074 assert_eq!(entity.source, ScoreSource::Fused);
1075 }
1076
1077 // ── Regression: expand_graph SPARQL must actually parse (P0) ───────────
1078
1079 #[test]
1080 fn regression_expand_graph_query_never_emits_empty_prefix_hack() {
1081 for hops in [1usize, 2, 3, 5] {
1082 let seed_uris = vec!["<http://example.org/e>".to_string()];
1083 let sparql = build_expand_graph_query(&seed_uris, hops, 500);
1084 assert!(
1085 !sparql.contains(":|!:"),
1086 "hops={hops}: must not reference the undeclared empty prefix `:`/`!:`\n{sparql}"
1087 );
1088 assert!(
1089 !sparql.contains("!()"),
1090 "hops={hops}: must not use the unsupported empty negated property set\n{sparql}"
1091 );
1092 }
1093 }
1094
1095 #[test]
1096 fn regression_expand_graph_query_round_trips_through_real_arq_parser() {
1097 // The actual bug: the previous `(:|!:){1,hops}` property path failed
1098 // to parse against the real oxirs-arq engine for any hops > 1 (the
1099 // crate's own default config), so every real `GraphRAGEngine::query`
1100 // call failed at the graph-expansion step. Assert the generated
1101 // query is genuinely valid SPARQL per the workspace's own parser,
1102 // not just "doesn't contain a known-bad substring".
1103 for hops in [1usize, 2, 3, 5, 10] {
1104 let seed_uris = vec![
1105 "<http://example.org/seed1>".to_string(),
1106 "<http://example.org/seed2>".to_string(),
1107 ];
1108 let sparql = build_expand_graph_query(&seed_uris, hops, 500);
1109 let mut parser = oxirs_arq::query::QueryParser::new();
1110 parser
1111 .parse(&sparql)
1112 .unwrap_or_else(|e| panic!("hops={hops} query failed to parse: {e}\n{sparql}"));
1113 }
1114 }
1115
1116 // ── Shared test mocks for GraphRAGEngine ────────────────────────────────
1117
1118 struct MockVectorIndex;
1119
1120 #[async_trait]
1121 impl VectorIndexTrait for MockVectorIndex {
1122 async fn search_knn(
1123 &self,
1124 _query_vector: &[f32],
1125 _k: usize,
1126 ) -> GraphRAGResult<Vec<(String, f32)>> {
1127 Ok(vec![])
1128 }
1129
1130 async fn search_threshold(
1131 &self,
1132 _query_vector: &[f32],
1133 _threshold: f32,
1134 ) -> GraphRAGResult<Vec<(String, f32)>> {
1135 Ok(vec![])
1136 }
1137 }
1138
1139 struct MockEmbeddingModel;
1140
1141 #[async_trait]
1142 impl EmbeddingModelTrait for MockEmbeddingModel {
1143 async fn embed(&self, _text: &str) -> GraphRAGResult<Vec<f32>> {
1144 Ok(vec![0.0; 4])
1145 }
1146
1147 async fn embed_batch(&self, texts: &[&str]) -> GraphRAGResult<Vec<Vec<f32>>> {
1148 Ok(texts.iter().map(|_| vec![0.0; 4]).collect())
1149 }
1150 }
1151
1152 struct MockSparqlEngine;
1153
1154 #[async_trait]
1155 impl SparqlEngineTrait for MockSparqlEngine {
1156 async fn select(&self, _query: &str) -> GraphRAGResult<Vec<HashMap<String, String>>> {
1157 Ok(vec![])
1158 }
1159
1160 async fn ask(&self, _query: &str) -> GraphRAGResult<bool> {
1161 Ok(false)
1162 }
1163
1164 async fn construct(&self, _query: &str) -> GraphRAGResult<Vec<Triple>> {
1165 Ok(vec![])
1166 }
1167 }
1168
1169 struct MockLlmClient;
1170
1171 #[async_trait]
1172 impl LlmClientTrait for MockLlmClient {
1173 async fn generate(&self, _context: &str, _query: &str) -> GraphRAGResult<String> {
1174 Ok("mock answer".to_string())
1175 }
1176
1177 async fn generate_stream(
1178 &self,
1179 _context: &str,
1180 _query: &str,
1181 _callback: Box<dyn Fn(&str) + Send + Sync>,
1182 ) -> GraphRAGResult<String> {
1183 Ok("mock answer".to_string())
1184 }
1185 }
1186
1187 fn make_test_engine(
1188 algorithm: config::CommunityAlgorithm,
1189 ) -> GraphRAGEngine<MockVectorIndex, MockEmbeddingModel, MockSparqlEngine, MockLlmClient> {
1190 let config = GraphRAGConfig {
1191 community_algorithm: algorithm,
1192 ..GraphRAGConfig::default()
1193 };
1194 GraphRAGEngine::new(
1195 Arc::new(MockVectorIndex),
1196 Arc::new(MockEmbeddingModel),
1197 Arc::new(MockSparqlEngine),
1198 Arc::new(MockLlmClient),
1199 config,
1200 )
1201 }
1202
1203 // ── Regression: detect_communities uses real modularity (P1) ───────────
1204
1205 #[tokio::test]
1206 async fn regression_detect_communities_computes_real_modularity_not_hardcoded_zero() {
1207 let engine = make_test_engine(config::CommunityAlgorithm::ConnectedComponents);
1208
1209 // Two disconnected triangles: strong, unambiguous community
1210 // structure, so the true partition's modularity must be well above
1211 // zero (previously this was hardcoded to `0.0` for every community
1212 // regardless of actual graph structure).
1213 let subgraph = vec![
1214 Triple::new("http://a", "http://rel", "http://b"),
1215 Triple::new("http://b", "http://rel", "http://c"),
1216 Triple::new("http://c", "http://rel", "http://a"),
1217 Triple::new("http://x", "http://rel", "http://y"),
1218 Triple::new("http://y", "http://rel", "http://z"),
1219 Triple::new("http://z", "http://rel", "http://x"),
1220 ];
1221
1222 let communities = engine
1223 .detect_communities(&subgraph)
1224 .expect("community detection should succeed");
1225
1226 assert_eq!(
1227 communities.len(),
1228 2,
1229 "two disconnected triangles should form two communities"
1230 );
1231 for community in &communities {
1232 assert!(
1233 community.modularity > 0.4,
1234 "modularity must be genuinely computed (expected ~0.5 for two \
1235 disconnected triangles), got {}",
1236 community.modularity
1237 );
1238 }
1239 }
1240
1241 #[tokio::test]
1242 async fn regression_detect_communities_respects_configured_algorithm() {
1243 // Distinct algorithms are wired through to the real detector: this
1244 // would previously always run plain connected-components no matter
1245 // what `config.community_algorithm` said.
1246 let subgraph = vec![
1247 Triple::new("http://a", "http://rel", "http://b"),
1248 Triple::new("http://b", "http://rel", "http://c"),
1249 Triple::new("http://c", "http://rel", "http://a"),
1250 Triple::new("http://x", "http://rel", "http://y"),
1251 Triple::new("http://y", "http://rel", "http://z"),
1252 Triple::new("http://z", "http://rel", "http://x"),
1253 ];
1254
1255 for algorithm in [
1256 config::CommunityAlgorithm::Louvain,
1257 config::CommunityAlgorithm::Leiden,
1258 config::CommunityAlgorithm::LabelPropagation,
1259 config::CommunityAlgorithm::ConnectedComponents,
1260 ] {
1261 let engine = make_test_engine(algorithm);
1262 let communities = engine
1263 .detect_communities(&subgraph)
1264 .unwrap_or_else(|e| panic!("{algorithm:?} community detection failed: {e}"));
1265 assert!(
1266 !communities.is_empty(),
1267 "{algorithm:?} should find the obvious two-triangle community structure"
1268 );
1269 }
1270 }
1271
1272 // ── Regression: QueryPlanner is actually consulted by query() (P2) ─────
1273
1274 #[test]
1275 fn regression_query_planner_marks_vector_and_keyword_search_independent() {
1276 // `GraphRAGEngine::query` uses `plan.parallel` to decide whether to
1277 // run vector and keyword search concurrently via `tokio::join!`.
1278 // That decision is only honest if the planner's dependency graph
1279 // genuinely has no edge between the two stages.
1280 let config = GraphRAGConfig::default();
1281 let planner = query::planner::QueryPlanner::new(config);
1282 let parsed = query::parser::QueryParser::new()
1283 .parse("What are the battery safety issues?")
1284 .expect("should parse");
1285 let plan = planner.plan(&parsed).expect("should plan");
1286
1287 assert!(plan.parallel);
1288
1289 let vector_stage = plan
1290 .stages
1291 .iter()
1292 .position(|s| s.stage_type == query::planner::StageType::VectorSearch)
1293 .expect("vector search stage present");
1294 let keyword_stage = plan
1295 .stages
1296 .iter()
1297 .position(|s| s.stage_type == query::planner::StageType::KeywordSearch)
1298 .expect("keyword search stage present");
1299
1300 assert!(
1301 !plan.stages[keyword_stage]
1302 .depends_on
1303 .contains(&vector_stage),
1304 "keyword search must not depend on vector search"
1305 );
1306 assert!(
1307 !plan.stages[vector_stage]
1308 .depends_on
1309 .contains(&keyword_stage),
1310 "vector search must not depend on keyword search"
1311 );
1312 }
1313
1314 #[tokio::test]
1315 async fn regression_query_actually_consults_planner_and_completes() {
1316 // End-to-end: `query()` must build a plan (not just leave
1317 // `QueryPlanner` as unreferenced dead code) and still produce a
1318 // valid result via the parallel vector+keyword path.
1319 let engine = make_test_engine(config::CommunityAlgorithm::ConnectedComponents);
1320 let result = engine
1321 .query("What are the safety issues?")
1322 .await
1323 .expect("query should succeed end-to-end with mock backends");
1324 assert_eq!(result.answer, "mock answer");
1325 }
1326}