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, ¶ms)
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}