Skip to main content

klieo_memory_graph_rag/
graph_aware.rs

1//! `GraphAwareLongTerm` — drop-in `LongTermMemory` that issues graph-first
2//! recall with vector fallback.
3//!
4//! **Authority hierarchy.** Qdrant (vector) is the source of truth for
5//! every `Fact`. `KnowledgeGraph` indexing is best-effort recall infra —
6//! graph failures on ingest log WARN and return the vector-minted
7//! `FactId`; graph failures on recall log WARN and fall back to pure
8//! vector. The composer never propagates graph errors to callers.
9//!
10//! **FactId provenance.** `recall` feeds the graph's `neighbors()` ids into
11//! `LongTermMemory::recall_filtered`, so the graph MUST be populated only with
12//! `FactId`s that exist in the vector store — i.e. via this composer's own
13//! `remember` (which indexes the vector-minted id into the graph). Do NOT
14//! point this at a graph an `EpisodeProjector` writes: that projector mints
15//! graph-only synthetic ids that match nothing in the vector store, so its
16//! facts would silently drop out of recall. Give projection its own scope.
17//!
18//! **Wire shape (M2 default).** Vector = `QdrantLongTerm` from
19//! `klieo-memory-qdrant`; graph = `Neo4jKnowledgeGraph` from
20//! `klieo-memory-graph-neo4j`; extractor = `FallbackExtractor` chaining
21//! `BuiltinExtractor` (regex) → `LlmEntityExtractor` (LLM). Drop into
22//! `klieo-triage` AppState behind `feature = "graph-rag"` (Task 2.4.8).
23//
24// Exceeds the 300-line file cap by design: single-purpose module holding
25// the graph-aware long-term composer, its shared recall core, and the
26// `RecallTrace` introspection type — cohesive, split would fragment one
27// pipeline across files.
28
29use crate::extractor::BuiltinExtractor;
30use crate::metadata::{KEY_ENTITIES, KEY_VALID_FROM};
31use async_trait::async_trait;
32use chrono::{DateTime, Utc};
33use klieo_core::error::MemoryError;
34use klieo_core::ids::FactId;
35use klieo_core::memory::{Fact, LongTermMemory, Scope};
36use klieo_memory_graph::{
37    EntityExtractor, EntityRef, FilterableLongTermMemory, KnowledgeGraph, RecallMetrics,
38    RetrievalPath,
39};
40use std::sync::Arc;
41
42const DEFAULT_MIN_GRAPH_HITS: usize = 1;
43
44/// The observable trace behind one [`GraphAwareLongTerm::recall_traced`] call.
45///
46/// `ranked_facts` and `fell_back_to_vector` come from the same core that
47/// [`LongTermMemory::recall`] uses and are authoritative — this trace
48/// cannot diverge from production recall output. `paths` and
49/// `extracted_entities` are display-only enrichment fetched via an extra
50/// [`KnowledgeGraph::recall_paths`] call that production `recall` never
51/// pays for; both are empty when the backend keeps the trait's default
52/// no-op `recall_paths` implementation.
53#[derive(Debug, Clone)]
54#[non_exhaustive]
55pub struct RecallTrace {
56    /// Entities the extractor pulled from the query text — the same input
57    /// the shared core used to decide the graph-vs-vector path. Empty
58    /// when the extractor failed OR legitimately found zero entities in
59    /// the query.
60    pub extracted_entities: Vec<EntityRef>,
61    /// Per-`(entity, fact_id)` traversal hops from
62    /// [`KnowledgeGraph::recall_paths`] over the extracted entities. Since
63    /// `recall_paths` mirrors [`KnowledgeGraph::neighbors`] without the
64    /// `min_graph_hits` fallback threshold, this can be non-empty even
65    /// when `fell_back_to_vector` is `true` (a below-threshold but nonzero
66    /// neighbor set). Empty when the backend keeps the trait's default
67    /// no-op `recall_paths`.
68    pub paths: Vec<RetrievalPath>,
69    /// Authoritative ranked facts — identical to what
70    /// [`LongTermMemory::recall`] returns for the same `(scope, query, k)`.
71    pub ranked_facts: Vec<Fact>,
72    /// `true` when this call fell through to pure vector recall (empty or
73    /// failed extraction, a graph error, or a candidate count below
74    /// `min_graph_hits`); `false` when the graph-filtered path was used.
75    pub fell_back_to_vector: bool,
76}
77
78/// Shared result of [`GraphAwareLongTerm::recall_core`] — `facts` and
79/// `fell_back_to_vector` are the two authoritative fields that both
80/// `LongTermMemory::recall` and `RecallTrace` are built from.
81///
82/// `recall_core` records NO [`RecallMetrics`]; the caller decides
83/// whether to. `graph_candidates` carries the candidate count from the
84/// graph-hit arm (`0` on the fallback arm) so the metric-recording
85/// caller can report it without re-running the traversal.
86struct RecallCore {
87    entities: Vec<EntityRef>,
88    facts: Vec<Fact>,
89    fell_back_to_vector: bool,
90    graph_candidates: usize,
91}
92
93/// Graph-first RAG composer over a `FilterableLongTermMemory` vector
94/// store + a `KnowledgeGraph` + an `EntityExtractor`.
95///
96/// Provenance recording is **not** the composer's responsibility —
97/// wrap the `graph` argument in
98/// [`crate::ProvenanceKnowledgeGraph`] at construction time to emit
99/// signed chain entries on every `graph.index()` call (ADR-038).
100pub struct GraphAwareLongTerm {
101    vector: Arc<dyn FilterableLongTermMemory>,
102    graph: Arc<dyn KnowledgeGraph>,
103    extractor: Arc<dyn EntityExtractor>,
104    metrics: Arc<RecallMetrics>,
105    min_graph_hits: usize,
106}
107
108impl GraphAwareLongTerm {
109    /// Build with the default `min_graph_hits = 1` threshold.
110    pub fn new(
111        vector: Arc<dyn FilterableLongTermMemory>,
112        graph: Arc<dyn KnowledgeGraph>,
113        extractor: Arc<dyn EntityExtractor>,
114        metrics: Arc<RecallMetrics>,
115    ) -> Self {
116        Self {
117            vector,
118            graph,
119            extractor,
120            metrics,
121            min_graph_hits: DEFAULT_MIN_GRAPH_HITS,
122        }
123    }
124
125    /// Override the minimum candidate count required to take the graph
126    /// path. When `graph.neighbors()` returns fewer than `n` `FactId`s,
127    /// recall falls back to pure vector. Default is `1`.
128    pub fn with_min_graph_hits(mut self, n: usize) -> Self {
129        self.min_graph_hits = n;
130        self
131    }
132
133    /// Capability-shaped builder facade — defaults the extractor to
134    /// [`BuiltinExtractor`] (regex over caller hints + typed-id
135    /// patterns) and the metrics to a fresh [`RecallMetrics`].
136    ///
137    /// ```no_run
138    /// # use std::sync::Arc;
139    /// # use klieo_memory_graph::{FilterableLongTermMemory, KnowledgeGraph};
140    /// # use klieo_memory_graph_rag::GraphAwareLongTerm;
141    /// # async fn run(vector: Arc<dyn FilterableLongTermMemory>, graph: Arc<dyn KnowledgeGraph>) {
142    /// let long_term = GraphAwareLongTerm::builder().build(vector, graph);
143    /// # let _ = long_term; }
144    /// ```
145    ///
146    /// Override defaults via the builder's setters; pass the
147    /// authoritative vector store + graph at [`GraphAwareLongTermBuilder::build`]
148    /// time since both are required.
149    pub fn builder() -> GraphAwareLongTermBuilder {
150        GraphAwareLongTermBuilder::default()
151    }
152
153    /// Graph-first recall core shared by [`LongTermMemory::recall`] and
154    /// [`Self::recall_traced`] — the single place that decides whether a
155    /// query takes the graph-filtered path or falls back to pure vector,
156    /// so the trace can never drift from what `recall` actually returns.
157    ///
158    /// Records no [`RecallMetrics`]: the caller owns that decision, so a
159    /// debug `recall_traced` read never pollutes the production graph-hit
160    /// vs vector-fallback ratio the ops endpoint reports.
161    async fn recall_core(
162        &self,
163        scope: &Scope,
164        query: &str,
165        k: usize,
166    ) -> Result<RecallCore, MemoryError> {
167        let entities = self
168            .extractor
169            .extract(query, &[])
170            .await
171            .unwrap_or_else(|e| {
172                tracing::warn!(error = %e, "extractor failed on recall; falling back to pure vector");
173                Vec::new()
174            });
175
176        if !entities.is_empty() {
177            match self.graph.neighbors(scope, &entities).await {
178                Ok(candidate_ids) if candidate_ids.len() >= self.min_graph_hits => {
179                    let facts = self
180                        .vector
181                        .recall_filtered(scope.clone(), query, k, &candidate_ids)
182                        .await?;
183                    return Ok(RecallCore {
184                        entities,
185                        facts,
186                        fell_back_to_vector: false,
187                        graph_candidates: candidate_ids.len(),
188                    });
189                }
190                Ok(_) => { /* below threshold — fall through to vector */ }
191                Err(e) => tracing::warn!(
192                    error = %e,
193                    "graph neighbors failed on recall; falling back to pure vector"
194                ),
195            }
196        }
197        let facts = self.vector.recall(scope.clone(), query, k).await?;
198        Ok(RecallCore {
199            entities,
200            facts,
201            fell_back_to_vector: true,
202            graph_candidates: 0,
203        })
204    }
205
206    /// The "why did retrieval return this?" trace behind one recall call.
207    ///
208    /// Runs the exact same [`Self::recall_core`] that
209    /// [`LongTermMemory::recall`] uses, then additionally fetches
210    /// [`KnowledgeGraph::recall_paths`] for display. Production `recall`
211    /// never issues that second graph call — this method is the only path
212    /// that pays for it.
213    ///
214    /// Records no [`RecallMetrics`] — it's a debug/introspection call, not
215    /// production recall traffic.
216    pub async fn recall_traced(
217        &self,
218        scope: Scope,
219        query: &str,
220        k: usize,
221    ) -> Result<RecallTrace, MemoryError> {
222        let core = self.recall_core(&scope, query, k).await?;
223        // Display-only enrichment: a failure here must not fail the trace,
224        // but log it — the sibling fallback arms in `recall_core` log theirs,
225        // and a silent drop would hide backend failures from the why-lens.
226        let paths = match self.graph.recall_paths(&scope, &core.entities).await {
227            Ok(paths) => paths,
228            Err(e) => {
229                tracing::warn!(
230                    ?e,
231                    "recall_paths failed in recall_traced; returning empty paths"
232                );
233                Vec::new()
234            }
235        };
236        Ok(RecallTrace {
237            extracted_entities: core.entities,
238            paths,
239            ranked_facts: core.facts,
240            fell_back_to_vector: core.fell_back_to_vector,
241        })
242    }
243}
244
245/// Builder facade for [`GraphAwareLongTerm`] — defaults the extractor
246/// and metrics for the common case so callers only supply the two
247/// mandatory backends (vector + graph).
248#[derive(Default)]
249pub struct GraphAwareLongTermBuilder {
250    extractor: Option<Arc<dyn EntityExtractor>>,
251    metrics: Option<Arc<RecallMetrics>>,
252    min_graph_hits: Option<usize>,
253}
254
255impl GraphAwareLongTermBuilder {
256    /// Override the default [`BuiltinExtractor`]. Use a
257    /// [`crate::FallbackExtractor`] or [`crate::LlmEntityExtractor`] for
258    /// richer extraction.
259    pub fn extractor(mut self, extractor: Arc<dyn EntityExtractor>) -> Self {
260        self.extractor = Some(extractor);
261        self
262    }
263
264    /// Supply a shared [`RecallMetrics`] when the caller wants to read
265    /// `metrics.snapshot()` from an ops/metrics endpoint. Without this,
266    /// the builder allocates a fresh counter the caller cannot observe.
267    pub fn metrics(mut self, metrics: Arc<RecallMetrics>) -> Self {
268        self.metrics = Some(metrics);
269        self
270    }
271
272    /// Override the minimum candidate count required to take the graph
273    /// path. Default `1` (any graph hit wins).
274    pub fn min_graph_hits(mut self, n: usize) -> Self {
275        self.min_graph_hits = Some(n);
276        self
277    }
278
279    /// Build the composer. `vector` is the authoritative store; `graph`
280    /// is the best-effort recall index. Defaults fill in any setter
281    /// that was not called.
282    pub fn build(
283        self,
284        vector: Arc<dyn FilterableLongTermMemory>,
285        graph: Arc<dyn KnowledgeGraph>,
286    ) -> GraphAwareLongTerm {
287        GraphAwareLongTerm {
288            vector,
289            graph,
290            extractor: self
291                .extractor
292                .unwrap_or_else(|| Arc::new(BuiltinExtractor::default())),
293            metrics: self
294                .metrics
295                .unwrap_or_else(|| Arc::new(RecallMetrics::default())),
296            min_graph_hits: self.min_graph_hits.unwrap_or(DEFAULT_MIN_GRAPH_HITS),
297        }
298    }
299}
300
301fn extract_hints_from_metadata(metadata: &serde_json::Value) -> Vec<EntityRef> {
302    metadata
303        .get(KEY_ENTITIES)
304        .and_then(|v| serde_json::from_value::<Vec<EntityRef>>(v.clone()).ok())
305        .unwrap_or_default()
306}
307
308fn parse_valid_from(metadata: &serde_json::Value) -> Option<DateTime<Utc>> {
309    metadata
310        .get(KEY_VALID_FROM)
311        .and_then(|v| v.as_str())
312        .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
313        .map(|dt| dt.with_timezone(&Utc))
314}
315
316#[async_trait]
317impl LongTermMemory for GraphAwareLongTerm {
318    async fn remember(&self, scope: Scope, fact: Fact) -> Result<FactId, MemoryError> {
319        // Vector is authoritative — mint the FactId first.
320        let fact_id = self.vector.remember(scope.clone(), fact.clone()).await?;
321
322        let hints = extract_hints_from_metadata(&fact.metadata);
323        let valid_from = parse_valid_from(&fact.metadata);
324        let entities = self
325            .extractor
326            .extract(&fact.text, &hints)
327            .await
328            .unwrap_or_else(|e| {
329                tracing::warn!(error = %e, "extractor failed; indexing graph with caller hints only");
330                hints.clone()
331            });
332
333        if entities.is_empty() {
334            return Ok(fact_id);
335        }
336
337        if let Err(e) = self
338            .graph
339            .index(scope, &fact_id, &entities, &fact.text, valid_from)
340            .await
341        {
342            tracing::warn!(
343                fact_id = %fact_id,
344                error = %e,
345                "graph index failed; vector remains authoritative"
346            );
347        }
348        Ok(fact_id)
349    }
350
351    async fn recall(&self, scope: Scope, query: &str, k: usize) -> Result<Vec<Fact>, MemoryError> {
352        let core = self.recall_core(&scope, query, k).await?;
353        if core.fell_back_to_vector {
354            self.metrics.record_vector_fallback();
355        } else {
356            self.metrics
357                .record_graph_hit_with_candidates(core.graph_candidates as u64);
358        }
359        Ok(core.facts)
360    }
361
362    async fn forget(&self, id: FactId) -> Result<(), MemoryError> {
363        // Graph-side forget lands in M4 (paired with provenance chain).
364        // Until then this delegates to vector only.
365        self.vector.forget(id).await
366    }
367}