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.
136 ///
137 /// The comparison is TYPE-STRICT with no coercion, so the JSON type sent
138 /// here is part of the query: `20260601` (number) never matches a fact
139 /// stored as `"20260601"` (string) — same value, no match, and **no
140 /// error**. A wrong type here is therefore the one mistake this API
141 /// cannot report; it just returns nothing.
142 ///
143 /// Which is why the advertised type is spelled out rather than left as
144 /// the empty schema `serde_json::Value` would produce. `{}` says "send
145 /// anything", on the single field where sending the wrong thing fails
146 /// silently.
147 #[schemars(schema_with = "comparable_json_value")]
148 pub value: Value,
149}
150
151/// The JSON types a `ColumnFilter` can actually compare: number, string,
152/// boolean. Objects and arrays are not orderable and never match; `null` is
153/// not a value to compare against.
154fn comparable_json_value(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
155 schemars::json_schema!({
156 "type": ["number", "string", "boolean"],
157 "description": "Value to compare against. TYPE-STRICT: the JSON type must match \
158 how the fact was stored (a number never matches a string), and a \
159 mismatch returns no results rather than an error.",
160 })
161}
162
163/// Tuning knobs for
164/// [`MemoryService::recall_fused`](crate::service::MemoryService::recall_fused).
165///
166/// `Default` matches the values validated on the LoCoMo/HotpotQA/TimeQA
167/// benchmarks (`examples/locomo`, `examples/multihop`, `examples/timeqa`):
168/// `graph_boost = 0.15` was the optimum of a sweep (0.30/0.50/0.80 all
169/// degraded ranking quality), and `hops = 2` is the minimum depth at which a
170/// fact wired only through a shared topic (the `remember_extracted` hub
171/// scaffolding: fact → hub is hop 1, hub → sibling fact is hop 2) becomes
172/// reachable at all.
173#[derive(Debug, Clone, Copy)]
174pub struct FusionOptions {
175 /// Hops the graph traversal walks from the top vector seed.
176 pub hops: usize,
177 /// Weight added to a graph-reached fact's normalised vector score.
178 pub graph_boost: f64,
179 /// Depth of the oversampled vector pool fusion re-ranks. `None` uses the
180 /// proven default (`k` scaled up, floored at 64 — see
181 /// `crate::fusion::pool_size`). Widen this to give
182 /// [`MemoryService::recall_fused_reranked`](crate::service::MemoryService::recall_fused_reranked)'s
183 /// reranker more candidates to work with.
184 pub pool: Option<usize>,
185}
186
187impl Default for FusionOptions {
188 fn default() -> Self {
189 Self {
190 hops: 2,
191 graph_boost: 0.15,
192 pool: None,
193 }
194 }
195}
196
197impl FusionOptions {
198 /// Build options from optional, untrusted tuning knobs, applying the
199 /// defaults and clamps every binding must enforce identically: `hops`
200 /// clamped to the graph-traversal ceiling
201 /// ([`clamp_hops`](crate::limits::clamp_hops)), `graph_boost` defaulted when
202 /// absent, and `pool` clamped to the recall ceiling
203 /// ([`clamp_recall_limit`](crate::limits::clamp_recall_limit)) or left at the
204 /// proven default. The MCP `recall_fused` tool and the Python
205 /// `recall_fused` binding both build their options here — same three
206 /// knobs, same clamps — so the transports can't drift on what they
207 /// accept. A non-finite `graph_boost` is not filtered here — that guard
208 /// lives in [`Self::sanitized`], applied by fusion itself so *every*
209 /// caller is covered, not just this constructor.
210 #[must_use]
211 pub fn from_knobs(hops: Option<usize>, graph_boost: Option<f64>, pool: Option<usize>) -> Self {
212 let defaults = Self::default();
213 Self {
214 hops: crate::limits::clamp_hops(hops.unwrap_or(defaults.hops)),
215 graph_boost: graph_boost.unwrap_or(defaults.graph_boost),
216 pool: pool
217 .map(crate::limits::clamp_recall_limit)
218 .or(defaults.pool),
219 }
220 }
221
222 /// A copy with any non-finite `graph_boost` (NaN or ±∞) reset to the
223 /// default. A non-finite boost poisons fusion catastrophically: the score
224 /// term `graph_boost · weight` is `NaN` for *every* candidate — even a
225 /// pool-only one, since `NaN · 0.0 == NaN` — so `crate::fusion::fuse`'s
226 /// `total_cmp` sort sees all scores as equal, degenerates to a no-op, and
227 /// then truncates away the graph-reached facts fusion exists to surface
228 /// (they are appended after the vector pool). The result is silently worse
229 /// than a plain `recall`. Applied inside
230 /// [`recall_fused`](crate::service::MemoryService::recall_fused) so no
231 /// caller — any binding, or a direct Rust user who filled the struct — can
232 /// trip it, however the options were built.
233 #[must_use]
234 pub fn sanitized(mut self) -> Self {
235 if !self.graph_boost.is_finite() {
236 self.graph_boost = Self::default().graph_boost;
237 }
238 self
239 }
240}
241
242/// A node in an [`Explanation`] subgraph.
243#[derive(Debug, Clone, Serialize, JsonSchema)]
244#[schemars(transform = crate::schema::strip_int_formats)]
245pub struct MemoryNode {
246 /// Stable id of the memory.
247 pub id: u64,
248 /// Stored fact content.
249 pub content: String,
250 /// Distance in hops from the seed memory (the seed is hop `0`).
251 pub hop: usize,
252}
253
254/// A typed edge in an [`Explanation`] subgraph.
255#[derive(Debug, Clone, Serialize, JsonSchema)]
256#[schemars(transform = crate::schema::strip_int_formats)]
257pub struct MemoryEdge {
258 /// Stable id of the edge itself — what
259 /// [`MemoryStore::unrelate`](crate::storage::MemoryStore::unrelate)
260 /// removes by.
261 pub id: u64,
262 /// Source memory id.
263 pub from: u64,
264 /// Target memory id.
265 pub to: u64,
266 /// Relationship label.
267 pub relation: String,
268}
269
270/// Outcome of [`MemoryService::unrelate`](crate::service::MemoryService::unrelate):
271/// idempotent by design, so an absent edge is a `found: false` answer, not an
272/// error — a cleanup must be replayable.
273#[derive(Debug, Clone, Copy, Serialize, JsonSchema)]
274#[schemars(transform = crate::schema::strip_int_formats)]
275pub struct UnrelateOutcome {
276 /// Whether at least one matching edge existed and was removed.
277 pub found: bool,
278 /// How many matching edges were removed (parallel duplicates included).
279 pub removed: usize,
280}
281
282/// One typed edge leaving an entity, as reported by
283/// [`MemoryService::entity_profile`](crate::service::MemoryService::entity_profile).
284#[derive(Debug, Clone, Serialize, JsonSchema)]
285#[schemars(transform = crate::schema::strip_int_formats)]
286pub struct EntityRelation {
287 /// The edge label the passage stated (e.g. `"pere de"`, `"soeur de"`).
288 pub predicate: String,
289 /// Stable id of the entity (or fact) on the far end.
290 pub target_id: u64,
291 /// Stored content of the far end — for an entity hub, `Entity: <name>`.
292 pub target: String,
293}
294
295/// What [`crate::MemoryService::remember_extracted`] actually did with a
296/// passage: the stored fact ids, and how many extracted facts it had to drop.
297///
298/// A separate struct rather than a bare `Vec<u64>` because the drop count is
299/// part of the contract: an extracted fact past the embeddable cap is
300/// *skipped* — one unusable element must not cost the others — and a skip the
301/// caller cannot see is indistinguishable from the model simply extracting
302/// fewer facts.
303#[derive(Debug, Clone)]
304pub struct RememberedExtraction {
305 /// Stable ids of the stored facts, in extraction order.
306 pub ids: Vec<u64>,
307 /// Extracted facts dropped for exceeding
308 /// [`crate::limits::MAX_EMBEDDABLE_TEXT_BYTES`].
309 pub skipped_over_cap: usize,
310}
311
312/// Everything the auto-built graph knows about one named entity: the
313/// attributes merged onto its hub and the typed edges touching it.
314#[derive(Debug, Clone, Serialize, JsonSchema)]
315#[schemars(transform = crate::schema::strip_int_formats)]
316pub struct EntityProfile {
317 /// Stable, content-addressed id of the entity hub.
318 pub id: u64,
319 /// Canonical (trimmed, lowercased) entity name.
320 pub name: String,
321 /// Attributes learned about this entity, reserved keys stripped.
322 pub attributes: crate::service::Metadata,
323 /// Typed edges leaving this entity (bipartite scaffolding excluded).
324 pub relations: Vec<EntityRelation>,
325 /// Typed edges pointing AT this entity (bipartite scaffolding excluded).
326 /// Here [`EntityRelation::target_id`]/[`EntityRelation::target`] name the
327 /// far end the edge comes FROM — its source.
328 pub relations_in: Vec<EntityRelation>,
329}
330
331/// The connected answer to a `why` question: the best-matching seed memory plus
332/// everything reachable from it within a hop budget. This connected subgraph is
333/// the differentiator — it surfaces related memories a purely vector recall is
334/// blind to (no textual similarity required).
335#[derive(Debug, Clone, Default, Serialize, JsonSchema)]
336pub struct Explanation {
337 /// Memories in the subgraph, seed first.
338 pub nodes: Vec<MemoryNode>,
339 /// Typed edges connecting the nodes.
340 pub edges: Vec<MemoryEdge>,
341}
342
343#[cfg(test)]
344#[path = "model_tests.rs"]
345mod tests;