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}