Skip to main content

velesdb_memory/context/
memory_bridge.rs

1//! The context compiler's memory bridge: memory-backed fragment selection,
2//! recoverable sources, aggregatable compilation events, and persisted
3//! working contexts — the `MemoryService` half of EPIC-P-070's US-002.
4//!
5//! Everything the bridge persists is a **system fact**: hub-marked
6//! (`_veles_hub`) and carrying **only reserved `_veles_*` metadata keys**, so
7//! it is invisible to unfiltered recall (hub exclusion), can never match a
8//! caller's include filter (callers cannot name reserved keys), and can never
9//! be forged by a caller fact (reserved keys are rejected at `remember`).
10//! Stored ids are salted, and both the source writer and the handle resolver
11//! verify the `_veles_ctx_source` marker, so a caller fact squatting a salt
12//! preimage is neither overwritten nor ever served back as a source. Events
13//! carry metadata and hashes only — never fragment content. Event recording
14//! stamps wall-clock time; the compile pipeline itself stays clock-free and
15//! deterministic.
16
17use std::collections::BTreeMap;
18use std::sync::atomic::{AtomicU64, Ordering};
19use std::time::{SystemTime, UNIX_EPOCH};
20
21use serde_json::{Map, Number, Value};
22
23use super::{positive_ttl, MemoryService, Metadata, HUB_FIELD};
24use crate::context::model::{
25    CompileRequest, CompiledContext, ContextFragment, ContextSavings, MemoryScope, WorkingContext,
26};
27use crate::context::{provenance, ContextCompiler};
28use crate::embedder::Embedder;
29use crate::error::MemoryError;
30use crate::id::stable_id;
31use crate::model::FusionOptions;
32use crate::storage::MemoryStore;
33
34/// Salt for stored source ids — disjoint from natural fact ids, so a caller
35/// later remembering the same text can never overwrite a stored source (or
36/// inherit its system marker).
37const SOURCE_ID_SALT: &str = "veles-ctx-source:";
38/// Salt for compilation-event ids.
39const EVENT_ID_SALT: &str = "veles-ctx-event:";
40/// Salt for working-context ids (deterministic per project+session, so a
41/// save is an idempotent upsert).
42const WORKING_ID_SALT: &str = "veles-ctx-working:";
43
44/// The constant lexical anchor every event's content starts with, so one
45/// vector query can sweep the event family for aggregation.
46const EVENT_ANCHOR: &str = "veles context compilation event";
47
48/// Reserved metadata keys of the bridge's system facts. Reserved (`_veles_`)
49/// on purpose: callers can neither set them (forgery) nor filter on them, so
50/// system facts are invisible to every caller-facing recall path and
51/// [`MemoryService::context_savings`] aggregates only genuine events (it
52/// filters at the storage layer, below the caller-facing validation).
53const CTX_EVENT_FIELD: &str = "_veles_ctx_event";
54const CTX_PROJECT_FIELD: &str = "_veles_ctx_project";
55const CTX_MODEL_FIELD: &str = "_veles_ctx_model";
56const CTX_SOURCE_FIELD: &str = "_veles_ctx_source";
57const CTX_WORKING_FIELD: &str = "_veles_ctx_working";
58const CTX_SESSION_FIELD: &str = "_veles_ctx_session";
59const CTX_TOKENS_IN_FIELD: &str = "_veles_ctx_tokens_in";
60const CTX_TOKENS_OUT_FIELD: &str = "_veles_ctx_tokens_out";
61const CTX_TOKENS_SAVED_FIELD: &str = "_veles_ctx_tokens_saved";
62const CTX_COST_FIELD: &str = "_veles_ctx_cost_micros";
63const CTX_CURRENCY_FIELD: &str = "_veles_ctx_currency";
64const CTX_AT_FIELD: &str = "_veles_ctx_at";
65
66/// Per-process sequence folded into event ids so two compilations landing on
67/// the same clock tick (coarse timers, concurrent calls) never collide.
68static EVENT_SEQ: AtomicU64 = AtomicU64::new(0);
69
70impl<E: Embedder, S: MemoryStore> MemoryService<E, S> {
71    /// [`ContextCompiler::compile`] with this service's memory folded in:
72    /// when the request carries a [`MemoryScope`], relevant memories are
73    /// pulled through the fused vector+graph recall and compiled alongside
74    /// the caller's fragments, each with its `memory_id` and a normalised
75    /// fused-ranking relevance recorded in provenance. Afterwards (policy
76    /// permitting) the distinct originals are stored so every
77    /// `ctx://source/<hash>` handle round-trips, and a metadata-only
78    /// compilation event is recorded for [`Self::context_savings`].
79    ///
80    /// # Errors
81    /// Returns [`MemoryError`] if compilation itself fails (budget, caps),
82    /// or if recall, embedding, or storage fails.
83    pub fn compile_context(
84        &self,
85        compiler: &ContextCompiler,
86        request: &CompileRequest,
87    ) -> Result<CompiledContext, MemoryError> {
88        let memories = self.context_memories(request)?;
89        self.compile_with_memories(compiler, request, memories)
90    }
91
92    /// [`Self::compile_context`] with a caller-supplied [`crate::Reranker`] driving
93    /// memory selection: the reranker receives the FULL fused candidate pool
94    /// (vector + graph, before the `k` cutoff) and its ordering decides
95    /// which `k` memories are compiled in — the seam for a semantic
96    /// cross-encoder or LLM judge a Rust embedder brings along. Not exposed
97    /// on the wire (a reranker is code, not JSON), and never a default: the
98    /// shipped [`crate::context::DeterministicReranker`] is *lexical*, and a
99    /// lexical second stage demotes exactly the zero-vocabulary-overlap
100    /// evidence the graph walk rescues (measured in the BDD suite) — bring
101    /// a semantic one.
102    ///
103    /// # Errors
104    /// Returns [`MemoryError`] if compilation, recall, the reranker itself,
105    /// or storage fails.
106    pub fn compile_context_reranked<R: crate::Reranker>(
107        &self,
108        compiler: &ContextCompiler,
109        request: &CompileRequest,
110        reranker: &R,
111    ) -> Result<CompiledContext, MemoryError> {
112        let memories = self.context_memories_reranked(request, reranker)?;
113        self.compile_with_memories(compiler, request, memories)
114    }
115
116    /// The shared back half of every compile flavour: augment the request
117    /// with the pulled memories, compile, annotate provenance, persist
118    /// sources/events per policy.
119    fn compile_with_memories(
120        &self,
121        compiler: &ContextCompiler,
122        request: &CompileRequest,
123        memories: Vec<PulledMemory>,
124    ) -> Result<CompiledContext, MemoryError> {
125        let mut augmented = request.clone();
126        let mut pulled: BTreeMap<u64, PulledMemory> = BTreeMap::new();
127        for memory in memories {
128            augmented.fragments.push(memory.fragment.clone());
129            pulled.insert(stable_id(&memory.fragment.content), memory);
130        }
131        let mut out = compiler.compile(&augmented)?;
132        annotate_memory_provenance(&mut out, &pulled);
133        let policy = compiler.effective_policy(request);
134        if policy.store_sources {
135            self.store_context_sources(&augmented, &out, policy.source_ttl_seconds)?;
136        }
137        if policy.record_events {
138            self.record_context_event(request, &out, policy.event_ttl_seconds)?;
139        }
140        Ok(out)
141    }
142
143    /// The memories a request's scope pulls in, as compile fragments plus
144    /// their id and normalised fused relevance.
145    fn context_memories(&self, request: &CompileRequest) -> Result<Vec<PulledMemory>, MemoryError> {
146        let Some((scope, k)) = scope_and_k(request) else {
147            return Ok(Vec::new());
148        };
149        let filter = scope_filter(scope);
150        // The scope's fusion knobs (clamped by from_knobs); absent ones fall
151        // back to the crate defaults — raising graph_boost lets a curated
152        // relate-chain out-rank lexically-noisy near-misses (see MemoryScope).
153        let opts = FusionOptions::from_knobs(scope.hops, scope.graph_boost, None);
154        let scored = self.recall_fused_scored(&request.query, k, filter.as_ref(), opts)?;
155        let max_fused = scored
156            .iter()
157            .map(|s| s.fused)
158            .fold(f64::MIN, f64::max)
159            .max(f64::EPSILON);
160        Ok(scored
161            .into_iter()
162            .map(|scored| {
163                let memory_id = scored.recollection.id;
164                // Sanitise a non-finite fused score to 0 before normalising:
165                // `f32::clamp` returns NaN for a NaN input (it does not clamp),
166                // which would put a non-`[0, 1]` value — serialising as JSON
167                // `null` — into an output sold as deterministic and auditable.
168                let fused = if scored.fused.is_finite() {
169                    scored.fused
170                } else {
171                    0.0
172                };
173                #[allow(clippy::cast_possible_truncation)] // normalised into [0, 1]
174                let relevance = (fused / max_fused).clamp(0.0, 1.0) as f32;
175                let fragment = ContextFragment {
176                    id: None,
177                    content: scored.recollection.content,
178                    kind: Some("memory".to_owned()),
179                    priority: None,
180                    metadata: None,
181                };
182                PulledMemory {
183                    fragment,
184                    memory_id,
185                    relevance,
186                    vector_norm: scored.vector_norm,
187                    graph_weight: scored.graph_weight,
188                }
189            })
190            .collect())
191    }
192
193    /// Memory selection driven by a caller-supplied reranker: the fused
194    /// candidate pool (at pool depth, vector + graph) is handed to the
195    /// reranker whole, its ordering is truncated to `k`, and relevance is
196    /// rank-based (the reranker defines the ranking; the fused ventilation
197    /// no longer describes it, so vector/graph read 0 in provenance).
198    fn context_memories_reranked<R: crate::Reranker>(
199        &self,
200        request: &CompileRequest,
201        reranker: &R,
202    ) -> Result<Vec<PulledMemory>, MemoryError> {
203        let Some((scope, k)) = scope_and_k(request) else {
204            return Ok(Vec::new());
205        };
206        let filter = scope_filter(scope);
207        let opts = FusionOptions::from_knobs(scope.hops, scope.graph_boost, None);
208        let ranked =
209            self.recall_fused_reranked(&request.query, k, filter.as_ref(), opts, reranker)?;
210        let count = ranked.len().max(1);
211        Ok(ranked
212            .into_iter()
213            .enumerate()
214            .map(|(rank, recollection)| {
215                #[allow(clippy::cast_precision_loss)] // rank/count are tiny
216                let relevance = 1.0 - (rank as f32 / count as f32);
217                PulledMemory {
218                    fragment: ContextFragment {
219                        id: None,
220                        content: recollection.content,
221                        kind: Some("memory".to_owned()),
222                        priority: None,
223                        metadata: None,
224                    },
225                    memory_id: recollection.id,
226                    relevance,
227                    vector_norm: 0.0,
228                    graph_weight: 0.0,
229                }
230            })
231            .collect())
232    }
233
234    /// Store every distinct fragment's original as a hub-marked system fact
235    /// keyed by its salted content hash, so its handle can be resolved later.
236    fn store_context_sources(
237        &self,
238        augmented: &CompileRequest,
239        out: &CompiledContext,
240        ttl_seconds: Option<u64>,
241    ) -> Result<(), MemoryError> {
242        let by_hash: BTreeMap<u64, &str> = augmented
243            .fragments
244            .iter()
245            .map(|fragment| (stable_id(&fragment.content), fragment.content.as_str()))
246            .collect();
247        let ttl_seconds = positive_ttl(ttl_seconds);
248        for source in &out.sources {
249            let Some(hash) = provenance::parse_handle(&source.handle) else {
250                continue;
251            };
252            let Some(content) = by_hash.get(&hash) else {
253                continue;
254            };
255            let slot = source_id(hash);
256            // An occupied slot is never rewritten: without our marker it is a
257            // caller fact squatting the salt preimage (clobbering it would
258            // destroy user data); with the marker it already holds these
259            // exact bytes — sources are content-addressed — so re-embedding
260            // and re-storing would only burn work (quadratically, on an
261            // agent session whose context accumulates across turns).
262            if self.store.get(slot)?.is_some() {
263                continue;
264            }
265            let embedding = self.embedder.embed(content)?;
266            self.store_fact(
267                slot,
268                content,
269                &embedding,
270                Some(&system_meta(&[(CTX_SOURCE_FIELD, Value::Bool(true))])),
271                ttl_seconds,
272            )?;
273        }
274        Ok(())
275    }
276
277    /// Whether the fact at `slot` carries the stored-source marker.
278    fn slot_is_context_source(&self, slot: u64) -> Result<bool, MemoryError> {
279        let payloads = self.store.get_metadata_batch(&[slot])?;
280        Ok(payloads.first().is_some_and(|payload| {
281            payload
282                .as_ref()
283                .is_some_and(|meta| meta.get(CTX_SOURCE_FIELD) == Some(&Value::Bool(true)))
284        }))
285    }
286
287    /// The original content behind a `ctx://source/<hash>` handle.
288    ///
289    /// # Errors
290    /// Returns [`MemoryError::UnknownHandle`] when the handle is malformed
291    /// or nothing is stored under it (never stored, expired, or forgotten).
292    pub fn retrieve_context_source(&self, handle: &str) -> Result<String, MemoryError> {
293        let unknown = || MemoryError::UnknownHandle(handle.to_owned());
294        let hash = provenance::parse_handle(handle).ok_or_else(unknown)?;
295        let slot = source_id(hash);
296        // Only marker-bearing facts are sources: a caller fact squatting the
297        // salted slot is never served back as compiled provenance.
298        if !self.slot_is_context_source(slot)? {
299            return Err(unknown());
300        }
301        self.store
302            .get(slot)?
303            .map(|(content, _)| content)
304            .ok_or_else(unknown)
305    }
306
307    /// Record one compilation's savings as a metadata-only system fact
308    /// (hashes and token counts — never fragment content). Wall-clock time
309    /// is stamped here, outside the deterministic compile pipeline.
310    fn record_context_event(
311        &self,
312        request: &CompileRequest,
313        out: &CompiledContext,
314        ttl_seconds: Option<u64>,
315    ) -> Result<(), MemoryError> {
316        let occurred_at_nanos = SystemTime::now()
317            .duration_since(UNIX_EPOCH)
318            .map(|elapsed| elapsed.as_nanos())
319            .unwrap_or(0);
320        // The per-process sequence keeps ids unique even when two compiles
321        // land on the same (possibly coarse) clock tick.
322        let seq = EVENT_SEQ.fetch_add(1, Ordering::Relaxed);
323        let content = format!("{EVENT_ANCHOR} {occurred_at_nanos}-{seq}");
324        let id = stable_id(&format!("{EVENT_ID_SALT}{occurred_at_nanos}:{seq}"));
325        let embedding = self.embedder.embed(&content)?;
326        let meta = event_meta(request, out, occurred_at_nanos);
327        self.store_fact(
328            id,
329            &content,
330            &embedding,
331            Some(&meta),
332            positive_ttl(ttl_seconds),
333        )?;
334        Ok(())
335    }
336
337    /// Aggregate the recorded compilation events, optionally per project.
338    /// Sweeps at most [`crate::limits::MAX_RECALL_LIMIT`] events (newest
339    /// need not be first — the sweep is similarity-ordered over a constant
340    /// anchor, i.e. effectively the whole family until the cap);
341    /// [`ContextSavings::truncated`] reports when the cap was hit.
342    ///
343    /// # Errors
344    /// Returns [`MemoryError`] if the underlying filtered recall fails.
345    pub fn context_savings(&self, project: Option<&str>) -> Result<ContextSavings, MemoryError> {
346        // Filter at the STORAGE layer on the reserved event marker: callers
347        // can neither set nor query `_veles_*` keys, so only genuine bridge
348        // events can ever match — a caller fact posing as an event counts
349        // for nothing.
350        let mut filter = Map::new();
351        filter.insert(CTX_EVENT_FIELD.to_owned(), Value::Bool(true));
352        if let Some(project) = project {
353            filter.insert(
354                CTX_PROJECT_FIELD.to_owned(),
355                Value::String(project.to_owned()),
356            );
357        }
358        let embedding = self.embedder.embed(EVENT_ANCHOR)?;
359        let hits =
360            self.store
361                .query_filtered(&embedding, crate::limits::MAX_RECALL_LIMIT, &filter, 0)?;
362        let ids: Vec<u64> = hits.iter().map(|(id, _, _)| *id).collect();
363        let payloads = self.store.get_metadata_batch(&ids)?;
364        Ok(aggregate_events(&payloads))
365    }
366
367    /// Persist `working` under `project` + `session` (idempotent upsert:
368    /// saving again replaces the previous state). Returns the system fact id.
369    ///
370    /// # Errors
371    /// Returns [`MemoryError::WorkingContextCodec`] if serialization fails,
372    /// or a storage/embedding error.
373    pub fn save_working_context(
374        &self,
375        project: &str,
376        session: &str,
377        working: &WorkingContext,
378    ) -> Result<u64, MemoryError> {
379        let content = serde_json::to_string(working)
380            .map_err(|err| MemoryError::WorkingContextCodec(err.to_string()))?;
381        let id = working_id(project, session);
382        let embedding = self
383            .embedder
384            .embed(&format!("working context {project} {session}"))?;
385        let meta = system_meta(&[
386            (CTX_WORKING_FIELD, Value::Bool(true)),
387            (CTX_PROJECT_FIELD, Value::String(project.to_owned())),
388            (CTX_SESSION_FIELD, Value::String(session.to_owned())),
389        ]);
390        self.store_fact(id, &content, &embedding, Some(&meta), None)?;
391        Ok(id)
392    }
393
394    /// The working context previously saved under `project` + `session`,
395    /// `None` when there is none.
396    ///
397    /// # Errors
398    /// Returns [`MemoryError::WorkingContextCodec`] if the stored payload
399    /// does not parse, or a storage error.
400    pub fn load_working_context(
401        &self,
402        project: &str,
403        session: &str,
404    ) -> Result<Option<WorkingContext>, MemoryError> {
405        match self.store.get(working_id(project, session))? {
406            Some((content, _)) => serde_json::from_str(&content)
407                .map(Some)
408                .map_err(|err| MemoryError::WorkingContextCodec(err.to_string())),
409            None => Ok(None),
410        }
411    }
412}
413
414/// How many memories a scope pulls when it does not say (`k` absent).
415const DEFAULT_MEMORY_K: usize = 5;
416
417/// The request's memory scope plus the clamped pull count — `None` when
418/// there is no scope or no room: pulled memories must never push the
419/// request over the fragment cap (the cap is validated after augmentation,
420/// and a rejection there would blame the caller for fragments the bridge
421/// itself added).
422fn scope_and_k(request: &CompileRequest) -> Option<(&MemoryScope, usize)> {
423    let scope = request.memory_scope.as_ref()?;
424    let room = crate::limits::MAX_FRAGMENTS.saturating_sub(request.fragments.len());
425    let k = crate::limits::clamp_recall_limit(scope.k.unwrap_or(DEFAULT_MEMORY_K)).min(room);
426    (k > 0).then_some((scope, k))
427}
428
429/// The recall filter a scope narrows to (its project facet), if any.
430fn scope_filter(scope: &MemoryScope) -> Option<Metadata> {
431    scope.project.as_ref().map(|project| {
432        let mut meta = Map::new();
433        meta.insert("project".to_owned(), Value::String(project.clone()));
434        meta
435    })
436}
437
438/// One memory the scope pulled in, with its full ranking ventilation.
439struct PulledMemory {
440    fragment: ContextFragment,
441    memory_id: u64,
442    /// Fused score normalised over the pulled batch, in `[0, 1]`.
443    relevance: f32,
444    /// Normalised vector term of the fused score.
445    vector_norm: f64,
446    /// Graph promotion weight of the fused score.
447    graph_weight: f64,
448}
449
450/// Stamp pulled memories into the compiled provenance: their decisions and
451/// sources gain the backing `memory_id`, the decision's relevance becomes
452/// the normalised fused-ranking score, and the reason spells out the score
453/// ventilation (vector vs graph) so `why this memory` is answerable from
454/// the decision alone.
455fn annotate_memory_provenance(out: &mut CompiledContext, pulled: &BTreeMap<u64, PulledMemory>) {
456    for decision in &mut out.decisions {
457        if let Some(memory) = pulled.get(&decision.content_hash) {
458            decision.memory_id = Some(memory.memory_id);
459            decision.relevance = memory.relevance;
460            decision.reason = format!(
461                "{} — pulled from memory {} (vector {:.2}, graph {:.2})",
462                decision.reason, memory.memory_id, memory.vector_norm, memory.graph_weight
463            );
464        }
465    }
466    for source in &mut out.sources {
467        if let Some(hash) = provenance::parse_handle(&source.handle) {
468            if let Some(memory) = pulled.get(&hash) {
469                source.memory_id = Some(memory.memory_id);
470            }
471        }
472    }
473}
474
475/// Base metadata of every bridge-stored system fact: hub-marked (invisible
476/// to normal recall) plus the given extra keys.
477fn system_meta(extra: &[(&str, Value)]) -> Metadata {
478    let mut meta = Map::new();
479    meta.insert(HUB_FIELD.to_owned(), Value::Bool(true));
480    for (key, value) in extra {
481        meta.insert((*key).to_owned(), value.clone());
482    }
483    meta
484}
485
486/// The metadata of one compilation event — counts and identifiers only,
487/// every key reserved.
488fn event_meta(request: &CompileRequest, out: &CompiledContext, nanos: u128) -> Metadata {
489    let mut extra: Vec<(&str, Value)> = vec![
490        (CTX_EVENT_FIELD, Value::Bool(true)),
491        (
492            CTX_TOKENS_IN_FIELD,
493            Value::Number(out.insights.tokens_in.into()),
494        ),
495        (
496            CTX_TOKENS_OUT_FIELD,
497            Value::Number(out.insights.tokens_out.into()),
498        ),
499        (
500            CTX_TOKENS_SAVED_FIELD,
501            Value::Number(out.insights.tokens_saved.into()),
502        ),
503        (
504            CTX_AT_FIELD,
505            Value::Number(Number::from(
506                u64::try_from(nanos / 1_000_000_000).unwrap_or(u64::MAX),
507            )),
508        ),
509    ];
510    if let Some(project) = &request.project {
511        extra.push((CTX_PROJECT_FIELD, Value::String(project.clone())));
512    }
513    if let Some(model) = &request.target_model {
514        extra.push((CTX_MODEL_FIELD, Value::String(model.clone())));
515    }
516    if let (Some(micros), Some(currency)) = (
517        out.insights.estimated_cost_saved_micros,
518        out.insights.currency.as_ref(),
519    ) {
520        extra.push((CTX_COST_FIELD, Value::Number(micros.into())));
521        extra.push((CTX_CURRENCY_FIELD, Value::String(currency.clone())));
522    }
523    system_meta(&extra)
524}
525
526/// Fold raw event payloads (reserved keys included) into one
527/// [`ContextSavings`]. Every accumulation saturates — an aggregate must
528/// never panic, whatever the stored numbers.
529fn aggregate_events(payloads: &[Option<Metadata>]) -> ContextSavings {
530    let mut savings = ContextSavings {
531        events: payloads.len() as u64,
532        truncated: payloads.len() >= crate::limits::MAX_RECALL_LIMIT,
533        ..ContextSavings::default()
534    };
535    for payload in payloads {
536        let Some(meta) = payload else { continue };
537        savings.tokens_in = savings
538            .tokens_in
539            .saturating_add(meta_u64(meta, CTX_TOKENS_IN_FIELD));
540        savings.tokens_out = savings
541            .tokens_out
542            .saturating_add(meta_u64(meta, CTX_TOKENS_OUT_FIELD));
543        savings.tokens_saved = savings
544            .tokens_saved
545            .saturating_add(meta_u64(meta, CTX_TOKENS_SAVED_FIELD));
546        if let (Some(Value::String(currency)), micros) =
547            (meta.get(CTX_CURRENCY_FIELD), meta_u64(meta, CTX_COST_FIELD))
548        {
549            if micros > 0 {
550                let entry = savings
551                    .cost_saved_micros_by_currency
552                    .entry(currency.clone())
553                    .or_insert(0);
554                *entry = entry.saturating_add(micros);
555            }
556        }
557    }
558    savings
559}
560
561/// A `u64` metadata field, `0` when absent or non-numeric.
562fn meta_u64(meta: &Metadata, key: &str) -> u64 {
563    meta.get(key).and_then(Value::as_u64).unwrap_or(0)
564}
565
566/// The salted system-fact id of a stored source.
567fn source_id(content_hash: u64) -> u64 {
568    stable_id(&format!("{SOURCE_ID_SALT}{content_hash}"))
569}
570
571/// The salted, deterministic system-fact id of a working context.
572fn working_id(project: &str, session: &str) -> u64 {
573    stable_id(&format!("{WORKING_ID_SALT}{project}\u{1f}{session}"))
574}