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