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}