Skip to main content

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;