klieo_memory_graph/traits.rs
1//! Trait surface for graph-aware memory.
2//!
3//! Lives in `klieo-memory-graph` at 0.x (see ADR-035) — `klieo-core` stays
4//! frozen at 1.x and cannot host these traits.
5
6use crate::types::EntityRef;
7use async_trait::async_trait;
8use chrono::{DateTime, Utc};
9use klieo_core::error::MemoryError;
10use klieo_core::ids::FactId;
11use klieo_core::memory::{Fact, LongTermMemory, Scope};
12
13/// One element of a batched [`KnowledgeGraph::index_many`] call.
14///
15/// Mirrors the per-fact arguments of [`KnowledgeGraph::index`] so
16/// backends that natively support multi-fact UNWIND / bulk-insert can
17/// flush an entire batch in O(1) round-trips instead of O(N).
18#[derive(Debug, Clone)]
19#[non_exhaustive]
20pub struct IndexEntry {
21 #[allow(missing_docs)]
22 pub fact_id: FactId,
23 #[allow(missing_docs)]
24 pub entities: Vec<EntityRef>,
25 /// Fact text — passed for parity with single `index`. Backends
26 /// that don't store the text on the graph node can ignore it.
27 pub text: String,
28 /// World-time validity. `None` defaults to `recorded_at`.
29 pub valid_from: Option<DateTime<Utc>>,
30}
31
32impl IndexEntry {
33 #[allow(missing_docs)]
34 pub fn new(
35 fact_id: FactId,
36 entities: Vec<EntityRef>,
37 text: impl Into<String>,
38 valid_from: Option<DateTime<Utc>>,
39 ) -> Self {
40 Self {
41 fact_id,
42 entities,
43 text: text.into(),
44 valid_from,
45 }
46 }
47}
48
49/// Structured entity graph over stored facts.
50///
51/// Implementations index facts as `MENTIONED_IN` edges from `Entity` nodes
52/// to `FactRef` nodes, plus `CO_OCCURS` edges between entities seen in
53/// the same fact. `neighbors()` returns fact ids reachable via direct
54/// `MENTIONED_IN` plus 1-hop `CO_OCCURS` sibling traversal — see
55/// [`Self::neighbors`] for the precise contract.
56#[async_trait]
57pub trait KnowledgeGraph: Send + Sync {
58 /// Index a stored fact's entities into the graph.
59 ///
60 /// `valid_from` records when the knowledge was valid in the world.
61 /// `None` defaults to `recorded_at` (now). Pass `known_epoch()` for
62 /// all-time knowledge (regulatory texts, baseline facts).
63 ///
64 /// ### Upsert semantics
65 ///
66 /// `index()` is **additive**, not replacing — re-indexing the same
67 /// `FactId` with a different entity set adds new `MENTIONED_IN` edges
68 /// and increments `CO_OCCURS` counts for any newly-co-occurring pairs.
69 /// **Existing edges are not removed.** Callers that need replace
70 /// semantics must call `forget(fact_id)` first (M4). M1 does not ship
71 /// `forget()`; the entire fact-set is append-only until M4.
72 async fn index(
73 &self,
74 scope: Scope,
75 fact_id: &FactId,
76 entities: &[EntityRef],
77 text: &str,
78 valid_from: Option<DateTime<Utc>>,
79 ) -> Result<(), MemoryError>;
80
81 /// Index a batch of facts under the same [`Scope`] in one call.
82 ///
83 /// Same upsert semantics as [`Self::index`] applied per entry.
84 /// Default impl loops on [`Self::index`] sequentially so
85 /// existing backends remain correct without source change;
86 /// backends with native bulk-insert (e.g. Neo4j UNWIND) override
87 /// for O(1) round-trip per batch.
88 ///
89 /// ### Skip-empty contract
90 ///
91 /// Entries whose `entities` is empty are skipped — no graph
92 /// write, no observable side effect. Wrappers (e.g.
93 /// `ProvenanceKnowledgeGraph`) MUST filter the same way before
94 /// emitting side-effect ledgers so audit state aligns with
95 /// graph state.
96 ///
97 /// ### Failure
98 ///
99 /// Fails fast on the first entry that errors — partial progress
100 /// is left committed because [`Self::index`] is additive (no
101 /// rollback across entries). Callers that need atomic batches
102 /// must wrap the call in a backend-specific transaction.
103 async fn index_many(&self, scope: Scope, batch: &[IndexEntry]) -> Result<(), MemoryError> {
104 for entry in batch {
105 if entry.entities.is_empty() {
106 continue;
107 }
108 self.index(
109 scope.clone(),
110 &entry.fact_id,
111 &entry.entities,
112 &entry.text,
113 entry.valid_from,
114 )
115 .await?;
116 }
117 Ok(())
118 }
119
120 /// Return `FactId`s of facts reachable from `entities` via:
121 /// (a) direct `MENTIONED_IN` edges, and
122 /// (b) 1-hop `CO_OCCURS` siblings' `MENTIONED_IN` edges.
123 ///
124 /// Depth is fixed at 1-hop CO_OCCURS for M1; a `hops`/`max_hops`
125 /// parameter is deliberately omitted until M5 introduces PathRAG
126 /// traversal depth control (no impl currently honors it, so the
127 /// signature stays honest about what callers get).
128 async fn neighbors(
129 &self,
130 scope: &Scope,
131 entities: &[EntityRef],
132 ) -> Result<Vec<FactId>, MemoryError>;
133
134 /// Return one [`crate::RetrievalPath`] per `(entity, fact_id)`
135 /// edge reachable from `entities` via the same traversal as
136 /// [`Self::neighbors`]. Each hop carries `chain_entry: None`;
137 /// the `ProvenanceKnowledgeGraph` wrapper attaches provenance
138 /// after the inner graph returns.
139 ///
140 /// Default impl returns `Ok(Vec::new())` so existing backends
141 /// continue to compile against the 0.2 trait surface without
142 /// code change until they opt in to per-path output.
143 async fn recall_paths(
144 &self,
145 scope: &Scope,
146 entities: &[EntityRef],
147 ) -> Result<Vec<crate::RetrievalPath>, MemoryError> {
148 let _ = (scope, entities);
149 Ok(Vec::new())
150 }
151
152 /// Enumerate up to `limit` nodes of `scope`'s graph for browsing.
153 ///
154 /// Read-only, retrieval-agnostic — unlike [`Self::neighbors`] it needs
155 /// no seed entities. `GraphView::truncated` is set when the scope held
156 /// more nodes than `limit`. When truncated, the retained subset is
157 /// backend-defined but stable for a given graph state (the in-memory
158 /// backend keeps lowest node-index first). Default impl returns an empty
159 /// view so backends compile unchanged until they opt in.
160 async fn subgraph(&self, scope: &Scope, limit: usize) -> Result<crate::GraphView, MemoryError> {
161 let _ = (scope, limit);
162 Ok(crate::GraphView::default())
163 }
164
165 /// How many facts mention each entity under `scope`, keyed by entity
166 /// name — the corpus-wide document-frequency tally.
167 ///
168 /// Answers "is this term generic in this corpus", which every consumer
169 /// that wants to tell a specific term from a corpus-generic one needs.
170 /// Without it the only route is walking [`Self::subgraph`] and tallying
171 /// client-side, and `subgraph` takes a `limit` that returns a truncation
172 /// error rather than data — so the client has to pick a cap and hope.
173 /// A backend can count without materialising the view, which is the part
174 /// the client-side version cannot do.
175 ///
176 /// Names are the normalised (lowercase) [`EntityRef::name`], so an entity
177 /// indexed under several types is counted once per mention across all of
178 /// them. Entities with no mentions are absent, not zero.
179 ///
180 /// The default fails loudly rather than guessing: a silent `Ok(empty)`
181 /// reads as "nothing is generic in this corpus" and would let every
182 /// consumer's threshold pass on a backend that never counted anything.
183 async fn all_entity_frequencies(
184 &self,
185 scope: &Scope,
186 ) -> Result<std::collections::HashMap<String, usize>, MemoryError> {
187 let _ = scope;
188 Err(MemoryError::Store(
189 "backend does not implement all_entity_frequencies".into(),
190 ))
191 }
192
193 /// [`Self::all_entity_frequencies`] restricted to `names`.
194 ///
195 /// Defaults to filtering the full tally so a backend gets both from one
196 /// implementation; override when the store can count the named entities
197 /// directly, which is cheaper for a short list over a large corpus.
198 /// A name with no mentions maps to `0` rather than being absent — the
199 /// caller asked about it, so it gets an answer.
200 async fn entity_frequencies(
201 &self,
202 scope: &Scope,
203 names: &[&str],
204 ) -> Result<std::collections::HashMap<String, usize>, MemoryError> {
205 let all = self.all_entity_frequencies(scope).await?;
206 Ok(names
207 .iter()
208 .map(|name| ((*name).to_owned(), all.get(*name).copied().unwrap_or(0)))
209 .collect())
210 }
211
212 /// Remove a fact's graph-side index entries. Paired with a
213 /// `Custom("MemoryForget")` provenance event by
214 /// `ProvenanceKnowledgeGraph` so the audit chain records the
215 /// deletion even though the chain itself stays append-only.
216 ///
217 /// Default impl is a no-op; backends that support deletion
218 /// override. Calling on a fact that was never indexed must not
219 /// be an error.
220 async fn forget(&self, scope: &Scope, fact_id: &FactId) -> Result<(), MemoryError> {
221 let _ = (scope, fact_id);
222 Ok(())
223 }
224}
225
226/// `LongTermMemory` extension that supports candidate-set filtering.
227///
228/// Used by `GraphAwareLongTerm` (M2) to restrict vector recall to the
229/// fact ids surfaced by the graph traversal.
230#[async_trait]
231pub trait FilterableLongTermMemory: LongTermMemory {
232 /// Top-`k` semantic recall restricted to the given `candidate_ids`.
233 ///
234 /// Returns an empty `Vec` when `candidate_ids` is empty — callers
235 /// should treat empty candidates as "fall back to pure vector".
236 async fn recall_filtered(
237 &self,
238 scope: Scope,
239 query: &str,
240 k: usize,
241 candidate_ids: &[FactId],
242 ) -> Result<Vec<Fact>, MemoryError>;
243
244 /// Identifier of the embedder this store was initialised with.
245 /// Used by `recall_filtered_checked()` (M2 concrete impls) to
246 /// hard-fail on cross-embedder queries — re-indexing required
247 /// before mixing embedder versions.
248 fn embedder_id(&self) -> &str;
249}
250
251/// Extracts typed entity references from text.
252///
253/// Implementations include `BuiltinExtractor` (regex, M2),
254/// `LlmEntityExtractor` (M2), `FallbackExtractor` (chain primary→secondary
255/// on empty, M2). Tests use a `FakeExtractor` returning a fixed set.
256#[async_trait]
257pub trait EntityExtractor: Send + Sync {
258 /// Extract entities from `text`, merging with caller-supplied `hints`.
259 ///
260 /// Hints take precedence — extractor implementations dedupe by
261 /// `(EntityType::as_str(), name)` and never drop a hint silently.
262 async fn extract(&self, text: &str, hints: &[EntityRef])
263 -> Result<Vec<EntityRef>, MemoryError>;
264
265 /// Stable identity of *this* extractor, including anything it wraps.
266 ///
267 /// Exists for memoising decorators. A cache keyed on the text, the hints
268 /// and a caller-supplied model string but **not** on the extractor it
269 /// decorates serves the previous extractor's entries when the extractor
270 /// is swapped: the second arm of an A/B reads the first arm's cache, both
271 /// arms report identical numbers at full speed, and the run reads as
272 /// "the extractor makes no difference" rather than "the second arm never
273 /// ran". Fold this into the key instead.
274 ///
275 /// The default is the concrete type name, which distinguishes swaps
276 /// between extractor *types*. Override when two instances of the same
277 /// type differ in a way that changes output — a different model, a
278 /// different prompt, a different regex table — and compose the inner
279 /// ids in decorators so the whole chain is named.
280 fn id(&self) -> std::borrow::Cow<'static, str> {
281 std::borrow::Cow::Borrowed(std::any::type_name::<Self>())
282 }
283}