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/// [`deserialize_id`]'s `Option`-shaped sibling for OPTIONAL id fields
45/// (`list_memories.cursor`): absent and `null` mean `None`, anything else
46/// takes the same number-or-decimal-string rule. Feature-independent like
47/// its sibling and for the same reason — `crate::context::wire`'s
48/// equivalent lives behind the `context` feature, and an `mcp`-only build
49/// must still parse the cursor.
50///
51/// Gated on `mcp` — its one consumer is the tool DTO layer — because the
52/// wasm build (`context` alone, `-D warnings`) rejects it as dead code
53/// otherwise. `deserialize_id` above stays ungated only because [`Link`]'s
54/// own deserialization uses it feature-free.
55#[cfg(feature = "mcp")]
56pub(crate) fn deserialize_optional_id<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
57where
58    D: serde::Deserializer<'de>,
59{
60    use serde::de::Error;
61    let expected = "expected a u64 number, a decimal u64 string, or null";
62    match Value::deserialize(deserializer)? {
63        Value::Null => Ok(None),
64        Value::Number(number) => number
65            .as_u64()
66            .map(Some)
67            .ok_or_else(|| Error::custom(format!("invalid id {number} ({expected})"))),
68        Value::String(text) => text
69            .trim()
70            .parse()
71            .map(Some)
72            .map_err(|_| Error::custom(format!("invalid id '{text}' ({expected})"))),
73        other => Err(Error::custom(format!("invalid id {other} ({expected})"))),
74    }
75}
76
77/// A typed link from a freshly remembered fact to an existing memory.
78#[derive(Debug, Clone, Deserialize, JsonSchema)]
79#[schemars(transform = crate::schema::strip_int_formats)]
80pub struct Link {
81    /// Id of the memory being linked to. Accepts a JSON number or a decimal
82    /// string — ids can exceed 2^53, where float-lossy JSON clients (JS
83    /// `number`) round a plain integer, so a caller relaying an `id_str`
84    /// value straight from a previous response must be able to resubmit it
85    /// as-is (see issue #1468).
86    #[serde(deserialize_with = "deserialize_id")]
87    pub target: u64,
88    /// Relationship label (e.g. `"decided_in"`, `"references"`, `"depends_on"`).
89    pub relation: String,
90}
91
92/// One semantically recalled memory.
93#[derive(Debug, Clone, Serialize, JsonSchema)]
94#[schemars(transform = crate::schema::strip_int_formats)]
95pub struct Recollection {
96    /// Stable id of the memory.
97    pub id: u64,
98    /// Similarity score (higher is closer).
99    pub score: f32,
100    /// Stored fact content.
101    pub content: String,
102    /// Caller-supplied structured metadata stored with the fact (the `ColumnStore`
103    /// facet), with reserved system keys (`content`, `_veles_*`) excluded —
104    /// EXCEPT [`crate::storage::AUTO_DATE_FIELD`] (`_veles_date`), the
105    /// `YYYYMMDD` date `remember` auto-stamps onto (almost) every fact, which
106    /// stays visible here on purpose so `recall_fused`'s `date_field` can read
107    /// it back with no caller effort. `None` only when the fact carries no
108    /// metadata at all AND no auto-date could be stamped (`wasm32-unknown-unknown`,
109    /// which has no clock). This is what makes dated recall work: store a date
110    /// (e.g. `occurred_at`, or just rely on the automatic `_veles_date`) and it
111    /// round-trips here, so a `recall_where`/`recall_fused` result can be
112    /// ordered into a chronological timeline.
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub metadata: Option<Map<String, Value>>,
115}
116
117/// Comparison operator for a [`ColumnFilter`] in
118/// [`MemoryService::recall_where`](crate::service::MemoryService::recall_where).
119#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)]
120#[serde(rename_all = "lowercase")]
121pub enum ColumnOp {
122    /// `=`
123    Eq,
124    /// `!=`
125    Ne,
126    /// `<`
127    Lt,
128    /// `<=`
129    Le,
130    /// `>`
131    Gt,
132    /// `>=`
133    Ge,
134}
135
136impl ColumnOp {
137    /// The `VelesQL` operator token. Only [`crate::storage::NativeStore`]
138    /// builds `VelesQL` text; a non-`persistence` backend (e.g.
139    /// `velesdb-wasm`'s in-memory one) filters `ColumnFilter`s directly, with
140    /// no query-string step.
141    #[cfg(feature = "persistence")]
142    #[must_use]
143    pub(crate) fn as_sql(self) -> &'static str {
144        match self {
145            Self::Eq => "=",
146            Self::Ne => "!=",
147            Self::Lt => "<",
148            Self::Le => "<=",
149            Self::Gt => ">",
150            Self::Ge => ">=",
151        }
152    }
153}
154
155/// Whether a STORED value satisfies `op` against a filter's `target`.
156///
157/// The single definition of what a [`ColumnFilter`] means once its field has
158/// been found, so a backend that evaluates payloads directly and one that
159/// translates to `VelesQL` cannot answer differently. `ne` on an absent field
160/// diverged between the two for the API's whole life precisely because each
161/// carried its own copy of this rule (#1759).
162///
163/// **A `null` satisfies nothing**, whatever the operator — a comparison
164/// against null is not true, as in SQL, and `ne` is no exception. Querying for
165/// null-ness is what `IsNull`/`IsNotNull` are for at the `VelesQL` layer. The
166/// caller is responsible for the *absent* case: no value here means no match.
167///
168/// Comparison is numeric when both sides are numbers, lexicographic when both
169/// are strings, and equality-only otherwise — an ordering over two unrelated
170/// JSON shapes has no meaning, so it is false rather than arbitrary.
171#[must_use]
172pub fn column_value_matches(stored: &Value, op: ColumnOp, target: &Value) -> bool {
173    if stored.is_null() {
174        return false;
175    }
176    if let (Some(left), Some(right)) = (stored.as_f64(), target.as_f64()) {
177        return compare_f64(op, left, right);
178    }
179    if let (Some(left), Some(right)) = (stored.as_str(), target.as_str()) {
180        return compare_ordered(op, &left, &right);
181    }
182    match op {
183        ColumnOp::Eq => stored == target,
184        ColumnOp::Ne => stored != target,
185        ColumnOp::Lt | ColumnOp::Le | ColumnOp::Gt | ColumnOp::Ge => false,
186    }
187}
188
189/// The numeric arm of [`column_value_matches`], split out for its one real
190/// difference from the ordered arm: equality is epsilon-based, so `Eq`/`Ne`
191/// cannot be expressed through `PartialOrd` without changing what a
192/// float-rounded stored value matches.
193fn compare_f64(op: ColumnOp, left: f64, right: f64) -> bool {
194    match op {
195        ColumnOp::Eq => (left - right).abs() < f64::EPSILON,
196        ColumnOp::Ne => (left - right).abs() >= f64::EPSILON,
197        ColumnOp::Lt => left < right,
198        ColumnOp::Le => left <= right,
199        ColumnOp::Gt => left > right,
200        ColumnOp::Ge => left >= right,
201    }
202}
203
204/// The ordered arm of [`column_value_matches`]: any type whose native
205/// comparisons ARE the predicate semantics (strings today).
206fn compare_ordered<T: PartialOrd>(op: ColumnOp, left: &T, right: &T) -> bool {
207    match op {
208        ColumnOp::Eq => left == right,
209        ColumnOp::Ne => left != right,
210        ColumnOp::Lt => left < right,
211        ColumnOp::Le => left <= right,
212        ColumnOp::Gt => left > right,
213        ColumnOp::Ge => left >= right,
214    }
215}
216
217/// A structured predicate over a memory's metadata column, for the fused
218/// vector+`ColumnStore` recall
219/// [`MemoryService::recall_where`](crate::service::MemoryService::recall_where).
220/// Unlike the exact-match filter on
221/// [`MemoryService::recall`](crate::service::MemoryService::recall), this supports
222/// ranges and comparisons (e.g. `timestamp >= …`), so temporal and numeric facets
223/// become queryable, not just equal-matchable.
224#[derive(Debug, Clone, Deserialize, JsonSchema)]
225pub struct ColumnFilter {
226    /// Metadata field name (alphanumeric/underscore).
227    pub field: String,
228    /// Comparison operator.
229    pub op: ColumnOp,
230    /// Value to compare against.
231    ///
232    /// The comparison is TYPE-STRICT with no coercion, so the JSON type sent
233    /// here is part of the query: `20260601` (number) never matches a fact
234    /// stored as `"20260601"` (string) — same value, no match, and **no
235    /// error**. A wrong type here is therefore the one mistake this API
236    /// cannot report; it just returns nothing.
237    ///
238    /// Which is why the advertised type is spelled out rather than left as
239    /// the empty schema `serde_json::Value` would produce. `{}` says "send
240    /// anything", on the single field where sending the wrong thing fails
241    /// silently.
242    #[schemars(schema_with = "comparable_json_value")]
243    pub value: Value,
244}
245
246/// The JSON types a `ColumnFilter` can actually compare: number, string,
247/// boolean. Objects and arrays are not orderable and never match; `null` is
248/// not a value to compare against.
249fn comparable_json_value(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
250    schemars::json_schema!({
251        "type": ["number", "string", "boolean"],
252        "description": "Value to compare against. TYPE-STRICT: the JSON type must match \
253                        how the fact was stored (a number never matches a string), and a \
254                        mismatch returns no results rather than an error.",
255    })
256}
257
258/// Tuning knobs for
259/// [`MemoryService::recall_fused`](crate::service::MemoryService::recall_fused).
260///
261/// `Default` matches the values validated on the LoCoMo/HotpotQA/TimeQA
262/// benchmarks (`examples/locomo`, `examples/multihop`, `examples/timeqa`):
263/// `graph_boost = 0.15` was the optimum of a sweep (0.30/0.50/0.80 all
264/// degraded ranking quality), and `hops = 2` is the minimum depth at which a
265/// fact wired only through a shared topic (the `remember_extracted` hub
266/// scaffolding: fact → hub is hop 1, hub → sibling fact is hop 2) becomes
267/// reachable at all.
268#[derive(Debug, Clone, Copy)]
269pub struct FusionOptions {
270    /// Hops the graph traversal walks from the top vector seed.
271    pub hops: usize,
272    /// Weight added to a graph-reached fact's normalised vector score.
273    pub graph_boost: f64,
274    /// Depth of the oversampled vector pool fusion re-ranks. `None` uses the
275    /// proven default (`k` scaled up, floored at 64 — see
276    /// `crate::fusion::pool_size`). Widen this to give
277    /// [`MemoryService::recall_fused_reranked`](crate::service::MemoryService::recall_fused_reranked)'s
278    /// reranker more candidates to work with.
279    pub pool: Option<usize>,
280}
281
282impl Default for FusionOptions {
283    fn default() -> Self {
284        Self {
285            hops: 2,
286            graph_boost: 0.15,
287            pool: None,
288        }
289    }
290}
291
292impl FusionOptions {
293    /// Build options from optional, untrusted tuning knobs, applying the
294    /// defaults and clamps every binding must enforce identically: `hops`
295    /// clamped to the graph-traversal ceiling
296    /// ([`clamp_hops`](crate::limits::clamp_hops)), `graph_boost` defaulted when
297    /// absent, and `pool` clamped to the recall ceiling
298    /// ([`clamp_recall_limit`](crate::limits::clamp_recall_limit)) or left at the
299    /// proven default. The MCP `recall_fused` tool and the Python
300    /// `recall_fused` binding both build their options here — same three
301    /// knobs, same clamps — so the transports can't drift on what they
302    /// accept. A non-finite `graph_boost` is not filtered here — that guard
303    /// lives in [`Self::sanitized`], applied by fusion itself so *every*
304    /// caller is covered, not just this constructor.
305    #[must_use]
306    pub fn from_knobs(hops: Option<usize>, graph_boost: Option<f64>, pool: Option<usize>) -> Self {
307        let defaults = Self::default();
308        Self {
309            hops: crate::limits::clamp_hops(hops.unwrap_or(defaults.hops)),
310            graph_boost: graph_boost.unwrap_or(defaults.graph_boost),
311            pool: pool
312                .map(crate::limits::clamp_recall_limit)
313                .or(defaults.pool),
314        }
315    }
316
317    /// A copy with any non-finite `graph_boost` (NaN or ±∞) reset to the
318    /// default. A non-finite boost poisons fusion catastrophically: the score
319    /// term `graph_boost · weight` is `NaN` for *every* candidate — even a
320    /// pool-only one, since `NaN · 0.0 == NaN` — so `crate::fusion::fuse`'s
321    /// `total_cmp` sort sees all scores as equal, degenerates to a no-op, and
322    /// then truncates away the graph-reached facts fusion exists to surface
323    /// (they are appended after the vector pool). The result is silently worse
324    /// than a plain `recall`. Applied inside
325    /// [`recall_fused`](crate::service::MemoryService::recall_fused) so no
326    /// caller — any binding, or a direct Rust user who filled the struct — can
327    /// trip it, however the options were built.
328    #[must_use]
329    pub fn sanitized(mut self) -> Self {
330        if !self.graph_boost.is_finite() {
331            self.graph_boost = Self::default().graph_boost;
332        }
333        self
334    }
335}
336
337/// A node in an [`Explanation`] subgraph.
338#[derive(Debug, Clone, Serialize, JsonSchema)]
339#[schemars(transform = crate::schema::strip_int_formats)]
340pub struct MemoryNode {
341    /// Stable id of the memory.
342    pub id: u64,
343    /// Stored fact content.
344    pub content: String,
345    /// Distance in hops from the seed memory (the seed is hop `0`).
346    pub hop: usize,
347}
348
349/// A typed edge in an [`Explanation`] subgraph.
350#[derive(Debug, Clone, Serialize, JsonSchema)]
351#[schemars(transform = crate::schema::strip_int_formats)]
352pub struct MemoryEdge {
353    /// Stable id of the edge itself — what
354    /// [`MemoryStore::unrelate`](crate::storage::MemoryStore::unrelate)
355    /// removes by.
356    pub id: u64,
357    /// Source memory id.
358    pub from: u64,
359    /// Target memory id.
360    pub to: u64,
361    /// Relationship label.
362    pub relation: String,
363}
364
365/// At most `cap` edges of one memory point, plus the honest signal that the
366/// point carries more — returned by the bounded accessors of
367/// [`MemoryStore`](crate::storage::MemoryStore) (#1820).
368///
369/// `truncated` is a separate field because `edges.len() == cap` cannot carry
370/// the signal: a node with exactly `cap` edges is indistinguishable from a
371/// truncated one. It compares the node's TOTAL stored degree against the
372/// scan cap, so expired far ends dropped inside the scanned window (never
373/// replaced — the O(cap) bound is the contract) can leave `edges.len() <
374/// cap` with `truncated == true` as a normal outcome.
375#[derive(Debug, Clone)]
376pub struct BoundedMemoryEdges {
377    /// At most `cap` edges, in storage index order.
378    pub edges: Vec<MemoryEdge>,
379    /// Whether the node's total stored degree exceeded the scan cap.
380    pub truncated: bool,
381}
382
383/// Outcome of [`MemoryService::unrelate`](crate::service::MemoryService::unrelate):
384/// idempotent by design, so an absent edge is a `found: false` answer, not an
385/// error — a cleanup must be replayable.
386#[derive(Debug, Clone, Copy, Serialize, JsonSchema)]
387#[schemars(transform = crate::schema::strip_int_formats)]
388pub struct UnrelateOutcome {
389    /// Whether at least one matching edge existed and was removed.
390    pub found: bool,
391    /// How many matching edges were removed.
392    ///
393    /// `relate` is idempotent per (from, relation, to), so anything it wrote
394    /// removes as 0 or 1. Higher counts mean parallel edges predating that
395    /// guarantee, or a direct graph write that bypassed `relate`.
396    pub removed: usize,
397}
398
399/// One typed edge leaving an entity, as reported by
400/// [`MemoryService::entity_profile`](crate::service::MemoryService::entity_profile).
401#[derive(Debug, Clone, Serialize, JsonSchema)]
402#[schemars(transform = crate::schema::strip_int_formats)]
403pub struct EntityRelation {
404    /// The edge label the passage stated (e.g. `"pere de"`, `"soeur de"`).
405    pub predicate: String,
406    /// Stable id of the entity (or fact) on the far end.
407    pub target_id: u64,
408    /// Stored content of the far end — for an entity hub, `Entity: <name>`.
409    pub target: String,
410}
411
412/// What [`crate::MemoryService::remember_extracted`] actually did with a
413/// passage: the stored fact ids, and how many extracted facts it had to drop.
414///
415/// A separate struct rather than a bare `Vec<u64>` because the drop count is
416/// part of the contract: an extracted fact past the embeddable cap is
417/// *skipped* — one unusable element must not cost the others — and a skip the
418/// caller cannot see is indistinguishable from the model simply extracting
419/// fewer facts.
420#[derive(Debug, Clone)]
421pub struct RememberedExtraction {
422    /// Stable ids of the stored facts, in extraction order.
423    pub ids: Vec<u64>,
424    /// Extracted facts dropped for exceeding
425    /// [`crate::limits::MAX_EMBEDDABLE_TEXT_BYTES`].
426    pub skipped_over_cap: usize,
427}
428
429/// Everything the auto-built graph knows about one named entity: the
430/// attributes merged onto its hub and the typed edges touching it.
431#[derive(Debug, Clone, Serialize, JsonSchema)]
432#[schemars(transform = crate::schema::strip_int_formats)]
433pub struct EntityProfile {
434    /// Stable, content-addressed id of the entity hub.
435    pub id: u64,
436    /// Canonical (trimmed, lowercased) entity name.
437    pub name: String,
438    /// Attributes learned about this entity, reserved keys stripped.
439    pub attributes: crate::service::Metadata,
440    /// Typed edges leaving this entity (bipartite scaffolding excluded), at
441    /// most [`crate::limits::MAX_ENTITY_RELATIONS`] of them.
442    pub relations: Vec<EntityRelation>,
443    /// Typed edges pointing AT this entity (bipartite scaffolding excluded),
444    /// at most [`crate::limits::MAX_ENTITY_RELATIONS`] of them.
445    /// Here [`EntityRelation::target_id`]/[`EntityRelation::target`] name the
446    /// far end the edge comes FROM — its source.
447    pub relations_in: Vec<EntityRelation>,
448    /// Whether `relations` is a PARTIAL view: true when the resolution cap
449    /// ([`crate::limits::MAX_ENTITY_RELATIONS`]) or the raw scan window
450    /// ([`crate::limits::MAX_ENTITY_SCAN_EDGES`]) cut the outgoing side. A
451    /// list holding exactly the cap is otherwise indistinguishable from a
452    /// cut one (#1820).
453    pub relations_truncated: bool,
454    /// Whether `relations_in` is a PARTIAL view — the incoming mirror of
455    /// [`Self::relations_truncated`].
456    pub relations_in_truncated: bool,
457}
458
459/// The connected answer to a `why` question: the best-matching seed memory plus
460/// everything reachable from it within a hop budget. This connected subgraph is
461/// the differentiator — it surfaces related memories a purely vector recall is
462/// blind to (no textual similarity required).
463#[derive(Debug, Clone, Default, Serialize, JsonSchema)]
464pub struct Explanation {
465    /// Memories in the subgraph, seed first.
466    pub nodes: Vec<MemoryNode>,
467    /// Typed edges connecting the nodes.
468    pub edges: Vec<MemoryEdge>,
469    /// Whether a width budget cut this walk before it exhausted the
470    /// reachable graph (#1820). A subgraph sitting exactly at a cap
471    /// ([`crate::limits::MAX_WHY_NODES`], [`crate::limits::MAX_WHY_EDGES`],
472    /// [`crate::limits::MAX_WHY_NODE_DEGREE`]) is otherwise
473    /// indistinguishable from a complete one — counts at a ceiling were the
474    /// only signal, and they are ambiguous by construction.
475    ///
476    /// True when a node's degree exceeded the per-node budget, or when the
477    /// node/edge budget stopped the walk while unexpanded work remained.
478    /// The latter is conservative: expanding the rest is exactly what the
479    /// budget forbids, so whether it held anything unseen is unknowable —
480    /// and a rare cautious `true` is harmless where a false "complete" is
481    /// the defect this field exists to close.
482    pub truncated: bool,
483}
484
485#[cfg(test)]
486#[path = "model_tests.rs"]
487mod tests;
488
489/// One audited fact, as `list_memories` returns it: the caller-facing shape
490/// of a [`crate::storage::RawListedFact`] after the service applied its
491/// visibility policy (hub filtering, reserved-key stripping).
492#[derive(Debug, Clone)]
493pub struct ListedMemory {
494    /// Stable id of the memory.
495    pub id: u64,
496    /// Stored fact content.
497    pub content: String,
498    /// Metadata as the policy leaves it: business keys (plus the
499    /// auto-stamped date) by default, the raw payload under
500    /// `include_internal`. `None` when nothing survives.
501    pub metadata: Option<crate::service::Metadata>,
502}