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/// A typed link from a freshly remembered fact to an existing memory.
45#[derive(Debug, Clone, Deserialize, JsonSchema)]
46#[schemars(transform = crate::schema::strip_int_formats)]
47pub struct Link {
48 /// Id of the memory being linked to. Accepts a JSON number or a decimal
49 /// string — ids can exceed 2^53, where float-lossy JSON clients (JS
50 /// `number`) round a plain integer, so a caller relaying an `id_str`
51 /// value straight from a previous response must be able to resubmit it
52 /// as-is (see issue #1468).
53 #[serde(deserialize_with = "deserialize_id")]
54 pub target: u64,
55 /// Relationship label (e.g. `"decided_in"`, `"references"`, `"depends_on"`).
56 pub relation: String,
57}
58
59/// One semantically recalled memory.
60#[derive(Debug, Clone, Serialize, JsonSchema)]
61#[schemars(transform = crate::schema::strip_int_formats)]
62pub struct Recollection {
63 /// Stable id of the memory.
64 pub id: u64,
65 /// Similarity score (higher is closer).
66 pub score: f32,
67 /// Stored fact content.
68 pub content: String,
69 /// Caller-supplied structured metadata stored with the fact (the `ColumnStore`
70 /// facet), with reserved system keys (`content`, `_veles_*`) excluded —
71 /// EXCEPT [`crate::storage::AUTO_DATE_FIELD`] (`_veles_date`), the
72 /// `YYYYMMDD` date `remember` auto-stamps onto (almost) every fact, which
73 /// stays visible here on purpose so `recall_fused`'s `date_field` can read
74 /// it back with no caller effort. `None` only when the fact carries no
75 /// metadata at all AND no auto-date could be stamped (`wasm32-unknown-unknown`,
76 /// which has no clock). This is what makes dated recall work: store a date
77 /// (e.g. `occurred_at`, or just rely on the automatic `_veles_date`) and it
78 /// round-trips here, so a `recall_where`/`recall_fused` result can be
79 /// ordered into a chronological timeline.
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub metadata: Option<Map<String, Value>>,
82}
83
84/// Comparison operator for a [`ColumnFilter`] in
85/// [`MemoryService::recall_where`](crate::service::MemoryService::recall_where).
86#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)]
87#[serde(rename_all = "lowercase")]
88pub enum ColumnOp {
89 /// `=`
90 Eq,
91 /// `!=`
92 Ne,
93 /// `<`
94 Lt,
95 /// `<=`
96 Le,
97 /// `>`
98 Gt,
99 /// `>=`
100 Ge,
101}
102
103impl ColumnOp {
104 /// The `VelesQL` operator token. Only [`crate::storage::NativeStore`]
105 /// builds `VelesQL` text; a non-`persistence` backend (e.g.
106 /// `velesdb-wasm`'s in-memory one) filters `ColumnFilter`s directly, with
107 /// no query-string step.
108 #[cfg(feature = "persistence")]
109 #[must_use]
110 pub(crate) fn as_sql(self) -> &'static str {
111 match self {
112 Self::Eq => "=",
113 Self::Ne => "!=",
114 Self::Lt => "<",
115 Self::Le => "<=",
116 Self::Gt => ">",
117 Self::Ge => ">=",
118 }
119 }
120}
121
122/// A structured predicate over a memory's metadata column, for the fused
123/// vector+`ColumnStore` recall
124/// [`MemoryService::recall_where`](crate::service::MemoryService::recall_where).
125/// Unlike the exact-match filter on
126/// [`MemoryService::recall`](crate::service::MemoryService::recall), this supports
127/// ranges and comparisons (e.g. `timestamp >= …`), so temporal and numeric facets
128/// become queryable, not just equal-matchable.
129#[derive(Debug, Clone, Deserialize, JsonSchema)]
130pub struct ColumnFilter {
131 /// Metadata field name (alphanumeric/underscore).
132 pub field: String,
133 /// Comparison operator.
134 pub op: ColumnOp,
135 /// Value to compare against (numbers, strings, booleans).
136 pub value: Value,
137}
138
139/// Tuning knobs for
140/// [`MemoryService::recall_fused`](crate::service::MemoryService::recall_fused).
141///
142/// `Default` matches the values validated on the LoCoMo/HotpotQA/TimeQA
143/// benchmarks (`examples/locomo`, `examples/multihop`, `examples/timeqa`):
144/// `graph_boost = 0.15` was the optimum of a sweep (0.30/0.50/0.80 all
145/// degraded ranking quality), and `hops = 2` is the minimum depth at which a
146/// fact wired only through a shared topic (the `remember_extracted` hub
147/// scaffolding: fact → hub is hop 1, hub → sibling fact is hop 2) becomes
148/// reachable at all.
149#[derive(Debug, Clone, Copy)]
150pub struct FusionOptions {
151 /// Hops the graph traversal walks from the top vector seed.
152 pub hops: usize,
153 /// Weight added to a graph-reached fact's normalised vector score.
154 pub graph_boost: f64,
155 /// Depth of the oversampled vector pool fusion re-ranks. `None` uses the
156 /// proven default (`k` scaled up, floored at 64 — see
157 /// `crate::fusion::pool_size`). Widen this to give
158 /// [`MemoryService::recall_fused_reranked`](crate::service::MemoryService::recall_fused_reranked)'s
159 /// reranker more candidates to work with.
160 pub pool: Option<usize>,
161}
162
163impl Default for FusionOptions {
164 fn default() -> Self {
165 Self {
166 hops: 2,
167 graph_boost: 0.15,
168 pool: None,
169 }
170 }
171}
172
173impl FusionOptions {
174 /// Build options from optional, untrusted tuning knobs, applying the
175 /// defaults and clamps every binding must enforce identically: `hops`
176 /// clamped to the graph-traversal ceiling
177 /// ([`clamp_hops`](crate::limits::clamp_hops)), `graph_boost` defaulted when
178 /// absent, and `pool` clamped to the recall ceiling
179 /// ([`clamp_recall_limit`](crate::limits::clamp_recall_limit)) or left at the
180 /// proven default. The MCP `recall_fused` tool (which exposes no `pool`, so
181 /// passes `None`) and the Python `recall_fused` binding both build their
182 /// options here so the transports can't drift on what they accept. A
183 /// non-finite `graph_boost` is not filtered here — that guard lives in
184 /// [`Self::sanitized`], applied by fusion itself so *every* caller is
185 /// covered, not just this constructor.
186 #[must_use]
187 pub fn from_knobs(hops: Option<usize>, graph_boost: Option<f64>, pool: Option<usize>) -> Self {
188 let defaults = Self::default();
189 Self {
190 hops: crate::limits::clamp_hops(hops.unwrap_or(defaults.hops)),
191 graph_boost: graph_boost.unwrap_or(defaults.graph_boost),
192 pool: pool
193 .map(crate::limits::clamp_recall_limit)
194 .or(defaults.pool),
195 }
196 }
197
198 /// A copy with any non-finite `graph_boost` (NaN or ±∞) reset to the
199 /// default. A non-finite boost poisons fusion catastrophically: the score
200 /// term `graph_boost · weight` is `NaN` for *every* candidate — even a
201 /// pool-only one, since `NaN · 0.0 == NaN` — so `crate::fusion::fuse`'s
202 /// `total_cmp` sort sees all scores as equal, degenerates to a no-op, and
203 /// then truncates away the graph-reached facts fusion exists to surface
204 /// (they are appended after the vector pool). The result is silently worse
205 /// than a plain `recall`. Applied inside
206 /// [`recall_fused`](crate::service::MemoryService::recall_fused) so no
207 /// caller — any binding, or a direct Rust user who filled the struct — can
208 /// trip it, however the options were built.
209 #[must_use]
210 pub fn sanitized(mut self) -> Self {
211 if !self.graph_boost.is_finite() {
212 self.graph_boost = Self::default().graph_boost;
213 }
214 self
215 }
216}
217
218/// A node in an [`Explanation`] subgraph.
219#[derive(Debug, Clone, Serialize, JsonSchema)]
220#[schemars(transform = crate::schema::strip_int_formats)]
221pub struct MemoryNode {
222 /// Stable id of the memory.
223 pub id: u64,
224 /// Stored fact content.
225 pub content: String,
226 /// Distance in hops from the seed memory (the seed is hop `0`).
227 pub hop: usize,
228}
229
230/// A typed edge in an [`Explanation`] subgraph.
231#[derive(Debug, Clone, Serialize, JsonSchema)]
232#[schemars(transform = crate::schema::strip_int_formats)]
233pub struct MemoryEdge {
234 /// Source memory id.
235 pub from: u64,
236 /// Target memory id.
237 pub to: u64,
238 /// Relationship label.
239 pub relation: String,
240}
241
242/// The connected answer to a `why` question: the best-matching seed memory plus
243/// everything reachable from it within a hop budget. This connected subgraph is
244/// the differentiator — it surfaces related memories a purely vector recall is
245/// blind to (no textual similarity required).
246#[derive(Debug, Clone, Default, Serialize, JsonSchema)]
247pub struct Explanation {
248 /// Memories in the subgraph, seed first.
249 pub nodes: Vec<MemoryNode>,
250 /// Typed edges connecting the nodes.
251 pub edges: Vec<MemoryEdge>,
252}
253
254#[cfg(test)]
255#[path = "model_tests.rs"]
256mod tests;