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