Skip to main content

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}