velesdb_memory/model.rs
1//! Domain data model: the request/response value types of the memory layer.
2//!
3//! These are pure data — the shapes a caller links, recalls, filters on, and
4//! gets back — with no dependency on [`MemoryService`](crate::service::MemoryService)
5//! itself. Keeping them here separates *what the memory layer exchanges* from
6//! *how the service computes it*, and gives every adapter (MCP, bindings) one
7//! canonical place to import the contract from.
8
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use serde_json::{Map, Value};
12
13/// Serde `deserialize_with` for a required `u64` id field: accepts a JSON
14/// number or a decimal string (issue #1468). Sibling of
15/// [`crate::context::wire::deserialize_optional_id`] (that one is
16/// `Option`-shaped and lives behind the `context` feature) — this one is
17/// deliberately feature-independent because [`Link`] is compiled whenever
18/// `model` is, regardless of `context`. Reused by `crate::mcp::dto`'s
19/// `relate`/`forget`/`feedback` id parameters so the accepted-forms rule
20/// lives in exactly one place. Input-side only and purely widening — the
21/// serialized (output) shape of every domain type is unchanged.
22///
23/// # Errors
24/// Returns a deserialize error naming the offending value if it is neither a
25/// `u64` number nor a decimal-`u64` string.
26pub(crate) fn deserialize_id<'de, D>(deserializer: D) -> Result<u64, D::Error>
27where
28 D: serde::Deserializer<'de>,
29{
30 use serde::de::Error;
31 let expected = "expected a u64 number or a decimal u64 string";
32 match Value::deserialize(deserializer)? {
33 Value::Number(number) => number
34 .as_u64()
35 .ok_or_else(|| Error::custom(format!("invalid id {number} ({expected})"))),
36 Value::String(text) => text
37 .trim()
38 .parse()
39 .map_err(|_| Error::custom(format!("invalid id '{text}' ({expected})"))),
40 other => Err(Error::custom(format!("invalid id {other} ({expected})"))),
41 }
42}
43
44/// [`deserialize_id`]'s `Option`-shaped sibling for OPTIONAL id fields
45/// (`list_memories.cursor`): absent and `null` mean `None`, anything else
46/// takes the same number-or-decimal-string rule. Feature-independent like
47/// its sibling and for the same reason — `crate::context::wire`'s
48/// equivalent lives behind the `context` feature, and an `mcp`-only build
49/// must still parse the cursor.
50///
51/// Gated on `mcp` — its one consumer is the tool DTO layer — because the
52/// wasm build (`context` alone, `-D warnings`) rejects it as dead code
53/// otherwise. `deserialize_id` above stays ungated only because [`Link`]'s
54/// own deserialization uses it feature-free.
55#[cfg(feature = "mcp")]
56pub(crate) fn deserialize_optional_id<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
57where
58 D: serde::Deserializer<'de>,
59{
60 use serde::de::Error;
61 let expected = "expected a u64 number, a decimal u64 string, or null";
62 match Value::deserialize(deserializer)? {
63 Value::Null => Ok(None),
64 Value::Number(number) => number
65 .as_u64()
66 .map(Some)
67 .ok_or_else(|| Error::custom(format!("invalid id {number} ({expected})"))),
68 Value::String(text) => text
69 .trim()
70 .parse()
71 .map(Some)
72 .map_err(|_| Error::custom(format!("invalid id '{text}' ({expected})"))),
73 other => Err(Error::custom(format!("invalid id {other} ({expected})"))),
74 }
75}
76
77/// A typed link from a freshly remembered fact to an existing memory.
78#[derive(Debug, Clone, Deserialize, JsonSchema)]
79#[schemars(transform = crate::schema::strip_int_formats)]
80pub struct Link {
81 /// Id of the memory being linked to. Accepts a JSON number or a decimal
82 /// string — ids can exceed 2^53, where float-lossy JSON clients (JS
83 /// `number`) round a plain integer, so a caller relaying an `id_str`
84 /// value straight from a previous response must be able to resubmit it
85 /// as-is (see issue #1468).
86 #[serde(deserialize_with = "deserialize_id")]
87 pub target: u64,
88 /// Relationship label (e.g. `"decided_in"`, `"references"`, `"depends_on"`).
89 pub relation: String,
90}
91
92/// One semantically recalled memory.
93#[derive(Debug, Clone, Serialize, JsonSchema)]
94#[schemars(transform = crate::schema::strip_int_formats)]
95pub struct Recollection {
96 /// Stable id of the memory.
97 pub id: u64,
98 /// Similarity score (higher is closer).
99 pub score: f32,
100 /// Stored fact content.
101 pub content: String,
102 /// Caller-supplied structured metadata stored with the fact (the `ColumnStore`
103 /// facet), with reserved system keys (`content`, `_veles_*`) excluded —
104 /// EXCEPT [`crate::storage::AUTO_DATE_FIELD`] (`_veles_date`), the
105 /// `YYYYMMDD` date `remember` auto-stamps onto (almost) every fact, which
106 /// stays visible here on purpose so `recall_fused`'s `date_field` can read
107 /// it back with no caller effort. `None` only when the fact carries no
108 /// metadata at all AND no auto-date could be stamped (`wasm32-unknown-unknown`,
109 /// which has no clock). This is what makes dated recall work: store a date
110 /// (e.g. `occurred_at`, or just rely on the automatic `_veles_date`) and it
111 /// round-trips here, so a `recall_where`/`recall_fused` result can be
112 /// ordered into a chronological timeline.
113 #[serde(skip_serializing_if = "Option::is_none")]
114 pub metadata: Option<Map<String, Value>>,
115}
116
117/// Comparison operator for a [`ColumnFilter`] in
118/// [`MemoryService::recall_where`](crate::service::MemoryService::recall_where).
119#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)]
120#[serde(rename_all = "lowercase")]
121#[non_exhaustive] // mirrors VelesQL operators, which grow; adapters only construct it; matching externally requires a wildcard arm
122pub enum ColumnOp {
123 /// `=`
124 Eq,
125 /// `!=`
126 Ne,
127 /// `<`
128 Lt,
129 /// `<=`
130 Le,
131 /// `>`
132 Gt,
133 /// `>=`
134 Ge,
135}
136
137impl ColumnOp {
138 /// The `VelesQL` operator token. Only [`crate::storage::NativeStore`]
139 /// builds `VelesQL` text; a non-`persistence` backend (e.g.
140 /// `velesdb-wasm`'s in-memory one) filters `ColumnFilter`s directly, with
141 /// no query-string step.
142 #[cfg(feature = "persistence")]
143 #[must_use]
144 pub(crate) fn as_sql(self) -> &'static str {
145 match self {
146 Self::Eq => "=",
147 Self::Ne => "!=",
148 Self::Lt => "<",
149 Self::Le => "<=",
150 Self::Gt => ">",
151 Self::Ge => ">=",
152 }
153 }
154}
155
156/// Whether a STORED value satisfies `op` against a filter's `target`.
157///
158/// The single definition of what a [`ColumnFilter`] means once its field has
159/// been found, so a backend that evaluates payloads directly and one that
160/// translates to `VelesQL` cannot answer differently. `ne` on an absent field
161/// diverged between the two for the API's whole life precisely because each
162/// carried its own copy of this rule (#1759).
163///
164/// **A `null` satisfies nothing**, whatever the operator — a comparison
165/// against null is not true, as in SQL, and `ne` is no exception. Querying for
166/// null-ness is what `IsNull`/`IsNotNull` are for at the `VelesQL` layer. The
167/// caller is responsible for the *absent* case: no value here means no match.
168///
169/// Comparison is numeric when both sides are numbers, lexicographic when both
170/// are strings, and equality-only otherwise — an ordering over two unrelated
171/// JSON shapes has no meaning, so it is false rather than arbitrary.
172#[must_use]
173pub fn column_value_matches(stored: &Value, op: ColumnOp, target: &Value) -> bool {
174 if stored.is_null() {
175 return false;
176 }
177 if let (Some(left), Some(right)) = (stored.as_f64(), target.as_f64()) {
178 return compare_f64(op, left, right);
179 }
180 if let (Some(left), Some(right)) = (stored.as_str(), target.as_str()) {
181 return compare_ordered(op, &left, &right);
182 }
183 match op {
184 ColumnOp::Eq => stored == target,
185 ColumnOp::Ne => stored != target,
186 ColumnOp::Lt | ColumnOp::Le | ColumnOp::Gt | ColumnOp::Ge => false,
187 }
188}
189
190/// The numeric arm of [`column_value_matches`], split out for its one real
191/// difference from the ordered arm: equality is epsilon-based, so `Eq`/`Ne`
192/// cannot be expressed through `PartialOrd` without changing what a
193/// float-rounded stored value matches.
194fn compare_f64(op: ColumnOp, left: f64, right: f64) -> bool {
195 match op {
196 ColumnOp::Eq => (left - right).abs() < f64::EPSILON,
197 ColumnOp::Ne => (left - right).abs() >= f64::EPSILON,
198 ColumnOp::Lt => left < right,
199 ColumnOp::Le => left <= right,
200 ColumnOp::Gt => left > right,
201 ColumnOp::Ge => left >= right,
202 }
203}
204
205/// The ordered arm of [`column_value_matches`]: any type whose native
206/// comparisons ARE the predicate semantics (strings today).
207fn compare_ordered<T: PartialOrd>(op: ColumnOp, left: &T, right: &T) -> bool {
208 match op {
209 ColumnOp::Eq => left == right,
210 ColumnOp::Ne => left != right,
211 ColumnOp::Lt => left < right,
212 ColumnOp::Le => left <= right,
213 ColumnOp::Gt => left > right,
214 ColumnOp::Ge => left >= right,
215 }
216}
217
218/// A structured predicate over a memory's metadata column, for the fused
219/// vector+`ColumnStore` recall
220/// [`MemoryService::recall_where`](crate::service::MemoryService::recall_where).
221/// Unlike the exact-match filter on
222/// [`MemoryService::recall`](crate::service::MemoryService::recall), this supports
223/// ranges and comparisons (e.g. `timestamp >= …`), so temporal and numeric facets
224/// become queryable, not just equal-matchable.
225#[derive(Debug, Clone, Deserialize, JsonSchema)]
226pub struct ColumnFilter {
227 /// Metadata field name (alphanumeric/underscore).
228 pub field: String,
229 /// Comparison operator.
230 pub op: ColumnOp,
231 /// Value to compare against.
232 ///
233 /// The comparison is TYPE-STRICT with no coercion, so the JSON type sent
234 /// here is part of the query: `20260601` (number) never matches a fact
235 /// stored as `"20260601"` (string) — same value, no match, and **no
236 /// error**. A wrong type here is therefore the one mistake this API
237 /// cannot report; it just returns nothing.
238 ///
239 /// Which is why the advertised type is spelled out rather than left as
240 /// the empty schema `serde_json::Value` would produce. `{}` says "send
241 /// anything", on the single field where sending the wrong thing fails
242 /// silently.
243 #[schemars(schema_with = "comparable_json_value")]
244 pub value: Value,
245}
246
247/// The JSON types a `ColumnFilter` can actually compare: number, string,
248/// boolean. Objects and arrays are not orderable and never match; `null` is
249/// not a value to compare against.
250fn comparable_json_value(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
251 schemars::json_schema!({
252 "type": ["number", "string", "boolean"],
253 "description": "Value to compare against. TYPE-STRICT: the JSON type must match \
254 how the fact was stored (a number never matches a string), and a \
255 mismatch returns no results rather than an error.",
256 })
257}
258
259/// Tuning knobs for
260/// [`MemoryService::recall_fused`](crate::service::MemoryService::recall_fused).
261///
262/// `Default` matches the values validated on the LoCoMo/HotpotQA/TimeQA
263/// benchmarks (`examples/locomo`, `examples/multihop`, `examples/timeqa`):
264/// `graph_boost = 0.15` was the optimum of a sweep (0.30/0.50/0.80 all
265/// degraded ranking quality), and `hops = 2` is the minimum depth at which a
266/// fact wired only through a shared topic (the `remember_extracted` hub
267/// scaffolding: fact → hub is hop 1, hub → sibling fact is hop 2) becomes
268/// reachable at all.
269#[derive(Debug, Clone, Copy)]
270pub struct FusionOptions {
271 /// Hops the graph traversal walks from the top vector seed.
272 pub hops: usize,
273 /// Weight added to a graph-reached fact's normalised vector score.
274 pub graph_boost: f64,
275 /// Depth of the oversampled vector pool fusion re-ranks. `None` uses the
276 /// proven default (`k` scaled up, floored at 64 — see
277 /// `crate::fusion::pool_size`). Widen this to give
278 /// [`MemoryService::recall_fused_reranked`](crate::service::MemoryService::recall_fused_reranked)'s
279 /// reranker more candidates to work with.
280 pub pool: Option<usize>,
281}
282
283impl Default for FusionOptions {
284 fn default() -> Self {
285 Self {
286 hops: 2,
287 graph_boost: 0.15,
288 pool: None,
289 }
290 }
291}
292
293impl FusionOptions {
294 /// Build options from optional, untrusted tuning knobs, applying the
295 /// defaults and clamps every binding must enforce identically: `hops`
296 /// clamped to the graph-traversal ceiling
297 /// ([`clamp_hops`](crate::limits::clamp_hops)), `graph_boost` defaulted when
298 /// absent, and `pool` clamped to the recall ceiling
299 /// ([`clamp_recall_limit`](crate::limits::clamp_recall_limit)) or left at the
300 /// proven default. The MCP `recall_fused` tool and the Python
301 /// `recall_fused` binding both build their options here — same three
302 /// knobs, same clamps — so the transports can't drift on what they
303 /// accept. A non-finite `graph_boost` is not filtered here — that guard
304 /// lives in [`Self::sanitized`], applied by fusion itself so *every*
305 /// caller is covered, not just this constructor.
306 #[must_use]
307 pub fn from_knobs(hops: Option<usize>, graph_boost: Option<f64>, pool: Option<usize>) -> Self {
308 let defaults = Self::default();
309 Self {
310 hops: crate::limits::clamp_hops(hops.unwrap_or(defaults.hops)),
311 graph_boost: graph_boost.unwrap_or(defaults.graph_boost),
312 pool: pool
313 .map(crate::limits::clamp_recall_limit)
314 .or(defaults.pool),
315 }
316 }
317
318 /// A copy with any non-finite `graph_boost` (NaN or ±∞) reset to the
319 /// default. A non-finite boost poisons fusion catastrophically: the score
320 /// term `graph_boost · weight` is `NaN` for *every* candidate — even a
321 /// pool-only one, since `NaN · 0.0 == NaN` — so `crate::fusion::fuse`'s
322 /// `total_cmp` sort sees all scores as equal, degenerates to a no-op, and
323 /// then truncates away the graph-reached facts fusion exists to surface
324 /// (they are appended after the vector pool). The result is silently worse
325 /// than a plain `recall`. Applied inside
326 /// [`recall_fused`](crate::service::MemoryService::recall_fused) so no
327 /// caller — any binding, or a direct Rust user who filled the struct — can
328 /// trip it, however the options were built.
329 #[must_use]
330 pub fn sanitized(mut self) -> Self {
331 if !self.graph_boost.is_finite() {
332 self.graph_boost = Self::default().graph_boost;
333 }
334 self
335 }
336}
337
338/// A node in an [`Explanation`] subgraph.
339#[derive(Debug, Clone, Serialize, JsonSchema)]
340#[schemars(transform = crate::schema::strip_int_formats)]
341pub struct MemoryNode {
342 /// Stable id of the memory.
343 pub id: u64,
344 /// Stored fact content.
345 pub content: String,
346 /// Distance in hops from the seed memory (the seed is hop `0`).
347 pub hop: usize,
348}
349
350/// A typed edge in an [`Explanation`] subgraph.
351#[derive(Debug, Clone, Serialize, JsonSchema)]
352#[schemars(transform = crate::schema::strip_int_formats)]
353pub struct MemoryEdge {
354 /// Stable id of the edge itself — what
355 /// [`GraphStore::unrelate`](crate::storage::GraphStore::unrelate)
356 /// removes by.
357 pub id: u64,
358 /// Source memory id.
359 pub from: u64,
360 /// Target memory id.
361 pub to: u64,
362 /// Relationship label.
363 pub relation: String,
364}
365
366/// At most `cap` edges of one memory point, plus the honest signal that the
367/// point carries more — returned by the bounded accessors of
368/// [`MemoryStore`](crate::storage::MemoryStore) (#1820).
369///
370/// `truncated` is a separate field because `edges.len() == cap` cannot carry
371/// the signal: a node with exactly `cap` edges is indistinguishable from a
372/// truncated one. It compares the node's TOTAL stored degree against the
373/// scan cap, so expired far ends dropped inside the scanned window (never
374/// replaced — the O(cap) bound is the contract) can leave `edges.len() <
375/// cap` with `truncated == true` as a normal outcome.
376#[derive(Debug, Clone)]
377pub struct BoundedMemoryEdges {
378 /// At most `cap` edges, in storage index order.
379 pub edges: Vec<MemoryEdge>,
380 /// Whether the node's total stored degree exceeded the scan cap.
381 pub truncated: bool,
382}
383
384/// Outcome of [`MemoryService::unrelate`](crate::service::MemoryService::unrelate):
385/// idempotent by design, so an absent edge is a `found: false` answer, not an
386/// error — a cleanup must be replayable.
387#[derive(Debug, Clone, Copy, Serialize, JsonSchema)]
388#[schemars(transform = crate::schema::strip_int_formats)]
389pub struct UnrelateOutcome {
390 /// Whether at least one matching edge existed and was removed.
391 pub found: bool,
392 /// How many matching edges were removed.
393 ///
394 /// `relate` is idempotent per (from, relation, to), so anything it wrote
395 /// removes as 0 or 1. Higher counts mean parallel edges predating that
396 /// guarantee, or a direct graph write that bypassed `relate`.
397 pub removed: usize,
398}
399
400/// One typed edge leaving an entity, as reported by
401/// [`MemoryService::entity_profile`](crate::service::MemoryService::entity_profile).
402#[derive(Debug, Clone, Serialize, JsonSchema)]
403#[schemars(transform = crate::schema::strip_int_formats)]
404pub struct EntityRelation {
405 /// The edge label the passage stated (e.g. `"pere de"`, `"soeur de"`).
406 pub predicate: String,
407 /// Stable id of the entity (or fact) on the far end.
408 pub target_id: u64,
409 /// Stored content of the far end — for an entity hub, `Entity: <name>`.
410 pub target: String,
411}
412
413/// What [`crate::MemoryService::remember_extracted`] actually did with a
414/// passage: the stored fact ids, and how many extracted facts it had to drop.
415///
416/// A separate struct rather than a bare `Vec<u64>` because the drop count is
417/// part of the contract: an extracted fact past the embeddable cap is
418/// *skipped* — one unusable element must not cost the others — and a skip the
419/// caller cannot see is indistinguishable from the model simply extracting
420/// fewer facts.
421#[derive(Debug, Clone)]
422pub struct RememberedExtraction {
423 /// Stable ids of the stored facts, in extraction order.
424 pub ids: Vec<u64>,
425 /// Extracted facts dropped for exceeding
426 /// [`crate::limits::MAX_EMBEDDABLE_TEXT_BYTES`].
427 pub skipped_over_cap: usize,
428}
429
430/// Everything the auto-built graph knows about one named entity: the
431/// attributes merged onto its hub and the typed edges touching it.
432#[derive(Debug, Clone, Serialize, JsonSchema)]
433#[schemars(transform = crate::schema::strip_int_formats)]
434pub struct EntityProfile {
435 /// Stable, content-addressed id of the entity hub.
436 pub id: u64,
437 /// Canonical (trimmed, lowercased) entity name.
438 pub name: String,
439 /// Attributes learned about this entity, reserved keys stripped.
440 pub attributes: crate::service::Metadata,
441 /// Typed edges leaving this entity (bipartite scaffolding excluded), at
442 /// most [`crate::limits::MAX_ENTITY_RELATIONS`] of them.
443 pub relations: Vec<EntityRelation>,
444 /// Typed edges pointing AT this entity (bipartite scaffolding excluded),
445 /// at most [`crate::limits::MAX_ENTITY_RELATIONS`] of them.
446 /// Here [`EntityRelation::target_id`]/[`EntityRelation::target`] name the
447 /// far end the edge comes FROM — its source.
448 pub relations_in: Vec<EntityRelation>,
449 /// Whether `relations` is a PARTIAL view: true when the resolution cap
450 /// ([`crate::limits::MAX_ENTITY_RELATIONS`]) or the raw scan window
451 /// ([`crate::limits::MAX_ENTITY_SCAN_EDGES`]) cut the outgoing side. A
452 /// list holding exactly the cap is otherwise indistinguishable from a
453 /// cut one (#1820).
454 pub relations_truncated: bool,
455 /// Whether `relations_in` is a PARTIAL view — the incoming mirror of
456 /// [`Self::relations_truncated`].
457 pub relations_in_truncated: bool,
458}
459
460/// The connected answer to a `why` question: the best-matching seed memory plus
461/// everything reachable from it within a hop budget. This connected subgraph is
462/// the differentiator — it surfaces related memories a purely vector recall is
463/// blind to (no textual similarity required).
464#[derive(Debug, Clone, Default, Serialize, JsonSchema)]
465pub struct Explanation {
466 /// Memories in the subgraph, seed first.
467 pub nodes: Vec<MemoryNode>,
468 /// Typed edges connecting the nodes.
469 pub edges: Vec<MemoryEdge>,
470 /// Whether a width budget cut this walk before it exhausted the
471 /// reachable graph (#1820). A subgraph sitting exactly at a cap
472 /// ([`crate::limits::MAX_WHY_NODES`], [`crate::limits::MAX_WHY_EDGES`],
473 /// [`crate::limits::MAX_WHY_NODE_DEGREE`]) is otherwise
474 /// indistinguishable from a complete one — counts at a ceiling were the
475 /// only signal, and they are ambiguous by construction.
476 ///
477 /// True when a node's degree exceeded the per-node budget, or when the
478 /// node/edge budget stopped the walk while unexpanded work remained.
479 /// The latter is conservative: expanding the rest is exactly what the
480 /// budget forbids, so whether it held anything unseen is unknowable —
481 /// and a rare cautious `true` is harmless where a false "complete" is
482 /// the defect this field exists to close.
483 pub truncated: bool,
484}
485
486#[cfg(test)]
487#[path = "model_tests.rs"]
488mod tests;
489
490/// One audited fact, as `list_memories` returns it: the caller-facing shape
491/// of a [`crate::storage::RawListedFact`] after the service applied its
492/// visibility policy (hub filtering, reserved-key stripping).
493#[derive(Debug, Clone)]
494pub struct ListedMemory {
495 /// Stable id of the memory.
496 pub id: u64,
497 /// Stored fact content.
498 pub content: String,
499 /// Metadata as the policy leaves it: business keys (plus the
500 /// auto-stamped date) by default, the raw payload under
501 /// `include_internal`. `None` when nothing survives.
502 pub metadata: Option<crate::service::Metadata>,
503}