Skip to main content

velesdb_memory/
storage.rs

1//! Storage backend abstraction for [`crate::service::MemoryService`].
2//!
3//! The wedge orchestration (remember/recall/relate/forget/why/fusion) is
4//! written once, generic over [`MemoryStore`], so it runs unchanged over any
5//! backend: the native, file-backed [`NativeStore`] (the default — nothing
6//! changes for existing callers), or an in-memory backend such as the one
7//! `velesdb-wasm` provides for the browser (no filesystem, no `persistence`
8//! feature).
9
10#[cfg(feature = "persistence")]
11use std::collections::HashMap;
12#[cfg(feature = "persistence")]
13use std::path::Path;
14#[cfg(feature = "persistence")]
15use std::sync::Arc;
16
17#[cfg(feature = "persistence")]
18use serde_json::json;
19use serde_json::Value;
20#[cfg(feature = "persistence")]
21use velesdb_core::agent::AgentMemory;
22#[cfg(feature = "persistence")]
23use velesdb_core::{Database, SearchResult};
24
25use crate::error::MemoryError;
26use crate::model::{BoundedMemoryEdges, ColumnFilter, MemoryEdge, Recollection};
27use crate::service::Metadata;
28
29/// The storage primitives [`crate::service::MemoryService`] needs: write,
30/// vector search, graph edges, and by-id lookup. A backend that implements
31/// this trait can run the full wedge (`remember`/`recall`/`recall_fused`/
32/// `relate`/`forget`/`why`/`remember_extracted`) with no orchestration code
33/// duplicated.
34pub trait MemoryStore {
35    /// Store a fact with no metadata or expiry.
36    ///
37    /// # Errors
38    /// Returns [`MemoryError`] if persistence fails.
39    fn store(&self, id: u64, content: &str, embedding: &[f32]) -> Result<(), MemoryError>;
40
41    /// Store a fact tagged with `metadata`, no expiry.
42    ///
43    /// # Errors
44    /// Returns [`MemoryError`] if persistence fails.
45    fn store_with_metadata(
46        &self,
47        id: u64,
48        content: &str,
49        embedding: &[f32],
50        metadata: &Metadata,
51    ) -> Result<(), MemoryError>;
52
53    /// Store a fact that expires after `ttl_seconds`, no metadata.
54    ///
55    /// # Errors
56    /// Returns [`MemoryError`] if persistence fails.
57    fn store_with_ttl(
58        &self,
59        id: u64,
60        content: &str,
61        embedding: &[f32],
62        ttl_seconds: u64,
63    ) -> Result<(), MemoryError>;
64
65    /// Store a fact with BOTH metadata and a durable TTL, in ONE write.
66    ///
67    /// Default: the historical two-call sequence, so a backend written before
68    /// this method keeps compiling and behaving as it did. Backends that can
69    /// write both at once should override it — the two-call form leaves the
70    /// fact live and expiring between the calls, so a short TTL can lapse in
71    /// the gap and the metadata write then fails on a fact that was perfectly
72    /// valid when the caller asked for it.
73    ///
74    /// # Errors
75    /// Returns [`MemoryError`] if persistence fails.
76    fn store_with_metadata_and_ttl(
77        &self,
78        id: u64,
79        content: &str,
80        embedding: &[f32],
81        metadata: &Metadata,
82        ttl_seconds: u64,
83    ) -> Result<(), MemoryError> {
84        self.store_with_ttl(id, content, embedding, ttl_seconds)?;
85        self.update_metadata(id, metadata)
86    }
87
88    /// Merge `metadata` into an already-stored fact's payload, preserving any
89    /// durable TTL. Used to combine metadata with an expiry (store both in
90    /// two calls rather than needing every metadata×TTL combination as a
91    /// separate primitive).
92    ///
93    /// # Errors
94    /// Returns [`MemoryError`] if `id` is unknown or persistence fails.
95    fn update_metadata(&self, id: u64, metadata: &Metadata) -> Result<(), MemoryError>;
96
97    /// A fact's content and embedding, or `None` if unknown/expired.
98    ///
99    /// # Errors
100    /// Returns [`MemoryError`] if storage access fails.
101    fn get(&self, id: u64) -> Result<Option<(String, Vec<f32>)>, MemoryError>;
102
103    /// A fact's raw stored payload — reserved system keys (`_veles_*`)
104    /// included, so the service layer can check the hub flag before
105    /// stripping them for the caller — or `None` when the fact is
106    /// unknown/expired.
107    ///
108    /// # Errors
109    /// Returns [`MemoryError`] if storage access fails.
110    fn get_metadata(&self, id: u64) -> Result<Option<Metadata>, MemoryError>;
111
112    /// Batched [`Self::get_metadata`]: one storage round trip for every id
113    /// in `ids`, results in the same order and length (an unknown or expired
114    /// id maps to `None`). Same raw-payload semantics as the single-id form.
115    ///
116    /// # Errors
117    /// Returns [`MemoryError`] if storage access fails.
118    fn get_metadata_batch(&self, ids: &[u64]) -> Result<Vec<Option<Metadata>>, MemoryError>;
119
120    /// Delete a fact.
121    ///
122    /// # Errors
123    /// Returns [`MemoryError`] if deletion fails.
124    fn delete(&self, id: u64) -> Result<(), MemoryError>;
125
126    /// Vector search for up to `k` ids, narrowed to facts whose metadata
127    /// exactly matches every key in `filter`.
128    ///
129    /// # Errors
130    /// Returns [`MemoryError`] if the query fails.
131    fn query_filtered(
132        &self,
133        embedding: &[f32],
134        k: usize,
135        filter: &Metadata,
136        offset: usize,
137    ) -> Result<Vec<(u64, f32, String)>, MemoryError>;
138
139    /// Vector search for up to `k` ids, dropping facts whose metadata matches
140    /// every key in `exclude`.
141    ///
142    /// # Errors
143    /// Returns [`MemoryError`] if the query fails.
144    fn query_excluding(
145        &self,
146        embedding: &[f32],
147        k: usize,
148        exclude: &Metadata,
149    ) -> Result<Vec<(u64, f32, String)>, MemoryError>;
150
151    /// Vector search fused with structured `ColumnStore` predicates (ranges
152    /// and comparisons, not just equality) — the engine behind
153    /// [`crate::service::MemoryService::recall_where`].
154    ///
155    /// # Absent and null fields
156    ///
157    /// **A filter is satisfied only by a fact that HAS the field with a
158    /// non-null value.** A fact missing the field, or storing `null` in it, is
159    /// never returned — and `ne` is no exception, exactly as a SQL comparison
160    /// against `NULL` is never true.
161    ///
162    /// | field state | `field != target` | `field == target` | `<` `<=` `>` `>=` |
163    /// |---|---|---|---|
164    /// | absent | no match | no match | no match |
165    /// | present, `null` | no match | no match | no match |
166    /// | present, equal | no match | match | per the comparison |
167    /// | present, different | **match** | no match | per the comparison |
168    ///
169    /// This is stated because it did not hold: `ne` on an absent field matched
170    /// on the native backend and never matched on WASM, for the API's whole
171    /// life, because nothing compared them (#1759). Every backend is now held
172    /// to one shared table —
173    /// [`crate::column_filter_conformance`] — run against both.
174    ///
175    /// Null-ness is not expressible through [`ColumnFilter`]; querying for it
176    /// is what `IsNull`/`IsNotNull` are for at the `VelesQL` layer.
177    ///
178    /// # Errors
179    /// Returns [`MemoryError::InvalidFilter`] if a filter field is not a
180    /// plain identifier or a filter value is non-scalar, or [`MemoryError`]
181    /// if the query fails.
182    fn query_columnar(
183        &self,
184        embedding: &[f32],
185        k: usize,
186        filters: &[ColumnFilter],
187    ) -> Result<Vec<Recollection>, MemoryError>;
188
189    /// Create a typed edge `from -> to`. Returns the edge id.
190    ///
191    /// # Errors
192    /// Returns [`MemoryError`] if either endpoint is missing or persistence fails.
193    fn relate(&self, from: u64, to: u64, relation: &str) -> Result<u64, MemoryError>;
194
195    /// The outgoing edges of `id`.
196    ///
197    /// # Errors
198    /// Returns [`MemoryError`] if storage access fails.
199    fn relations(&self, id: u64) -> Result<Vec<MemoryEdge>, MemoryError>;
200
201    /// The incoming edges of `id` — the mirror of [`Self::relations`], with
202    /// the same liveness rule applied to the far end (here the *source*).
203    ///
204    /// # Errors
205    /// Returns [`MemoryError`] if storage access fails.
206    fn incoming_relations(&self, id: u64) -> Result<Vec<MemoryEdge>, MemoryError>;
207
208    /// At most `cap` outgoing edges of `id`, plus whether its total degree
209    /// exceeded the scan — the bounded twin of [`Self::relations`] (#1820).
210    ///
211    /// The contract is on COST, not just shape: an implementation must keep
212    /// work and transient allocation O(cap), never O(degree) — a super-node
213    /// (an entity hub mentioned by thousands of facts) is exactly where this
214    /// accessor is reached for. `truncated` is a separate signal because
215    /// `edges.len() == cap` cannot carry it: a node with exactly `cap` edges
216    /// is indistinguishable from a truncated one.
217    ///
218    /// # Errors
219    /// Returns [`MemoryError`] if storage access fails.
220    fn relations_bounded(&self, id: u64, cap: usize) -> Result<BoundedMemoryEdges, MemoryError>;
221
222    /// At most `cap` incoming edges of `id`, plus whether its total incoming
223    /// degree exceeded the scan — the mirror of [`Self::relations_bounded`],
224    /// same O(cap) cost contract.
225    ///
226    /// # Errors
227    /// Returns [`MemoryError`] if storage access fails.
228    fn incoming_relations_bounded(
229        &self,
230        id: u64,
231        cap: usize,
232    ) -> Result<BoundedMemoryEdges, MemoryError>;
233
234    /// Remove the edge with `edge_id`. Returns `true` when it existed —
235    /// idempotent: removing an absent edge is `Ok(false)`, never an error.
236    ///
237    /// # Errors
238    /// Returns [`MemoryError`] if storage access fails.
239    fn unrelate(&self, edge_id: u64) -> Result<bool, MemoryError>;
240
241    /// The total number of live (non-expired) tracked facts, including
242    /// internal entity hubs — used as a corpus-size proxy for idf weighting.
243    fn count(&self) -> usize;
244
245    /// The total number of graph edges, when the backend can answer without
246    /// materializing them — the observable difference between a store whose
247    /// `why()` can walk somewhere and one where it degrades to plain
248    /// similarity search.
249    ///
250    /// Defaulted to `None` ("cannot say") rather than required, deliberately:
251    /// a backend outside this crate (velesdb-wasm's in-memory store) must
252    /// keep compiling when this surface grows, and a wrong-but-cheap answer
253    /// here would flag healthy graphs as flat. `memory_status` reports the
254    /// distinction to the caller instead of papering over it.
255    fn edge_count(&self) -> Option<usize> {
256        None
257    }
258
259    /// One cursor page of the store's live facts, ids ascending: up to
260    /// `limit` entries strictly after `cursor` (`None` starts the walk),
261    /// plus the cursor for the next page (`None` ends it). Payloads come
262    /// back RAW — reserved keys and scaffolding markers included — because
263    /// the policy of what a caller may see (hub filtering, key stripping)
264    /// belongs to the service layer, in one place, for every backend.
265    ///
266    /// TTL-expired facts are skipped, not listed: an audit must show what
267    /// the store will still serve, and a fact past its expiry is not it.
268    ///
269    /// Defaulted to a refusal rather than required, same reasoning as
270    /// [`Self::edge_count`]: an out-of-crate backend keeps compiling, and
271    /// its `list_memories` answers with this error instead of a wrong walk.
272    ///
273    /// # Errors
274    /// Returns [`MemoryError`] if the backend cannot enumerate at all, or if
275    /// the walk fails.
276    fn list(
277        &self,
278        cursor: Option<u64>,
279        limit: usize,
280    ) -> Result<(Vec<RawListedFact>, Option<u64>), MemoryError> {
281        let _ = (cursor, limit);
282        Err(MemoryError::InvalidFilter(
283            "this storage backend does not support listing".to_owned(),
284        ))
285    }
286}
287
288/// One fact as [`MemoryStore::list`] hands it to the service layer: content
289/// split out, everything else — reserved keys and scaffolding markers
290/// included — still in `payload` so the service can apply its visibility
291/// policy exactly once for every backend.
292#[derive(Debug, Clone)]
293pub struct RawListedFact {
294    /// Stable id of the fact.
295    pub id: u64,
296    /// The stored fact text (the payload's `content` key).
297    pub content: String,
298    /// The rest of the stored payload, verbatim.
299    pub payload: Metadata,
300}
301
302#[cfg(feature = "persistence")]
303impl RawListedFact {
304    /// The one place a stored payload is split into content + the rest —
305    /// shared by [`MemoryStore::list`] and the JSONL export so the two
306    /// reading surfaces can never disagree on what a fact's content IS.
307    pub(crate) fn from_raw(fact: &crate::migration::RawFact) -> Self {
308        let mut payload: Metadata = serde_json::from_str(&fact.payload).unwrap_or_default();
309        let content = match payload.remove("content") {
310            Some(Value::String(text)) => text,
311            _ => String::new(),
312        };
313        Self {
314            id: fact.id,
315            content,
316            payload,
317        }
318    }
319}
320
321/// The default [`MemoryStore`]: the native, file-backed engine
322/// (`velesdb-core`'s `Database`/`AgentMemory`, requiring the `persistence`
323/// feature). Existing callers of `MemoryService::open` see no change — this
324/// is exactly what they already ran.
325#[cfg(feature = "persistence")]
326pub struct NativeStore {
327    memory: AgentMemory,
328    /// Kept beside `memory` (which owns its own clone) for the read paths
329    /// that speak to the engine directly — [`MemoryStore::list`] walks the
330    /// collection cursor, which `AgentMemory` does not re-expose.
331    db: Arc<Database>,
332}
333
334#[cfg(feature = "persistence")]
335impl NativeStore {
336    /// Open (or create) a native store at `path`, sized for `dimension`.
337    ///
338    /// # Errors
339    /// Returns [`MemoryError`] if the store cannot be opened.
340    pub fn open<P: AsRef<Path>>(path: P, dimension: usize) -> Result<Self, MemoryError> {
341        let db = Arc::new(Database::open(path)?);
342        let memory = AgentMemory::with_dimension(Arc::clone(&db), dimension)?;
343        Ok(Self { memory, db })
344    }
345}
346
347#[cfg(feature = "persistence")]
348impl MemoryStore for NativeStore {
349    fn store(&self, id: u64, content: &str, embedding: &[f32]) -> Result<(), MemoryError> {
350        self.memory
351            .semantic()
352            .store(id, content, embedding)
353            .map_err(MemoryError::from)
354    }
355
356    fn store_with_metadata(
357        &self,
358        id: u64,
359        content: &str,
360        embedding: &[f32],
361        metadata: &Metadata,
362    ) -> Result<(), MemoryError> {
363        self.memory
364            .semantic()
365            .store_with_metadata(id, content, embedding, metadata)
366            .map_err(MemoryError::from)
367    }
368
369    fn store_with_ttl(
370        &self,
371        id: u64,
372        content: &str,
373        embedding: &[f32],
374        ttl_seconds: u64,
375    ) -> Result<(), MemoryError> {
376        self.memory
377            .semantic()
378            .store_with_ttl(id, content, embedding, ttl_seconds)
379            .map_err(MemoryError::from)
380    }
381
382    fn update_metadata(&self, id: u64, metadata: &Metadata) -> Result<(), MemoryError> {
383        self.memory
384            .semantic()
385            .update_metadata(id, metadata)
386            .map_err(MemoryError::from)
387    }
388
389    fn store_with_metadata_and_ttl(
390        &self,
391        id: u64,
392        content: &str,
393        embedding: &[f32],
394        metadata: &Metadata,
395        ttl_seconds: u64,
396    ) -> Result<(), MemoryError> {
397        // Ordre delibere : le fait est ecrit avec sa metadata et SANS
398        // expiration, donc il ne peut pas expirer entre les deux appels.
399        // L'expiration est posee ensuite. C'est l'inverse de la sequence
400        // historique (store_with_ttl puis update_metadata), ou le fait etait
401        // deja vivant et deja en train d'expirer pendant la seconde ecriture.
402        self.memory
403            .semantic()
404            .store_with_metadata(id, content, embedding, metadata)
405            .map_err(MemoryError::from)?;
406        self.memory
407            .semantic()
408            .set_ttl_durable(id, ttl_seconds)
409            .map_err(MemoryError::from)
410    }
411
412    fn get(&self, id: u64) -> Result<Option<(String, Vec<f32>)>, MemoryError> {
413        self.memory.semantic().get(id).map_err(MemoryError::from)
414    }
415
416    fn get_metadata(&self, id: u64) -> Result<Option<Metadata>, MemoryError> {
417        self.memory
418            .semantic()
419            .get_metadata(id)
420            .map_err(MemoryError::from)
421    }
422
423    fn get_metadata_batch(&self, ids: &[u64]) -> Result<Vec<Option<Metadata>>, MemoryError> {
424        self.memory
425            .semantic()
426            .get_metadata_batch(ids)
427            .map_err(MemoryError::from)
428    }
429
430    fn delete(&self, id: u64) -> Result<(), MemoryError> {
431        self.memory.semantic().delete(id).map_err(MemoryError::from)
432    }
433
434    fn query_filtered(
435        &self,
436        embedding: &[f32],
437        k: usize,
438        filter: &Metadata,
439        offset: usize,
440    ) -> Result<Vec<(u64, f32, String)>, MemoryError> {
441        self.memory
442            .semantic()
443            .query_filtered(embedding, k, filter, offset)
444            .map_err(MemoryError::from)
445    }
446
447    fn query_excluding(
448        &self,
449        embedding: &[f32],
450        k: usize,
451        exclude: &Metadata,
452    ) -> Result<Vec<(u64, f32, String)>, MemoryError> {
453        self.memory
454            .semantic()
455            .query_excluding(embedding, k, exclude)
456            .map_err(MemoryError::from)
457    }
458
459    fn query_columnar(
460        &self,
461        embedding: &[f32],
462        k: usize,
463        filters: &[ColumnFilter],
464    ) -> Result<Vec<Recollection>, MemoryError> {
465        let (sql, params) = self.build_fused_query(embedding, k, filters)?;
466        // Field names are validated by `build_fused_query`; ensure each one is
467        // indexed so the planner uses a bitmap prefilter instead of an O(n)
468        // post-filter scan. Idempotent and incrementally maintained thereafter.
469        for field in filters
470            .iter()
471            .map(|filter| filter.field.as_str())
472            .chain(INTERNAL_MARKER_FIELDS.iter().copied())
473        {
474            self.memory
475                .semantic()
476                .ensure_index(field)
477                .map_err(MemoryError::from)?;
478        }
479        let results = self
480            .memory
481            .query_semantic(&sql, &params)
482            .map_err(MemoryError::from)?;
483        Ok(results.iter().map(to_recollection).collect())
484    }
485
486    fn relate(&self, from: u64, to: u64, relation: &str) -> Result<u64, MemoryError> {
487        self.memory
488            .semantic()
489            .relate(from, to, relation, None)
490            .map_err(MemoryError::from)
491    }
492
493    fn relations(&self, id: u64) -> Result<Vec<MemoryEdge>, MemoryError> {
494        Ok(to_memory_edges(self.memory.semantic().relations(id)?))
495    }
496
497    fn incoming_relations(&self, id: u64) -> Result<Vec<MemoryEdge>, MemoryError> {
498        Ok(to_memory_edges(
499            self.memory.semantic().incoming_relations(id)?,
500        ))
501    }
502
503    fn relations_bounded(&self, id: u64, cap: usize) -> Result<BoundedMemoryEdges, MemoryError> {
504        let bounded = self.memory.semantic().relations_bounded(id, cap)?;
505        Ok(BoundedMemoryEdges {
506            edges: to_memory_edges(bounded.edges),
507            truncated: bounded.truncated,
508        })
509    }
510
511    fn incoming_relations_bounded(
512        &self,
513        id: u64,
514        cap: usize,
515    ) -> Result<BoundedMemoryEdges, MemoryError> {
516        let bounded = self.memory.semantic().incoming_relations_bounded(id, cap)?;
517        Ok(BoundedMemoryEdges {
518            edges: to_memory_edges(bounded.edges),
519            truncated: bounded.truncated,
520        })
521    }
522
523    fn unrelate(&self, edge_id: u64) -> Result<bool, MemoryError> {
524        self.memory
525            .semantic()
526            .unrelate(edge_id)
527            .map_err(MemoryError::from)
528    }
529
530    fn count(&self) -> usize {
531        self.memory.semantic().count()
532    }
533
534    fn edge_count(&self) -> Option<usize> {
535        // A collection-access failure here means the store is unusable for
536        // every other call too; for a status readout "cannot say" is the
537        // honest degradation, not an error path of its own.
538        self.memory.semantic().edge_count().ok()
539    }
540
541    fn list(
542        &self,
543        cursor: Option<u64>,
544        limit: usize,
545    ) -> Result<(Vec<RawListedFact>, Option<u64>), MemoryError> {
546        // The migration module's cursor walk, reused verbatim: id-keyed,
547        // ascending, exclusive, and it skips TTL-expired points — exactly
548        // the audit contract (#1762 built it to enumerate a store with full
549        // fidelity, which is what an audit is).
550        let (facts, next) = crate::migration::scroll_page(
551            &self.db,
552            self.memory.semantic().collection_name(),
553            cursor,
554            limit,
555        )?;
556        let listed = facts.iter().map(RawListedFact::from_raw).collect();
557        Ok((listed, next))
558    }
559}
560
561/// Map core [`GraphEdge`](velesdb_core::collection::graph::GraphEdge)s to the
562/// wire-facing [`MemoryEdge`] shape — shared by both edge directions, so the
563/// two can never disagree on which endpoint or id they report.
564#[cfg(feature = "persistence")]
565fn to_memory_edges(edges: Vec<velesdb_core::collection::graph::GraphEdge>) -> Vec<MemoryEdge> {
566    edges
567        .into_iter()
568        .map(|edge| MemoryEdge {
569            id: edge.id(),
570            from: edge.source(),
571            to: edge.target(),
572            relation: edge.label().to_owned(),
573        })
574        .collect()
575}
576
577#[cfg(feature = "persistence")]
578impl NativeStore {
579    /// Build the `VelesQL` for [`Self::query_columnar`]: a `NEAR` predicate
580    /// plus one bound parameter per filter, against the semantic collection.
581    /// Filter *values* are bound as query parameters (never interpolated);
582    /// filter *field names* are validated to be plain identifiers.
583    fn build_fused_query(
584        &self,
585        embedding: &[f32],
586        k: usize,
587        filters: &[ColumnFilter],
588    ) -> Result<(String, HashMap<String, Value>), MemoryError> {
589        use std::fmt::Write as _;
590        let mut params: HashMap<String, Value> = HashMap::new();
591        params.insert("q".to_string(), json!(embedding));
592        let mut predicate = String::from("vector NEAR $q");
593        for (index, filter) in filters.iter().enumerate() {
594            validate_column_filter(filter)?;
595            let key = format!("p{index}");
596            // `ne` is spelled out rather than left to `!=` alone. Core's
597            // `Condition::Neq` is `is_none_or`, so a bare `field != $p` also
598            // matches a fact that HAS no such field — which made `ne` mean two
599            // different things on the two backends (#1759). Requiring the
600            // field first pins the published contract here, at the adapter,
601            // instead of redefining `!=` for all of `VelesQL` — that is a
602            // product-wide semantic break, deliberately out of scope.
603            //
604            // `IS NOT NULL` is false for an absent field AND for an explicit
605            // `null`, which is exactly the contract: a comparison against null
606            // is never true, as in SQL. `IsNull`/`IsNotNull` stay the operators
607            // dedicated to null-ness.
608            if matches!(filter.op, crate::model::ColumnOp::Ne) {
609                let _ = write!(predicate, " AND {} IS NOT NULL", filter.field);
610            }
611            let _ = write!(
612                predicate,
613                " AND {} {} ${key}",
614                filter.field,
615                filter.op.as_sql()
616            );
617            params.insert(key, filter.value.clone());
618        }
619        // Exclude internal scaffolding INSIDE the query rather than after it:
620        // the engine applies `LIMIT k`, so a post-filter would quietly return
621        // fewer than `k` caller facts whenever artefacts crowd the ranking.
622        //
623        // `!=` is what excludes here, and it works for the same reason the
624        // leak existed: `Condition::Neq` is `is_none_or`, so a fact that has
625        // no such column at all MATCHES. Applied to a marker, that keeps every
626        // caller fact (none carries one) and drops exactly the class that
627        // does. These names are compile-time constants, never caller input,
628        // so they go straight into the text without passing through
629        // `validate_column_filter` — whose job is to reject a CALLER filter
630        // naming a reserved key.
631        //
632        // The asymmetry with the caller loop above is DELIBERATE and load-
633        // bearing: a caller's `ne` now carries an `IS NOT NULL`, this exclusion
634        // must NOT. It depends on the absent field matching — that is how it
635        // keeps every caller fact while dropping the marked ones. Giving these
636        // markers the same treatment would exclude every caller fact instead,
637        // since none of them carries a marker column at all.
638        for (index, marker) in INTERNAL_MARKER_FIELDS.iter().enumerate() {
639            let key = format!("m{index}");
640            let _ = write!(predicate, " AND {marker} != ${key}");
641            params.insert(key, json!(true));
642        }
643        let sql = format!(
644            "SELECT * FROM {} WHERE {predicate} LIMIT {k}",
645            self.memory.semantic().collection_name()
646        );
647        Ok((sql, params))
648    }
649}
650
651/// Reserved metadata key `remember`/`remember_with_ttl` auto-stamp with
652/// today's date (a `YYYYMMDD` integer, [`crate::clock::today_ymd`]) whenever
653/// the caller didn't already set it — see
654/// [`crate::service::MemoryService::remember_with_ttl`] for the full
655/// contract. A deliberate, documented **exception** to every other
656/// `_veles_`-namespaced key: [`is_reserved_key`] still names it (so it can
657/// never be confused with an arbitrary caller field), but unlike a true
658/// system key —
659/// - a caller MAY set it explicitly (to date a fact retroactively; never
660///   overwritten once present), and
661/// - it is NOT stripped from caller-facing results, so
662///   [`crate::dated_context::format_dated_context`]'s `date_field` (wired
663///   through `recall_fused`'s `date_field` parameter) can read it back with
664///   zero caller effort.
665///
666/// `pub` (re-exported at the crate root) so every caller of `date_field`
667/// names this one string in exactly one place, not a copy-pasted literal.
668pub const AUTO_DATE_FIELD: &str = "_veles_date";
669
670/// True for metadata keys the memory layer reserves: the engine's `content`
671/// payload, and any `_veles_`-namespaced system key (durable TTL, entity
672/// hubs) — [`AUTO_DATE_FIELD`] EXCEPTED, since (unlike every other reserved
673/// key) it is caller-settable and caller-visible by design. The single
674/// source of the reserved-key contract — the service layer (reject/strip)
675/// and every backend enforce it through this one predicate.
676pub(crate) fn is_reserved_key(key: &str) -> bool {
677    key != AUTO_DATE_FIELD && (key == "content" || key.starts_with("_veles_"))
678}
679
680/// Marks an entity hub minted by `remember_extracted` — graph scaffolding,
681/// never a fact the caller stored.
682pub const HUB_FIELD: &str = "_veles_hub";
683/// Marks a compilation event recorded for `context_savings`.
684pub const CTX_EVENT_FIELD: &str = "_veles_ctx_event";
685/// Marks a stored compilation source, served back by `retrieve_context_source`.
686pub const CTX_SOURCE_FIELD: &str = "_veles_ctx_source";
687/// Marks a saved working context, served back by `load_working_context`.
688pub const CTX_WORKING_FIELD: &str = "_veles_ctx_working";
689/// Marks a project's working-context index, read by `list_working_contexts`.
690pub const CTX_WORKING_INDEX_FIELD: &str = "_veles_ctx_working_index";
691
692/// Every marker that identifies a stored fact as internal scaffolding rather
693/// than a caller memory. Facts of these five classes live in the same
694/// collection as caller facts and are written by exactly one path each; the
695/// markers are declared here, and imported by those paths, so the write and
696/// the exclusion cannot drift apart.
697///
698/// The discriminant is the PRESENCE of one of these keys — deliberately NOT
699/// the `_veles_` prefix. [`AUTO_DATE_FIELD`] (`_veles_date`) is reserved too
700/// and is stamped onto ordinary CALLER facts, so a prefix test would hide the
701/// entire store instead of the scaffolding.
702pub const INTERNAL_MARKER_FIELDS: &[&str] = &[
703    HUB_FIELD,
704    CTX_EVENT_FIELD,
705    CTX_SOURCE_FIELD,
706    CTX_WORKING_FIELD,
707    CTX_WORKING_INDEX_FIELD,
708];
709
710/// Whether a raw payload belongs to one of the five internal classes.
711///
712/// `pub` and shared for the same reason as [`validate_column_filter`]: a
713/// caller-facing recall path must not depend on which backend answered it.
714/// A backend that can test the payload directly should use this; one that
715/// pushes the predicate into a query builds the equivalent there — the
716/// authority on *which* markers count is this list either way.
717#[must_use]
718pub fn is_internal_scaffolding(payload: &Metadata) -> bool {
719    INTERNAL_MARKER_FIELDS
720        .iter()
721        .any(|marker| payload.contains_key(*marker))
722}
723
724/// Drop reserved system keys from a raw payload, and collapse an
725/// empty-after-stripping map to `None` — the caller-facing shape every
726/// [`Recollection::metadata`] is built from. `pub` because a [`MemoryStore`]
727/// backend that assembles `Recollection`s itself (`query_columnar`) must
728/// apply the same stripping the service layer applies on every other recall
729/// path, or reserved keys leak to callers on that one path only.
730#[must_use]
731pub fn strip_reserved_keys(payload: Option<Metadata>) -> Option<Metadata> {
732    payload.and_then(|payload| {
733        let metadata: Metadata = payload
734            .into_iter()
735            .filter(|(key, _)| !is_reserved_key(key))
736            .collect();
737        (!metadata.is_empty()).then_some(metadata)
738    })
739}
740
741/// [`strip_reserved_keys`] over a *borrowed* payload: clones only the
742/// surviving non-reserved entries. Use this when the payload isn't already
743/// owned — cloning the whole map first would deep-copy the reserved
744/// `content` value (the full fact text) per hit, only to discard it.
745#[must_use]
746pub fn strip_reserved_keys_ref(payload: Option<&Metadata>) -> Option<Metadata> {
747    payload.and_then(|payload| {
748        let metadata: Metadata = payload
749            .iter()
750            .filter(|(key, _)| !is_reserved_key(key))
751            .map(|(key, value)| (key.clone(), value.clone()))
752            .collect();
753        (!metadata.is_empty()).then_some(metadata)
754    })
755}
756
757/// Map a core search result to a [`Recollection`], lifting the fact text out
758/// of the reserved `content` payload key and surfacing any remaining
759/// caller-supplied metadata (reserved system keys excluded).
760#[cfg(feature = "persistence")]
761fn to_recollection(result: &SearchResult) -> Recollection {
762    let payload = result.point.payload.as_ref().and_then(Value::as_object);
763    let content = payload
764        .and_then(|payload| payload.get("content"))
765        .and_then(Value::as_str)
766        .unwrap_or_default()
767        .to_owned();
768    Recollection {
769        id: result.point.id,
770        score: result.score,
771        content,
772        metadata: strip_reserved_keys_ref(payload),
773    }
774}
775
776/// Validate one `recall_where` column filter: a plain, non-reserved
777/// identifier field name and a scalar (string/number/boolean) value. `pub`
778/// and shared so every [`MemoryStore`] backend enforces the *same* documented
779/// contract — the field-name rule keeps a filter safe to place into query
780/// text (`NativeStore` builds `VelesQL`; values are always bound parameters),
781/// and rejects the reserved system columns (`content`, `_veles_*`) regardless
782/// of backend; the scalar rule turns what would be an opaque engine error
783/// into a clear client-input error.
784///
785/// # Errors
786/// Returns [`MemoryError::InvalidFilter`] when either rule is violated.
787pub fn validate_column_filter(filter: &ColumnFilter) -> Result<(), MemoryError> {
788    let field = &filter.field;
789    let plain = !field.is_empty() && field.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
790    if !plain || is_reserved_key(field) {
791        return Err(MemoryError::InvalidFilter(field.clone()));
792    }
793    match &filter.value {
794        Value::String(_) | Value::Number(_) | Value::Bool(_) => Ok(()),
795        value => Err(MemoryError::InvalidFilter(format!(
796            "value must be a string, number, or boolean, got {value}"
797        ))),
798    }
799}
800
801#[cfg(all(test, feature = "persistence"))]
802#[path = "storage_tests.rs"]
803mod tests;