Skip to main content

velesdb_memory/
service.rs

1//! The memory service: five operations over the in-core Agent Memory SDK.
2
3use std::collections::{HashMap, HashSet};
4use std::path::Path;
5use std::sync::Arc;
6
7use serde_json::{json, Map, Value};
8use velesdb_core::agent::AgentMemory;
9use velesdb_core::{Database, SearchResult};
10
11/// Structured metadata attached to a memory (the `ColumnStore` facet): exact-match
12/// fields like `project`, `author`, `type`, `status`, `date`. `content` and
13/// `_veles_expires_at` are reserved keys.
14pub type Metadata = Map<String, Value>;
15
16use crate::embedder::Embedder;
17use crate::error::MemoryError;
18use crate::extract::Extractor;
19use crate::id;
20use crate::model::{ColumnFilter, Explanation, Link, MemoryEdge, MemoryNode, Recollection};
21
22/// Reserved metadata key marking an entity hub auto-created by
23/// [`MemoryService::remember_extracted`] (value `true`). Namespaced under the
24/// system `_veles_` prefix so it can never collide with a caller's own metadata,
25/// and rejected from caller-supplied metadata/filters (see [`is_reserved_key`]).
26/// Hubs are internal graph scaffolding — they connect facts that share a topic —
27/// so they are excluded from unfiltered recall and from `why` seeds.
28const HUB_FIELD: &str = "_veles_hub";
29/// Salt mixed into a hub's stable id so the hub id space is disjoint from
30/// natural fact ids: a caller fact whose text happens to equal a hub's display
31/// content (`Entity: rust`) can never collide with, or overwrite, the hub.
32const HUB_ID_SALT: &str = "\u{0}_veles_entity_hub\u{0}";
33
34/// Local-first agent memory backed by a single `VelesDB` instance.
35///
36/// Generic over the [`Embedder`] so production can use an on-device model while
37/// tests use a deterministic, network-free one.
38pub struct MemoryService<E: Embedder> {
39    memory: AgentMemory,
40    embedder: E,
41}
42
43impl<E: Embedder> MemoryService<E> {
44    /// Open (or create) a memory store at `path`, using `embedder` for text
45    /// vectorization. The store never leaves this directory.
46    ///
47    /// # Errors
48    /// Returns [`MemoryError`] if the store cannot be opened or the agent
49    /// memory cannot be initialized for the embedder's dimension.
50    pub fn open<P: AsRef<Path>>(path: P, embedder: E) -> Result<Self, MemoryError> {
51        let db = Arc::new(Database::open(path)?);
52        let memory = AgentMemory::with_dimension(db, embedder.dimension())?;
53        Ok(Self { memory, embedder })
54    }
55
56    /// Remember a `fact`, optionally tagging it with structured `metadata`
57    /// (`ColumnStore` facet) and linking it to existing memories (graph facet).
58    /// Returns the stable id of the fact (idempotent on identical content).
59    ///
60    /// Link targets are validated to exist *before* the fact is stored, so a bad
61    /// link never leaves the fact half-written.
62    ///
63    /// # Errors
64    /// Returns [`MemoryError::EmptyFact`] for empty/whitespace facts,
65    /// [`MemoryError::ReservedKey`] if `metadata` names a reserved key
66    /// (`content` or any `_veles_`-prefixed system key),
67    /// [`MemoryError::UnknownMemory`] if a link points at a missing memory,
68    /// or a storage error if persistence fails.
69    pub fn remember(
70        &self,
71        fact: &str,
72        links: &[Link],
73        metadata: Option<&Metadata>,
74    ) -> Result<u64, MemoryError> {
75        self.remember_with_ttl(fact, links, metadata, None)
76    }
77
78    /// Like [`Self::remember`], but the fact **expires after `ttl_seconds`**.
79    ///
80    /// The expiry is a durable TTL — persisted with the fact (reserved
81    /// `_veles_expires_at` payload field), so it survives a process restart, and
82    /// expired facts stop being recalled. `None` (or `Some(0)`) stores the fact
83    /// permanently, exactly like [`Self::remember`]. Metadata and a TTL combine:
84    /// the metadata is written and the expiry preserved.
85    ///
86    /// # Errors
87    /// Same as [`Self::remember`].
88    pub fn remember_with_ttl(
89        &self,
90        fact: &str,
91        links: &[Link],
92        metadata: Option<&Metadata>,
93        ttl_seconds: Option<u64>,
94    ) -> Result<u64, MemoryError> {
95        let fact = fact.trim();
96        if fact.is_empty() {
97            return Err(MemoryError::EmptyFact);
98        }
99        reject_reserved_keys(metadata)?;
100        self.ensure_link_targets_exist(links)?;
101        let fact_id = id::stable_id(fact);
102        let embedding = self.embedder.embed(fact)?;
103        self.store(
104            fact_id,
105            fact,
106            &embedding,
107            metadata,
108            positive_ttl(ttl_seconds),
109        )?;
110        self.relate_links(fact_id, links)?;
111        Ok(fact_id)
112    }
113
114    /// Create each validated outgoing link from `fact_id`. Split out of
115    /// [`Self::remember_with_ttl`] to keep that method within the complexity
116    /// budget.
117    fn relate_links(&self, fact_id: u64, links: &[Link]) -> Result<(), MemoryError> {
118        for link in links {
119            validate_relation(&link.relation)?;
120            self.memory
121                .semantic()
122                .relate(fact_id, link.target, &link.relation, None)?;
123        }
124        Ok(())
125    }
126
127    /// Remember a passage of raw `text` by running it through an [`Extractor`]
128    /// and storing every fact it yields, **auto-wiring the fact↔entity graph**.
129    ///
130    /// This is the commodity on top of [`Self::remember`]'s bring-your-own-links
131    /// core: each extracted fact is stored (tagged with `metadata`), each salient
132    /// topic becomes a deduplicated hub memory, and every fact is linked to its
133    /// topics with a bidirectional `about`/`mentions` edge. Two facts sharing a
134    /// topic therefore become reachable from one another, so [`Self::why`] has a
135    /// real graph to traverse with no manual `relate()`.
136    ///
137    /// Entity hubs are content-addressed, so the same topic seen across many
138    /// calls collapses onto one hub. Returns the ids of the stored facts (entity
139    /// hubs excluded), in extraction order.
140    ///
141    /// # Errors
142    /// Returns [`MemoryError::EmptyFact`] for empty/whitespace `text`,
143    /// [`MemoryError::Extract`] if extraction fails, [`MemoryError::ReservedKey`]
144    /// if `metadata` names a reserved key, or a storage error if persistence fails.
145    pub fn remember_extracted<X: Extractor>(
146        &self,
147        text: &str,
148        extractor: &X,
149        metadata: Option<&Metadata>,
150    ) -> Result<Vec<u64>, MemoryError> {
151        let text = text.trim();
152        if text.is_empty() {
153            return Err(MemoryError::EmptyFact);
154        }
155        let facts = extractor.extract(text)?;
156        let mut fact_ids = Vec::with_capacity(facts.len());
157        let mut entity_ids: HashMap<String, u64> = HashMap::new();
158        let mut edges: HashSet<(u64, u64)> = HashSet::new();
159        let mut seeded: HashSet<u64> = HashSet::new();
160        for fact in &facts {
161            let content = fact.text.trim();
162            if content.is_empty() {
163                continue;
164            }
165            let fact_id = self.remember(content, &[], metadata)?;
166            fact_ids.push(fact_id);
167            self.wire_entities(
168                fact_id,
169                &fact.entities,
170                &mut entity_ids,
171                &mut edges,
172                &mut seeded,
173            )?;
174        }
175        Ok(fact_ids)
176    }
177
178    /// Link `fact_id` to each of its topics with a deduplicated edge in *both*
179    /// directions. `why()` only follows outgoing edges, so the fact→topic edge
180    /// alone leaves hubs as dead ends; the topic→fact edge is what lets a walk
181    /// hop from one fact, through a shared topic, to its sibling facts.
182    fn wire_entities(
183        &self,
184        fact_id: u64,
185        entities: &[String],
186        entity_ids: &mut HashMap<String, u64>,
187        edges: &mut HashSet<(u64, u64)>,
188        seeded: &mut HashSet<u64>,
189    ) -> Result<(), MemoryError> {
190        for entity in entities {
191            // Skip blank or punctuation-only topics: they would persist as junk
192            // hubs (`Entity: -`) yet can never carry a meaningful multi-hop link.
193            if entity.chars().any(char::is_alphanumeric) {
194                self.wire_entity(fact_id, entity, entity_ids, edges, seeded)?;
195            }
196        }
197        Ok(())
198    }
199
200    /// Wire one topic to `fact_id`: resolve its hub, then add the deduplicated
201    /// `about`/`mentions` pair (skipping a hub that is the fact itself).
202    fn wire_entity(
203        &self,
204        fact_id: u64,
205        entity: &str,
206        entity_ids: &mut HashMap<String, u64>,
207        edges: &mut HashSet<(u64, u64)>,
208        seeded: &mut HashSet<u64>,
209    ) -> Result<(), MemoryError> {
210        let entity_id = self.entity_hub(entity, entity_ids)?;
211        if entity_id == fact_id {
212            return Ok(());
213        }
214        // Fold already-persisted edges into the dedup set so re-ingesting the
215        // same text never creates duplicate parallel edges (core `relate` does
216        // not dedup by endpoint+label, only by edge id).
217        self.seed_existing_edges(fact_id, edges, seeded)?;
218        self.seed_existing_edges(entity_id, edges, seeded)?;
219        self.add_edge(fact_id, entity_id, "about", edges)?;
220        self.add_edge(entity_id, fact_id, "mentions", edges)?;
221        Ok(())
222    }
223
224    /// Create the edge `from -> to` labelled `label`, unless `edges` already
225    /// records that endpoint pair (in-call and persisted dedup).
226    fn add_edge(
227        &self,
228        from: u64,
229        to: u64,
230        label: &str,
231        edges: &mut HashSet<(u64, u64)>,
232    ) -> Result<(), MemoryError> {
233        if edges.insert((from, to)) {
234            self.relate(from, to, label)?;
235        }
236        Ok(())
237    }
238
239    /// Load `node`'s already-persisted outgoing edges into `edges` once per call
240    /// (tracked by `seeded`), so the dedup set reflects the stored graph and a
241    /// repeated ingest is idempotent rather than edge-duplicating.
242    fn seed_existing_edges(
243        &self,
244        node: u64,
245        edges: &mut HashSet<(u64, u64)>,
246        seeded: &mut HashSet<u64>,
247    ) -> Result<(), MemoryError> {
248        if !seeded.insert(node) {
249            return Ok(());
250        }
251        for edge in self.memory.semantic().relations(node)? {
252            edges.insert((node, edge.target()));
253        }
254        Ok(())
255    }
256
257    /// Get or create the hub memory for a topic, caching its id per call. The
258    /// hub id is a deterministic function of the (normalized) topic, so the same
259    /// topic resolves to the same hub across calls — never a duplicate.
260    fn entity_hub(
261        &self,
262        entity: &str,
263        entity_ids: &mut HashMap<String, u64>,
264    ) -> Result<u64, MemoryError> {
265        let key = entity.trim().to_lowercase();
266        if let Some(&id) = entity_ids.get(&key) {
267            return Ok(id);
268        }
269        let id = self.remember_hub(&key)?;
270        entity_ids.insert(key, id);
271        Ok(id)
272    }
273
274    /// Idempotently store the hub memory for topic `key`. The id is salted so the
275    /// hub id space is disjoint from natural fact ids (no caller fact can collide
276    /// with or overwrite a hub), while the stored content stays human-readable.
277    /// Marked with the reserved [`HUB_FIELD`] so recall and `why` seeds exclude
278    /// it; goes straight to [`Self::store`] to bypass the caller-facing reserved-
279    /// key rejection in [`Self::remember`].
280    fn remember_hub(&self, key: &str) -> Result<u64, MemoryError> {
281        let id = id::stable_id(&format!("{HUB_ID_SALT}{key}"));
282        let content = format!("Entity: {key}");
283        let embedding = self.embedder.embed(&content)?;
284        let mut meta = Map::new();
285        meta.insert(HUB_FIELD.to_string(), Value::Bool(true));
286        // Topic hubs are graph anchors — they never expire.
287        self.store(id, &content, &embedding, Some(&meta), None)?;
288        Ok(id)
289    }
290
291    /// Fail with [`MemoryError::UnknownMemory`] unless memory `id` exists.
292    fn ensure_exists(&self, id: u64) -> Result<(), MemoryError> {
293        if self.memory.semantic().get(id)?.is_none() {
294            return Err(MemoryError::UnknownMemory(id));
295        }
296        Ok(())
297    }
298
299    /// Fail unless every link target already exists (keeps `remember` atomic).
300    fn ensure_link_targets_exist(&self, links: &[Link]) -> Result<(), MemoryError> {
301        for link in links {
302            self.ensure_exists(link.target)?;
303        }
304        Ok(())
305    }
306
307    /// Store a fact with any combination of metadata and a durable TTL.
308    fn store(
309        &self,
310        id: u64,
311        fact: &str,
312        embedding: &[f32],
313        metadata: Option<&Metadata>,
314        ttl_seconds: Option<u64>,
315    ) -> Result<(), MemoryError> {
316        let semantic = self.memory.semantic();
317        match (metadata, ttl_seconds) {
318            (Some(meta), Some(ttl)) => {
319                // store_with_ttl writes the fact + the durable expiry; update_metadata
320                // then merges the metadata while preserving `_veles_expires_at`.
321                semantic.store_with_ttl(id, fact, embedding, ttl)?;
322                semantic.update_metadata(id, meta)?;
323            }
324            (Some(meta), None) => semantic.store_with_metadata(id, fact, embedding, meta)?,
325            (None, Some(ttl)) => semantic.store_with_ttl(id, fact, embedding, ttl)?,
326            (None, None) => semantic.store(id, fact, embedding)?,
327        }
328        Ok(())
329    }
330
331    /// Recall up to `k` memories semantically similar to `query` (vector facet),
332    /// optionally narrowed to an exact-match metadata `filter` (`ColumnStore`
333    /// facet) — e.g. `{ "project": "veles", "status": "resolved" }`.
334    ///
335    /// A highly selective filter may return fewer than `k` hits even when more
336    /// matches exist — raise `k` for fuller coverage with a narrow filter.
337    ///
338    /// Entity hubs created by [`Self::remember_extracted`] are never returned:
339    /// they are internal graph scaffolding, not facts the caller stored.
340    ///
341    /// # Errors
342    /// Returns [`MemoryError`] if the semantic query fails.
343    pub fn recall(
344        &self,
345        query: &str,
346        k: usize,
347        filter: Option<&Metadata>,
348    ) -> Result<Vec<Recollection>, MemoryError> {
349        let query = query.trim();
350        if query.is_empty() {
351            return Ok(Vec::new());
352        }
353        reject_reserved_keys(filter)?;
354        let embedding = self.embedder.embed(query)?;
355        let hits = self.search(&embedding, k, filter)?;
356        Ok(hits
357            .into_iter()
358            .map(|(id, score, content)| Recollection { id, score, content })
359            .collect())
360    }
361
362    /// Vector search for up to `k` ids, optionally narrowed by a metadata
363    /// `filter`. Shared by [`Self::recall`] and [`Self::why`].
364    fn search(
365        &self,
366        embedding: &[f32],
367        k: usize,
368        filter: Option<&Metadata>,
369    ) -> Result<Vec<(u64, f32, String)>, MemoryError> {
370        match filter {
371            // An include filter already excludes hubs: a hub only carries
372            // `{kind: entity}`, so it can never match a user's metadata filter.
373            Some(meta) => self
374                .memory
375                .semantic()
376                .query_filtered(embedding, k, meta, 0)
377                .map_err(MemoryError::from),
378            // Unfiltered recall must still drop entity hubs explicitly, or a hub
379            // like `Entity: rust` would rank for the topic and evict a real fact.
380            None => self
381                .memory
382                .semantic()
383                .query_excluding(embedding, k, &hub_exclude_filter())
384                .map_err(MemoryError::from),
385        }
386    }
387
388    /// Fused recall: semantic `NEAR` search combined with structured
389    /// `ColumnStore` predicates over metadata columns — ranges and comparisons,
390    /// not just the equality of [`Self::recall`]. One query spanning the vector
391    /// and column facets (e.g. "most similar facts **with `timestamp` in this
392    /// window**"), which a vector-only or equality-only recall cannot express.
393    ///
394    /// Filter *values* are bound as query parameters (never interpolated), so
395    /// they cannot inject; filter *field names* are validated to be plain
396    /// identifiers. Results come back in similarity order.
397    ///
398    /// # Errors
399    /// Returns [`MemoryError::InvalidFilter`] if a filter field is not a plain
400    /// identifier, [`MemoryError::Embed`] if the query cannot be embedded, or a
401    /// storage error if the query fails. An empty query or `k == 0` yields `[]`.
402    pub fn recall_where(
403        &self,
404        query: &str,
405        k: usize,
406        filters: &[ColumnFilter],
407    ) -> Result<Vec<Recollection>, MemoryError> {
408        let query = query.trim();
409        if query.is_empty() || k == 0 {
410            return Ok(Vec::new());
411        }
412        let embedding = self.embedder.embed(query)?;
413        let (sql, params) = self.build_fused_query(&embedding, k, filters)?;
414        // `build_fused_query` has validated every field name; ensure each one is
415        // indexed so the planner uses a bitmap prefilter instead of an O(n)
416        // post-filter scan. Idempotent and incrementally maintained thereafter.
417        for filter in filters {
418            self.memory
419                .semantic()
420                .ensure_index(&filter.field)
421                .map_err(MemoryError::from)?;
422        }
423        let results = self
424            .memory
425            .query_semantic(&sql, &params)
426            .map_err(MemoryError::from)?;
427        Ok(results.iter().map(to_recollection).collect())
428    }
429
430    /// Build the `VelesQL` for [`Self::recall_where`]: a `NEAR` predicate plus
431    /// one bound parameter per filter, against the semantic collection.
432    fn build_fused_query(
433        &self,
434        embedding: &[f32],
435        k: usize,
436        filters: &[ColumnFilter],
437    ) -> Result<(String, HashMap<String, Value>), MemoryError> {
438        use std::fmt::Write as _;
439        let mut params: HashMap<String, Value> = HashMap::new();
440        params.insert("q".to_string(), json!(embedding));
441        let mut predicate = String::from("vector NEAR $q");
442        for (index, filter) in filters.iter().enumerate() {
443            validate_field(&filter.field)?;
444            validate_scalar(&filter.value)?;
445            let key = format!("p{index}");
446            // Field is a validated identifier; the value is bound, never inlined.
447            let _ = write!(
448                predicate,
449                " AND {} {} ${key}",
450                filter.field,
451                filter.op.as_sql()
452            );
453            params.insert(key, filter.value.clone());
454        }
455        let sql = format!(
456            "SELECT * FROM {} WHERE {predicate} LIMIT {k}",
457            self.memory.semantic().collection_name()
458        );
459        Ok((sql, params))
460    }
461
462    /// Create a typed edge `from -> to`. Returns the edge id.
463    ///
464    /// Both endpoints are validated to exist first, so the tool reports an
465    /// unknown id as client input (`UnknownMemory`) rather than a generic
466    /// storage fault — and the graph never gains an edge dangling off a memory
467    /// that was never stored.
468    ///
469    /// # Errors
470    /// Returns [`MemoryError::UnknownMemory`] if either endpoint is missing, or
471    /// a storage error if the edge cannot be created.
472    pub fn relate(&self, from: u64, to: u64, relation: &str) -> Result<u64, MemoryError> {
473        validate_relation(relation)?;
474        self.ensure_exists(from)?;
475        self.ensure_exists(to)?;
476        self.memory
477            .semantic()
478            .relate(from, to, relation, None)
479            .map_err(MemoryError::from)
480    }
481
482    /// Forget (delete) the memory with `fact_id`.
483    ///
484    /// # Errors
485    /// Returns [`MemoryError`] if the deletion fails.
486    pub fn forget(&self, fact_id: u64) -> Result<(), MemoryError> {
487        self.memory
488            .semantic()
489            .delete(fact_id)
490            .map_err(MemoryError::from)
491    }
492
493    /// Explain a `decision`: find the best-matching memory (optionally scoped to
494    /// a metadata `filter`, e.g. the current project), then walk its typed links
495    /// up to `max_hops` away — fusing the vector, `ColumnStore`, and graph facets.
496    ///
497    /// Returns an empty [`Explanation`] when nothing matches the decision.
498    ///
499    /// # Errors
500    /// Returns [`MemoryError`] if recall or graph traversal fails.
501    pub fn why(
502        &self,
503        decision: &str,
504        max_hops: usize,
505        filter: Option<&Metadata>,
506    ) -> Result<Explanation, MemoryError> {
507        let decision = decision.trim();
508        if decision.is_empty() {
509            return Ok(Explanation::default());
510        }
511        reject_reserved_keys(filter)?;
512        let embedding = self.embedder.embed(decision)?;
513        let seeds = self.search(&embedding, 1, filter)?;
514        let Some((seed_id, _score, seed_content)) = seeds.into_iter().next() else {
515            return Ok(Explanation::default());
516        };
517        self.traverse(seed_id, seed_content, max_hops)
518    }
519
520    /// Breadth-first walk over outgoing links from `seed_id`, collecting nodes
521    /// and edges up to `max_hops` away.
522    fn traverse(
523        &self,
524        seed_id: u64,
525        seed_content: String,
526        max_hops: usize,
527    ) -> Result<Explanation, MemoryError> {
528        let mut explanation = Explanation {
529            nodes: vec![MemoryNode {
530                id: seed_id,
531                content: seed_content,
532                hop: 0,
533            }],
534            edges: Vec::new(),
535        };
536        let mut visited: HashSet<u64> = HashSet::from([seed_id]);
537        let mut frontier = vec![seed_id];
538        let mut next: Vec<u64> = Vec::new();
539        for hop in 1..=max_hops {
540            next.clear();
541            for node_id in frontier.drain(..) {
542                self.expand(node_id, hop, &mut explanation, &mut visited, &mut next)?;
543            }
544            if next.is_empty() {
545                break;
546            }
547            std::mem::swap(&mut frontier, &mut next);
548        }
549        Ok(explanation)
550    }
551
552    /// Expand a single node: enqueue unseen targets and record edges. An edge is
553    /// only recorded once its target is a resolved node, so the subgraph never
554    /// contains an edge pointing at a node absent from `nodes` (e.g. a forgotten
555    /// target whose edge outlived it).
556    fn expand(
557        &self,
558        node_id: u64,
559        hop: usize,
560        explanation: &mut Explanation,
561        visited: &mut HashSet<u64>,
562        next: &mut Vec<u64>,
563    ) -> Result<(), MemoryError> {
564        for edge in self.memory.semantic().relations(node_id)? {
565            let target = edge.target();
566            if !visited.contains(&target) {
567                let Some((content, _embedding)) = self.memory.semantic().get(target)? else {
568                    continue; // target no longer exists → drop the dangling edge too
569                };
570                visited.insert(target);
571                explanation.nodes.push(MemoryNode {
572                    id: target,
573                    content,
574                    hop,
575                });
576                next.push(target);
577            }
578            explanation.edges.push(MemoryEdge {
579                from: edge.source(),
580                to: target,
581                relation: edge.label().to_owned(),
582            });
583        }
584        Ok(())
585    }
586}
587
588/// The metadata filter that excludes entity hubs from unfiltered recall and
589/// `why` seeds — the negative counterpart [`MemoryService::search`] applies so
590/// internal `_veles_hub` scaffolding never surfaces as a result.
591fn hub_exclude_filter() -> Metadata {
592    let mut exclude = Map::new();
593    exclude.insert(HUB_FIELD.to_string(), Value::Bool(true));
594    exclude
595}
596
597/// True for metadata keys the memory layer reserves: the engine's `content`
598/// payload, and any `_veles_`-namespaced system key (durable TTL, entity hubs).
599/// Callers may neither set these in `remember` metadata nor filter on them, so
600/// they can't overwrite a system field or collide with internal scaffolding.
601fn is_reserved_key(key: &str) -> bool {
602    key == "content" || key.starts_with("_veles_")
603}
604
605/// Reject caller-supplied metadata/filters that name a reserved key.
606fn reject_reserved_keys(metadata: Option<&Metadata>) -> Result<(), MemoryError> {
607    let Some(meta) = metadata else {
608        return Ok(());
609    };
610    for key in meta.keys() {
611        if is_reserved_key(key) {
612            return Err(MemoryError::ReservedKey(key.clone()));
613        }
614    }
615    Ok(())
616}
617
618/// Normalise a requested TTL: `Some(0)` (and `None`) mean "no expiry" — the fact
619/// is stored permanently. Any positive value is kept as-is.
620fn positive_ttl(ttl_seconds: Option<u64>) -> Option<u64> {
621    ttl_seconds.filter(|&seconds| seconds > 0)
622}
623
624/// Map a core search result to a [`Recollection`], lifting the fact text out of
625/// the reserved `content` payload key.
626fn to_recollection(result: &SearchResult) -> Recollection {
627    let content = result
628        .point
629        .payload
630        .as_ref()
631        .and_then(|payload| payload.get("content"))
632        .and_then(Value::as_str)
633        .unwrap_or_default()
634        .to_owned();
635    Recollection {
636        id: result.point.id,
637        score: result.score,
638        content,
639    }
640}
641
642/// Accept only plain, non-reserved identifier field names, so a filter field
643/// can be safely placed into the query text (its value is always a bound
644/// parameter). Rejects the reserved system columns the docs promise are off
645/// limits: `content` (the fact payload) and any `_veles_`-prefixed engine key
646/// (e.g. durable TTL).
647fn validate_field(field: &str) -> Result<(), MemoryError> {
648    let plain = !field.is_empty() && field.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
649    let reserved = field == "content" || field.starts_with("_veles_");
650    if plain && !reserved {
651        Ok(())
652    } else {
653        Err(MemoryError::InvalidFilter(field.to_owned()))
654    }
655}
656
657/// Reject non-scalar filter values. Only strings, numbers, and booleans can be
658/// compared against a `ColumnStore` column; binding an array/object/null would
659/// fail deep in the query engine and surface as an opaque internal error instead
660/// of a clear client-input error.
661fn validate_scalar(value: &Value) -> Result<(), MemoryError> {
662    match value {
663        Value::String(_) | Value::Number(_) | Value::Bool(_) => Ok(()),
664        _ => Err(MemoryError::InvalidFilter(format!(
665            "value must be a string, number, or boolean, got {value}"
666        ))),
667    }
668}
669
670/// Maximum byte length for a relation label (prevents oversized graph edge labels
671/// from reaching the storage layer).
672const MAX_RELATION_BYTES: usize = 512;
673
674/// Validate a caller-supplied relation label: non-empty, within the size cap, and
675/// containing only printable, non-control ASCII characters (32–126) or non-ASCII
676/// Unicode. This prevents null bytes and control characters from reaching the
677/// storage layer while permitting natural-language labels like `"decided_in"` or
678/// `"is a friend of"`.
679fn validate_relation(label: &str) -> Result<(), MemoryError> {
680    if label.is_empty() {
681        return Err(MemoryError::InvalidRelation(
682            "relation label must not be empty".to_owned(),
683        ));
684    }
685    if label.len() > MAX_RELATION_BYTES {
686        return Err(MemoryError::InvalidRelation(format!(
687            "relation label exceeds maximum of {MAX_RELATION_BYTES} bytes ({} given)",
688            label.len()
689        )));
690    }
691    if label.chars().any(|c| c.is_ascii() && c.is_ascii_control()) {
692        return Err(MemoryError::InvalidRelation(
693            "relation label must not contain ASCII control characters".to_owned(),
694        ));
695    }
696    Ok(())
697}