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};
19#[cfg(not(target_arch = "wasm32"))]
20use std::time::{SystemTime, UNIX_EPOCH};
21
22/// Wall-clock nanos since the Unix epoch, stamped on savings events only —
23/// never in the compile pipeline. On `wasm32-unknown-unknown`
24/// `SystemTime::now()` aborts (`std` has no clock there), so events carry 0:
25/// the per-process sequence alone uniquifies their ids, and wasm stats are
26/// per-session by design (in-memory store).
27fn now_nanos() -> u128 {
28    #[cfg(target_arch = "wasm32")]
29    {
30        0
31    }
32    #[cfg(not(target_arch = "wasm32"))]
33    {
34        SystemTime::now()
35            .duration_since(UNIX_EPOCH)
36            .map(|elapsed| elapsed.as_nanos())
37            .unwrap_or(0)
38    }
39}
40
41/// Current Unix time in seconds — used only by
42/// [`MemoryService::should_upgrade_ttl`]'s extension-only comparison (the
43/// storage/expiry layer; the `compile` pipeline itself stays clock-free). On
44/// `wasm32-unknown-unknown` this is 0 (no clock, mirrors [`now_nanos`]); the
45/// wasm `MemoryStore` is in-memory only, so a stored durable expiry (a real
46/// epoch second count) never actually exists there for 0 to be compared
47/// against.
48fn now_unix_secs() -> u64 {
49    #[cfg(target_arch = "wasm32")]
50    {
51        0
52    }
53    #[cfg(not(target_arch = "wasm32"))]
54    {
55        SystemTime::now()
56            .duration_since(UNIX_EPOCH)
57            .map(|elapsed| elapsed.as_secs())
58            .unwrap_or(0)
59    }
60}
61
62use serde_json::{Map, Number, Value};
63
64use super::{positive_ttl, MemoryService, Metadata, HUB_FIELD};
65use crate::context::model::{
66    CompileRequest, CompiledContext, ContextFragment, ContextSavings, ContextSource,
67    ImportanceWeights, MediaRef, MemoryScope, WorkingContext,
68};
69use crate::context::{media, provenance, ContextCompiler};
70use crate::embedder::Embedder;
71use crate::error::MemoryError;
72use crate::id::stable_id;
73use crate::model::FusionOptions;
74use crate::storage::MemoryStore;
75
76/// Salt for stored source ids — disjoint from natural fact ids, so a caller
77/// later remembering the same text can never overwrite a stored source (or
78/// inherit its system marker).
79const SOURCE_ID_SALT: &str = "veles-ctx-source:";
80/// Salt for compilation-event ids.
81const EVENT_ID_SALT: &str = "veles-ctx-event:";
82/// Salt for working-context ids (deterministic per project+session, so a
83/// save is an idempotent upsert).
84const WORKING_ID_SALT: &str = "veles-ctx-working:";
85
86/// The constant lexical anchor every event's content starts with, so one
87/// vector query can sweep the event family for aggregation.
88const EVENT_ANCHOR: &str = "veles context compilation event";
89
90/// Reserved metadata keys of the bridge's system facts. Reserved (`_veles_`)
91/// on purpose: callers can neither set them (forgery) nor filter on them, so
92/// system facts are invisible to every caller-facing recall path and
93/// [`MemoryService::context_savings`] aggregates only genuine events (it
94/// filters at the storage layer, below the caller-facing validation).
95const CTX_EVENT_FIELD: &str = "_veles_ctx_event";
96const CTX_PROJECT_FIELD: &str = "_veles_ctx_project";
97const CTX_MODEL_FIELD: &str = "_veles_ctx_model";
98const CTX_SOURCE_FIELD: &str = "_veles_ctx_source";
99/// A stored source's media payload (US-009, PR2): `{"mime", "bytes_b64"}`,
100/// the exact [`MediaRef`] shape, set only when the source fragment carried
101/// one. Reserved like every other `_veles_ctx_*` key — a caller can neither
102/// set nor filter on it.
103const CTX_SOURCE_MEDIA_FIELD: &str = "_veles_ctx_source_media";
104/// The durable-TTL payload key set by [`super::positive_ttl`]-backed writes
105/// (`store_with_ttl`, via `store_fact`). Mirrors `velesdb_core::EXPIRES_AT_KEY`
106/// as a literal rather than an import: that re-export is `persistence`-gated,
107/// and this module (unlike `NativeStore`) must keep compiling under `context`
108/// alone (e.g. `velesdb-wasm`, which never enables `persistence`).
109const EXPIRES_AT_FIELD: &str = "_veles_expires_at";
110const CTX_WORKING_FIELD: &str = "_veles_ctx_working";
111const CTX_SESSION_FIELD: &str = "_veles_ctx_session";
112const CTX_TOKENS_IN_FIELD: &str = "_veles_ctx_tokens_in";
113const CTX_TOKENS_OUT_FIELD: &str = "_veles_ctx_tokens_out";
114const CTX_TOKENS_SAVED_FIELD: &str = "_veles_ctx_tokens_saved";
115const CTX_COST_FIELD: &str = "_veles_ctx_cost_micros";
116const CTX_CURRENCY_FIELD: &str = "_veles_ctx_currency";
117const CTX_AT_FIELD: &str = "_veles_ctx_at";
118
119/// Per-process sequence folded into event ids so two compilations landing on
120/// the same clock tick (coarse timers, concurrent calls) never collide.
121static EVENT_SEQ: AtomicU64 = AtomicU64::new(0);
122
123impl<E: Embedder, S: MemoryStore> MemoryService<E, S> {
124    /// [`ContextCompiler::compile`] with this service's memory folded in:
125    /// when the request carries a [`MemoryScope`], relevant memories are
126    /// pulled through the fused vector+graph recall and compiled alongside
127    /// the caller's fragments, each with its `memory_id` and a normalised
128    /// fused-ranking relevance recorded in provenance. Afterwards (policy
129    /// permitting) the distinct originals are stored so every
130    /// `ctx://source/<hash>` handle round-trips, and a metadata-only
131    /// compilation event is recorded for [`Self::context_savings`].
132    ///
133    /// # Errors
134    /// Returns [`MemoryError`] if compilation itself fails (budget, caps),
135    /// or if recall, embedding, or storage fails.
136    pub fn compile_context(
137        &self,
138        compiler: &ContextCompiler,
139        request: &CompileRequest,
140    ) -> Result<CompiledContext, MemoryError> {
141        let importance = compiler.effective_policy(request).importance.clone();
142        let memories = self.context_memories(request, &importance)?;
143        self.compile_with_memories(compiler, request, memories)
144    }
145
146    /// [`Self::compile_context`] with a caller-supplied [`crate::Reranker`] driving
147    /// memory selection: the reranker receives the FULL fused candidate pool
148    /// (vector + graph, before the `k` cutoff) and its ordering decides
149    /// which `k` memories are compiled in — the seam for a semantic
150    /// cross-encoder or LLM judge a Rust embedder brings along. Not exposed
151    /// on the wire (a reranker is code, not JSON), and never a default: the
152    /// shipped [`crate::context::DeterministicReranker`] is *lexical*, and a
153    /// lexical second stage demotes exactly the zero-vocabulary-overlap
154    /// evidence the graph walk rescues (measured in the BDD suite) — bring
155    /// a semantic one.
156    ///
157    /// # Errors
158    /// Returns [`MemoryError`] if compilation, recall, the reranker itself,
159    /// or storage fails.
160    pub fn compile_context_reranked<R: crate::Reranker>(
161        &self,
162        compiler: &ContextCompiler,
163        request: &CompileRequest,
164        reranker: &R,
165    ) -> Result<CompiledContext, MemoryError> {
166        let importance = compiler.effective_policy(request).importance.clone();
167        let memories = self.context_memories_reranked(request, reranker, &importance)?;
168        self.compile_with_memories(compiler, request, memories)
169    }
170
171    /// The shared back half of every compile flavour: augment the request
172    /// with the pulled memories, compile, annotate provenance, persist
173    /// sources/events per policy.
174    fn compile_with_memories(
175        &self,
176        compiler: &ContextCompiler,
177        request: &CompileRequest,
178        memories: Vec<PulledMemory>,
179    ) -> Result<CompiledContext, MemoryError> {
180        let mut augmented = request.clone();
181        let mut pulled: BTreeMap<u64, PulledMemory> = BTreeMap::new();
182        for memory in memories {
183            augmented.fragments.push(memory.fragment.clone());
184            pulled.insert(stable_id(&memory.fragment.content), memory);
185        }
186        let mut out = compiler.compile(&augmented)?;
187        annotate_memory_provenance(&mut out, &pulled);
188        let policy = compiler.effective_policy(request);
189        if policy.store_sources {
190            self.store_context_sources(&augmented, &out, policy.source_ttl_seconds)?;
191        }
192        if policy.record_events {
193            self.record_context_event(request, &out, policy.event_ttl_seconds)?;
194        }
195        Ok(out)
196    }
197
198    /// The memories a request's scope pulls in, as compile fragments plus
199    /// their id and normalised fused relevance, importance-blended
200    /// ([`Self::blend_importance`]) when the policy's weights are active.
201    fn context_memories(
202        &self,
203        request: &CompileRequest,
204        importance: &ImportanceWeights,
205    ) -> Result<Vec<PulledMemory>, MemoryError> {
206        let Some((scope, k)) = scope_and_k(request) else {
207            return Ok(Vec::new());
208        };
209        let filter = scope_filter(scope);
210        // The scope's fusion knobs (clamped by from_knobs); absent ones fall
211        // back to the crate defaults — raising graph_boost lets a curated
212        // relate-chain out-rank lexically-noisy near-misses (see MemoryScope).
213        let opts = FusionOptions::from_knobs(scope.hops, scope.graph_boost, None);
214        let scored = self.recall_fused_scored(&request.query, k, filter.as_ref(), opts)?;
215        let max_fused = scored
216            .iter()
217            .map(|s| s.fused)
218            .fold(f64::MIN, f64::max)
219            .max(f64::EPSILON);
220        let candidates = scored
221            .into_iter()
222            .map(|scored| {
223                // Sanitise a non-finite fused score to 0 before normalising:
224                // `f32::clamp` returns NaN for a NaN input (it does not clamp),
225                // which would put a non-`[0, 1]` value — serialising as JSON
226                // `null` — into an output sold as deterministic and auditable.
227                let fused = if scored.fused.is_finite() {
228                    scored.fused
229                } else {
230                    0.0
231                };
232                MemoryCandidate {
233                    memory_id: scored.recollection.id,
234                    base: (fused / max_fused).clamp(0.0, 1.0),
235                    vector_norm: scored.vector_norm,
236                    graph_weight: scored.graph_weight,
237                    metadata: scored.recollection.metadata,
238                    content: scored.recollection.content,
239                }
240            })
241            .collect();
242        self.blend_importance(candidates, importance)
243    }
244
245    /// Memory selection driven by a caller-supplied reranker: the fused
246    /// candidate pool (at pool depth, vector + graph) is handed to the
247    /// reranker whole, its ordering is truncated to `k`, and relevance is
248    /// rank-based (the reranker defines the ranking; the fused ventilation
249    /// no longer describes it, so vector/graph read 0 in provenance). The
250    /// importance blend then composes with the seam: it re-ranks INSIDE the
251    /// reranker-selected pool, exactly as it does over the fused pool.
252    fn context_memories_reranked<R: crate::Reranker>(
253        &self,
254        request: &CompileRequest,
255        reranker: &R,
256        importance: &ImportanceWeights,
257    ) -> Result<Vec<PulledMemory>, MemoryError> {
258        let Some((scope, k)) = scope_and_k(request) else {
259            return Ok(Vec::new());
260        };
261        let filter = scope_filter(scope);
262        let opts = FusionOptions::from_knobs(scope.hops, scope.graph_boost, None);
263        let ranked =
264            self.recall_fused_reranked(&request.query, k, filter.as_ref(), opts, reranker)?;
265        let count = ranked.len().max(1);
266        let candidates = ranked
267            .into_iter()
268            .enumerate()
269            .map(|(rank, recollection)| {
270                // Computed in f32 exactly as 0.8.0 did, so inactive weights
271                // reproduce the historical relevance bytes.
272                #[allow(clippy::cast_precision_loss)] // rank/count are tiny
273                let relevance = 1.0 - (rank as f32 / count as f32);
274                MemoryCandidate {
275                    memory_id: recollection.id,
276                    base: f64::from(relevance),
277                    vector_norm: 0.0,
278                    graph_weight: 0.0,
279                    metadata: recollection.metadata,
280                    content: recollection.content,
281                }
282            })
283            .collect();
284        self.blend_importance(candidates, importance)
285    }
286
287    /// Fold usage-driven importance into an already-selected memory pool —
288    /// the one ranking the whole engine stack shares (US-002 of EPIC-P-071):
289    /// per candidate the key becomes `base + w_c·(confidence − 0.5)·2 +
290    /// w_r·recency_norm`, where `base` is the fused (or rank-based)
291    /// similarity in `[0, 1]`. Selection is untouched on purpose: confidence
292    /// is not relevance, so a reinforced-but-off-topic fact can never buy
293    /// its way into the pool here. Inactive weights take the zero-cost path
294    /// and reproduce the 0.8.0 output byte for byte (golden-pinned). The
295    /// stable sort keeps equal keys in selection order, and no clock is ever
296    /// read — recency is min-max normalised within the batch.
297    fn blend_importance(
298        &self,
299        candidates: Vec<MemoryCandidate>,
300        weights: &ImportanceWeights,
301    ) -> Result<Vec<PulledMemory>, MemoryError> {
302        if !importance_active(weights) {
303            return Ok(candidates
304                .into_iter()
305                .map(MemoryCandidate::into_pulled)
306                .collect());
307        }
308        let ids: Vec<u64> = candidates.iter().map(|c| c.memory_id).collect();
309        // Raw payloads (reserved keys included): the learned confidence
310        // lives under `_veles_rl_confidence`, which caller-facing metadata
311        // strips.
312        let raw = self.store.get_metadata_batch(&ids)?;
313        let recencies = recency_norms(&candidates, weights);
314        let mut blended: Vec<(f64, PulledMemory)> = candidates
315            .into_iter()
316            .zip(raw)
317            .zip(recencies)
318            .map(|((candidate, payload), recency)| {
319                let confidence = payload_confidence(payload.as_ref());
320                let score = candidate.base
321                    + weights.confidence * (confidence - NEUTRAL_CONFIDENCE) * 2.0
322                    + weights.recency * recency;
323                let mut pulled = candidate.into_pulled();
324                #[allow(clippy::cast_possible_truncation)] // clamped into [0, 1]
325                {
326                    pulled.relevance = score.clamp(0.0, 1.0) as f32;
327                }
328                pulled.confidence = confidence;
329                pulled.recency = recency;
330                pulled.ventilated = true;
331                (score, pulled)
332            })
333            .collect();
334        // Stable: equal blended keys keep the selection order.
335        blended.sort_by(|a, b| b.0.total_cmp(&a.0));
336        Ok(blended.into_iter().map(|(_, pulled)| pulled).collect())
337    }
338
339    /// Store every distinct fragment's original as a hub-marked system fact
340    /// keyed by its salted handle hash, so its handle can be resolved later.
341    /// A fragment carrying media (US-009, PR2) has its base64 payload
342    /// persisted alongside the caption under the reserved
343    /// [`CTX_SOURCE_MEDIA_FIELD`] key.
344    ///
345    /// **Identity**: the key mirrors what the compiler mints handles from
346    /// (`Analysis::handle_hash` in `context.rs`) — the caption's
347    /// [`stable_id`] for text, the raw decoded bytes' hash
348    /// ([`media::MediaAnalysis::raw_hash`]) for media, the same identity
349    /// PR1's dedup keys on. Keying media on the caption instead was the PR2
350    /// review's proven blocker: every captionless image collided onto one
351    /// slot and one handle, serving arbitrary wrong bytes back. The slot
352    /// stays inside the salted system-fact namespace ([`source_id`] applies
353    /// `SOURCE_ID_SALT` to the hash) — same salt, no new namespace. On a
354    /// same-key collision (byte-identical images with different captions)
355    /// the FIRST occurrence wins, matching the dedup twin the compiler
356    /// keeps — a divergent duplicate caption does not survive, exactly as
357    /// its decision reason already says.
358    ///
359    /// Size: [`crate::limits::MAX_MEDIA_BYTES`] /
360    /// [`crate::limits::MAX_TOTAL_MEDIA_BYTES`] already bounded every
361    /// fragment's `bytes_b64` before `compiler.compile` ever ran (see
362    /// `validate_media`, called from `compile`'s `validate`) — `augmented`
363    /// here is exactly the request that passed that check, so no separate
364    /// size guard is needed on the write path itself
365    /// ([`crate::limits::MAX_FACT_BYTES`] governs the unrelated MCP
366    /// `remember`/`extract` text ceiling, not this one).
367    fn store_context_sources(
368        &self,
369        augmented: &CompileRequest,
370        out: &CompiledContext,
371        ttl_seconds: Option<u64>,
372    ) -> Result<(), MemoryError> {
373        let mut by_hash: BTreeMap<u64, &ContextFragment> = BTreeMap::new();
374        for fragment in &augmented.fragments {
375            // First occurrence wins (see the identity note above): `entry`
376            // + `or_insert`, never a blind overwrite.
377            by_hash
378                .entry(fragment_handle_hash(fragment))
379                .or_insert(fragment);
380        }
381        let ttl_seconds = positive_ttl(ttl_seconds);
382        for source in &out.sources {
383            let Some(hash) = provenance::parse_handle(&source.handle) else {
384                continue;
385            };
386            let Some(fragment) = by_hash.get(&hash) else {
387                continue;
388            };
389            let slot = source_id(hash);
390            // A slot never marked as ours is never rewritten: it is a caller
391            // fact squatting the salt preimage, and clobbering it would
392            // destroy user data. A slot already marked as ours holds these
393            // exact bytes — sources are content-addressed — so content and
394            // embedding never change; only durability can, and only upward
395            // (never-downgrade TTL upgrade, `should_store_source`) so a
396            // handle sold as permanent never silently expires just because
397            // an earlier compile first wrote it under a TTL.
398            if !self.should_store_source(slot, ttl_seconds)? {
399                continue;
400            }
401            // Upgrading to permanent needs the old point gone, not merely
402            // overwritten: velesdb-core's store path preserves every
403            // `_veles_*` key from a prior version of a re-stored id unless
404            // the new write explicitly sets it (semantic_memory.rs's
405            // `store_internal` carry-forward, so plain `remember` doesn't
406            // silently wipe learned state), and a permanent write has no
407            // expiry to explicitly set (`attach_expiry` is a no-op without
408            // one) — so without this delete, `_veles_expires_at` would
409            // survive the "upgrade" untouched. A TTL-to-TTL extension needs
410            // no delete: its new expiry always overwrites the old one.
411            if ttl_seconds.is_none() && self.store.get(slot)?.is_some() {
412                self.store.delete(slot)?;
413            }
414            let content = fragment.content.as_str();
415            let mut extra: Vec<(&str, Value)> = vec![(CTX_SOURCE_FIELD, Value::Bool(true))];
416            let embedding = if let Some(media_ref) = &fragment.media {
417                extra.push((
418                    CTX_SOURCE_MEDIA_FIELD,
419                    serde_json::to_value(media_ref).unwrap_or(Value::Null),
420                ));
421                // Deterministic, derived from the DECODED bytes — never the
422                // text embedder over `content` (often blank) or over the
423                // base64 payload itself (opaque, not language). Correct
424                // because `retrieve_context_source` resolves a media source
425                // EXCLUSIVELY by its content-addressed hash/slot, never by
426                // vector search — this vector only needs to be well-formed
427                // and non-degenerate for the underlying index, never
428                // semantically meaningful. For a media fragment `hash` IS
429                // the raw-bytes hash (see `fragment_handle_hash`), so no
430                // re-decode is needed here.
431                self.media_placeholder_embedding(hash)
432            } else {
433                self.embedder.embed(content)?
434            };
435            self.store_fact(
436                slot,
437                content,
438                &embedding,
439                Some(&system_meta(&extra)),
440                ttl_seconds,
441            )?;
442        }
443        Ok(())
444    }
445
446    /// Whether [`Self::store_context_sources`] should (re-)write `slot` for
447    /// this compile's requested (already [`positive_ttl`]-normalized —
448    /// `None` means permanent) TTL.
449    ///
450    /// - Not marked as ours (absent, or a caller fact squatting the salt
451    ///   preimage): store only if the slot is genuinely empty.
452    /// - Marked as ours: never re-embed or change content (content-addressed);
453    ///   only [`Self::should_upgrade_ttl`] decides whether durability changes.
454    fn should_store_source(
455        &self,
456        slot: u64,
457        requested_ttl: Option<u64>,
458    ) -> Result<bool, MemoryError> {
459        match self.context_source_metadata(slot)? {
460            Some(existing) => Ok(Self::should_upgrade_ttl(&existing, requested_ttl)),
461            None => Ok(self.store.get(slot)?.is_none()),
462        }
463    }
464
465    /// Never-downgrade TTL upgrade rule for an already-stored source: permanent
466    /// once requested stays permanent, and a TTL only ever extends, never
467    /// shortens. The clock read here is fine — this is the storage/expiry
468    /// layer, not the clock-free `compile` pipeline.
469    fn should_upgrade_ttl(existing: &Metadata, requested_ttl: Option<u64>) -> bool {
470        let existing_expiry = existing.get(EXPIRES_AT_FIELD).and_then(Value::as_u64);
471        match (requested_ttl, existing_expiry) {
472            // Permanent requested, slot still carries a TTL: upgrade.
473            (None, Some(_)) => true,
474            // Already permanent, or a TTL requested against a permanent slot:
475            // never downgrade.
476            (None | Some(_), None) => false,
477            // Both carry a TTL: extend only if the new one outlives what
478            // remains — never shorten.
479            (Some(ttl), Some(existing_exp)) => now_unix_secs().saturating_add(ttl) > existing_exp,
480        }
481    }
482
483    /// A deterministic, non-degenerate embedding for a media source (US-009,
484    /// PR2) — see [`Self::store_context_sources`] for why it is bytes-hash
485    /// derived rather than text-embedded.
486    fn media_placeholder_embedding(&self, raw_hash: u64) -> Vec<f32> {
487        let dim = self.embedder.dimension();
488        let mut vector = vec![0.0_f32; dim];
489        let Ok(dim_u64) = u64::try_from(dim) else {
490            return vector;
491        };
492        if dim_u64 == 0 {
493            return vector;
494        }
495        let bucket = usize::try_from(raw_hash % dim_u64).unwrap_or(0);
496        vector[bucket] = 1.0;
497        velesdb_core::simd_native::normalize_inplace_native(&mut vector);
498        vector
499    }
500
501    /// The fact at `slot`'s metadata, when it carries the stored-source
502    /// marker (`None` otherwise — absent, or a caller fact squatting the
503    /// slot).
504    fn context_source_metadata(&self, slot: u64) -> Result<Option<Metadata>, MemoryError> {
505        let payloads = self.store.get_metadata_batch(&[slot])?;
506        Ok(payloads
507            .into_iter()
508            .next()
509            .flatten()
510            .filter(|meta| meta.get(CTX_SOURCE_FIELD) == Some(&Value::Bool(true))))
511    }
512
513    /// The original content — and media, when the fragment carried one —
514    /// behind a `ctx://source/<hash>` handle.
515    ///
516    /// # Errors
517    /// Returns [`MemoryError::UnknownHandle`] when the handle is malformed
518    /// or nothing is stored under it (never stored, expired, or forgotten).
519    pub fn retrieve_context_source(&self, handle: &str) -> Result<ContextSource, MemoryError> {
520        let unknown = || MemoryError::UnknownHandle(handle.to_owned());
521        let hash = provenance::parse_handle(handle).ok_or_else(unknown)?;
522        let slot = source_id(hash);
523        // Only marker-bearing facts are sources: a caller fact squatting the
524        // salted slot is never served back as compiled provenance.
525        let meta = self.context_source_metadata(slot)?.ok_or_else(unknown)?;
526        let content = self
527            .store
528            .get(slot)?
529            .map(|(content, _embedding)| content)
530            .ok_or_else(unknown)?;
531        Ok(ContextSource {
532            content,
533            media: source_media(&meta),
534        })
535    }
536
537    /// Record one compilation's savings as a metadata-only system fact
538    /// (hashes and token counts — never fragment content). Wall-clock time
539    /// is stamped here, outside the deterministic compile pipeline.
540    fn record_context_event(
541        &self,
542        request: &CompileRequest,
543        out: &CompiledContext,
544        ttl_seconds: Option<u64>,
545    ) -> Result<(), MemoryError> {
546        let occurred_at_nanos = now_nanos();
547        // The per-process sequence keeps ids unique even when two compiles
548        // land on the same (possibly coarse) clock tick.
549        let seq = EVENT_SEQ.fetch_add(1, Ordering::Relaxed);
550        let content = format!("{EVENT_ANCHOR} {occurred_at_nanos}-{seq}");
551        let id = stable_id(&format!("{EVENT_ID_SALT}{occurred_at_nanos}:{seq}"));
552        let embedding = self.embedder.embed(&content)?;
553        let meta = event_meta(request, out, occurred_at_nanos);
554        self.store_fact(
555            id,
556            &content,
557            &embedding,
558            Some(&meta),
559            positive_ttl(ttl_seconds),
560        )?;
561        Ok(())
562    }
563
564    /// Aggregate the recorded compilation events, optionally per project.
565    /// Sweeps at most [`crate::limits::MAX_RECALL_LIMIT`] events (newest
566    /// need not be first — the sweep is similarity-ordered over a constant
567    /// anchor, i.e. effectively the whole family until the cap);
568    /// [`ContextSavings::truncated`] reports when the cap was hit.
569    ///
570    /// # Errors
571    /// Returns [`MemoryError`] if the underlying filtered recall fails.
572    pub fn context_savings(&self, project: Option<&str>) -> Result<ContextSavings, MemoryError> {
573        // Filter at the STORAGE layer on the reserved event marker: callers
574        // can neither set nor query `_veles_*` keys, so only genuine bridge
575        // events can ever match — a caller fact posing as an event counts
576        // for nothing.
577        let mut filter = Map::new();
578        filter.insert(CTX_EVENT_FIELD.to_owned(), Value::Bool(true));
579        if let Some(project) = project {
580            filter.insert(
581                CTX_PROJECT_FIELD.to_owned(),
582                Value::String(project.to_owned()),
583            );
584        }
585        let embedding = self.embedder.embed(EVENT_ANCHOR)?;
586        let hits =
587            self.store
588                .query_filtered(&embedding, crate::limits::MAX_RECALL_LIMIT, &filter, 0)?;
589        let ids: Vec<u64> = hits.iter().map(|(id, _, _)| *id).collect();
590        let payloads = self.store.get_metadata_batch(&ids)?;
591        Ok(aggregate_events(&payloads))
592    }
593
594    /// Persist `working` under `project` + `session` (idempotent upsert:
595    /// saving again replaces the previous state). Returns the system fact id.
596    ///
597    /// Serialized size is capped at [`crate::limits::MAX_FACT_BYTES`] (1
598    /// MiB) — the same ceiling every other stored fact honors — checked
599    /// BEFORE anything is written, so an oversized working context is never
600    /// partially stored.
601    ///
602    /// # Errors
603    /// Returns [`MemoryError::WorkingContextCodec`] if serialization fails,
604    /// [`MemoryError::ContextOverLimit`] if the serialized `working` exceeds
605    /// [`crate::limits::MAX_FACT_BYTES`], or a storage/embedding error.
606    pub fn save_working_context(
607        &self,
608        project: &str,
609        session: &str,
610        working: &WorkingContext,
611    ) -> Result<u64, MemoryError> {
612        let content = serde_json::to_string(working)
613            .map_err(|err| MemoryError::WorkingContextCodec(err.to_string()))?;
614        if content.len() > crate::limits::MAX_FACT_BYTES {
615            return Err(MemoryError::ContextOverLimit(format!(
616                "working context of {} bytes exceeds the cap of {} bytes",
617                content.len(),
618                crate::limits::MAX_FACT_BYTES
619            )));
620        }
621        let id = working_id(project, session);
622        let embedding = self
623            .embedder
624            .embed(&format!("working context {project} {session}"))?;
625        let meta = system_meta(&[
626            (CTX_WORKING_FIELD, Value::Bool(true)),
627            (CTX_PROJECT_FIELD, Value::String(project.to_owned())),
628            (CTX_SESSION_FIELD, Value::String(session.to_owned())),
629        ]);
630        self.store_fact(id, &content, &embedding, Some(&meta), None)?;
631        Ok(id)
632    }
633
634    /// The working context previously saved under `project` + `session`,
635    /// `None` when there is none.
636    ///
637    /// Symmetric to [`Self::context_source_metadata`]'s squatter guard: the
638    /// slot is only ever served back when its metadata carries the reserved
639    /// [`CTX_WORKING_FIELD`] marker (set exclusively by
640    /// [`Self::save_working_context`]). A slot occupied by an unmarked caller
641    /// fact — one that happened to land on this salted id, or a forged
642    /// probe — is indistinguishable from "nothing saved" on purpose: `None`,
643    /// never the forged content, and never an error (the caller cannot tell
644    /// a squatted slot from a genuinely empty one, which is the point — it
645    /// must never learn that *something* occupies this id).
646    ///
647    /// # Errors
648    /// Returns [`MemoryError::WorkingContextCodec`] if the stored payload
649    /// does not parse, or a storage error.
650    pub fn load_working_context(
651        &self,
652        project: &str,
653        session: &str,
654    ) -> Result<Option<WorkingContext>, MemoryError> {
655        let slot = working_id(project, session);
656        let payloads = self.store.get_metadata_batch(&[slot])?;
657        let marked = payloads
658            .into_iter()
659            .next()
660            .flatten()
661            .is_some_and(|meta| meta.get(CTX_WORKING_FIELD) == Some(&Value::Bool(true)));
662        if !marked {
663            return Ok(None);
664        }
665        match self.store.get(slot)? {
666            Some((content, _)) => serde_json::from_str(&content)
667                .map(Some)
668                .map_err(|err| MemoryError::WorkingContextCodec(err.to_string())),
669            None => Ok(None),
670        }
671    }
672}
673
674/// How many memories a scope pulls when it does not say (`k` absent).
675const DEFAULT_MEMORY_K: usize = 5;
676
677/// The request's memory scope plus the clamped pull count — `None` when
678/// there is no scope or no room: pulled memories must never push the
679/// request over the fragment cap (the cap is validated after augmentation,
680/// and a rejection there would blame the caller for fragments the bridge
681/// itself added).
682fn scope_and_k(request: &CompileRequest) -> Option<(&MemoryScope, usize)> {
683    let scope = request.memory_scope.as_ref()?;
684    let room = crate::limits::MAX_FRAGMENTS.saturating_sub(request.fragments.len());
685    let k = crate::limits::clamp_recall_limit(scope.k.unwrap_or(DEFAULT_MEMORY_K)).min(room);
686    (k > 0).then_some((scope, k))
687}
688
689/// The recall filter a scope narrows to (its project facet), if any.
690fn scope_filter(scope: &MemoryScope) -> Option<Metadata> {
691    scope.project.as_ref().map(|project| {
692        let mut meta = Map::new();
693        meta.insert("project".to_owned(), Value::String(project.clone()));
694        meta
695    })
696}
697
698/// One memory the scope pulled in, with its full ranking ventilation.
699struct PulledMemory {
700    fragment: ContextFragment,
701    memory_id: u64,
702    /// Fused score normalised over the pulled batch, in `[0, 1]` — the
703    /// importance-blended key (clamped) when the blend is active.
704    relevance: f32,
705    /// Normalised vector term of the fused score.
706    vector_norm: f64,
707    /// Graph promotion weight of the fused score.
708    graph_weight: f64,
709    /// Learned RL confidence the blend used (neutral `0.5` when the memory
710    /// never received feedback).
711    confidence: f64,
712    /// Batch-relative recency contribution in `[0, 1]` (`0` when the term
713    /// is inactive, the key is absent, or the batch is degenerate).
714    recency: f64,
715    /// Whether the importance blend ran — drives the extended four-signal
716    /// reason ventilation; `false` keeps the exact 0.8.0 reason bytes.
717    ventilated: bool,
718}
719
720/// A selected memory before the importance blend: its similarity base, its
721/// fused ventilation, and the caller-visible metadata the recency term reads.
722struct MemoryCandidate {
723    memory_id: u64,
724    /// Fused-normalised (or rank-based) similarity in `[0, 1]`.
725    base: f64,
726    vector_norm: f64,
727    graph_weight: f64,
728    metadata: Option<Metadata>,
729    content: String,
730}
731
732impl MemoryCandidate {
733    /// The unblended [`PulledMemory`] — bytes identical to the 0.8.0 pull.
734    fn into_pulled(self) -> PulledMemory {
735        #[allow(clippy::cast_possible_truncation)] // base is clamped into [0, 1]
736        let relevance = self.base as f32;
737        PulledMemory {
738            fragment: ContextFragment {
739                id: None,
740                content: self.content,
741                kind: Some("memory".to_owned()),
742                priority: None,
743                metadata: None,
744                media: None,
745            },
746            memory_id: self.memory_id,
747            relevance,
748            vector_norm: self.vector_norm,
749            graph_weight: self.graph_weight,
750            confidence: NEUTRAL_CONFIDENCE,
751            recency: 0.0,
752            ventilated: false,
753        }
754    }
755}
756
757/// The neutral confidence of a memory with no feedback history — mirrors
758/// `reinforce::RL_NEUTRAL_CONFIDENCE`, whose module is `persistence`-gated:
759/// its contribution to the blend is exactly `0`.
760const NEUTRAL_CONFIDENCE: f64 = 0.5;
761
762/// The learned RL confidence off a raw payload, in `[0, 1]`. Without the
763/// `persistence` feature the RL module (and thus `feedback`) does not exist,
764/// so every memory reads neutral.
765#[cfg(feature = "persistence")]
766fn payload_confidence(payload: Option<&Metadata>) -> f64 {
767    f64::from(payload.map_or(
768        super::reinforce::RL_NEUTRAL_CONFIDENCE,
769        super::reinforce::read_confidence,
770    ))
771}
772
773/// See the `persistence` twin: no RL module, always neutral.
774#[cfg(not(feature = "persistence"))]
775fn payload_confidence(_payload: Option<&Metadata>) -> f64 {
776    NEUTRAL_CONFIDENCE
777}
778
779/// Whether the policy's importance weights change anything at all: a
780/// non-zero confidence weight, or a non-zero recency weight WITH a field to
781/// read. Zero weights must cost nothing and change nothing (0.8.0 parity).
782#[allow(
783    clippy::float_cmp,
784    reason = "an exact zero weight is the documented off switch; any non-zero weight, however small, is active"
785)]
786fn importance_active(weights: &ImportanceWeights) -> bool {
787    weights.confidence != 0.0 || (weights.recency != 0.0 && weights.recency_field.is_some())
788}
789
790/// The batch-relative recency contribution of every candidate, in `[0, 1]`:
791/// min-max over the candidates that carry the policy's `recency_field` as a
792/// number (one monotone scale per batch — `YYYYMMDD` or an epoch, the
793/// caller's choice). A candidate without the key contributes `0` (never
794/// penalised), and a degenerate batch (`max == min`) contributes `0` for
795/// all. No clock: recency is relative to the newest of the batch.
796#[allow(
797    clippy::float_cmp,
798    reason = "an exact zero weight is the documented off switch for the recency term"
799)]
800fn recency_norms(candidates: &[MemoryCandidate], weights: &ImportanceWeights) -> Vec<f64> {
801    let field = weights
802        .recency_field
803        .as_ref()
804        .filter(|_| weights.recency != 0.0);
805    let Some(field) = field else {
806        return vec![0.0; candidates.len()];
807    };
808    let values: Vec<Option<f64>> = candidates
809        .iter()
810        .map(|candidate| {
811            candidate
812                .metadata
813                .as_ref()
814                .and_then(|meta| meta.get(field.as_str()))
815                .and_then(Value::as_f64)
816                .filter(|value| value.is_finite())
817        })
818        .collect();
819    let (min, max) = values
820        .iter()
821        .flatten()
822        .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &v| {
823            (lo.min(v), hi.max(v))
824        });
825    if max <= min {
826        return vec![0.0; candidates.len()];
827    }
828    values
829        .into_iter()
830        .map(|value| value.map_or(0.0, |v| ((v - min) / (max - min)).clamp(0.0, 1.0)))
831        .collect()
832}
833
834/// Stamp pulled memories into the compiled provenance: their decisions and
835/// sources gain the backing `memory_id`, the decision's relevance becomes
836/// the normalised (importance-blended, when active) ranking score, and the
837/// reason spells out the full score ventilation — vector and graph always,
838/// plus confidence and recency when the blend ran — so `why this memory` is
839/// answerable from the decision alone.
840fn annotate_memory_provenance(out: &mut CompiledContext, pulled: &BTreeMap<u64, PulledMemory>) {
841    for decision in &mut out.decisions {
842        if let Some(memory) = pulled.get(&decision.content_hash) {
843            decision.memory_id = Some(memory.memory_id);
844            decision.relevance = memory.relevance;
845            decision.reason = if memory.ventilated {
846                format!(
847                    "{} — pulled from memory {} (vector {:.2}, graph {:.2}, confidence {:.2}, recency {:.2})",
848                    decision.reason,
849                    memory.memory_id,
850                    memory.vector_norm,
851                    memory.graph_weight,
852                    memory.confidence,
853                    memory.recency
854                )
855            } else {
856                format!(
857                    "{} — pulled from memory {} (vector {:.2}, graph {:.2})",
858                    decision.reason, memory.memory_id, memory.vector_norm, memory.graph_weight
859                )
860            };
861        }
862    }
863    for source in &mut out.sources {
864        if let Some(hash) = provenance::parse_handle(&source.handle) {
865            if let Some(memory) = pulled.get(&hash) {
866                source.memory_id = Some(memory.memory_id);
867            }
868        }
869    }
870}
871
872/// Base metadata of every bridge-stored system fact: hub-marked (invisible
873/// to normal recall) plus the given extra keys.
874fn system_meta(extra: &[(&str, Value)]) -> Metadata {
875    let mut meta = Map::new();
876    meta.insert(HUB_FIELD.to_owned(), Value::Bool(true));
877    for (key, value) in extra {
878        meta.insert((*key).to_owned(), value.clone());
879    }
880    meta
881}
882
883/// The metadata of one compilation event — counts and identifiers only,
884/// every key reserved.
885fn event_meta(request: &CompileRequest, out: &CompiledContext, nanos: u128) -> Metadata {
886    let mut extra: Vec<(&str, Value)> = vec![
887        (CTX_EVENT_FIELD, Value::Bool(true)),
888        (
889            CTX_TOKENS_IN_FIELD,
890            Value::Number(out.insights.tokens_in.into()),
891        ),
892        (
893            CTX_TOKENS_OUT_FIELD,
894            Value::Number(out.insights.tokens_out.into()),
895        ),
896        (
897            CTX_TOKENS_SAVED_FIELD,
898            Value::Number(out.insights.tokens_saved.into()),
899        ),
900        (
901            CTX_AT_FIELD,
902            Value::Number(Number::from(
903                u64::try_from(nanos / 1_000_000_000).unwrap_or(u64::MAX),
904            )),
905        ),
906    ];
907    if let Some(project) = &request.project {
908        extra.push((CTX_PROJECT_FIELD, Value::String(project.clone())));
909    }
910    if let Some(model) = &request.target_model {
911        extra.push((CTX_MODEL_FIELD, Value::String(model.clone())));
912    }
913    if let (Some(micros), Some(currency)) = (
914        out.insights.estimated_cost_saved_micros,
915        out.insights.currency.as_ref(),
916    ) {
917        extra.push((CTX_COST_FIELD, Value::Number(micros.into())));
918        extra.push((CTX_CURRENCY_FIELD, Value::String(currency.clone())));
919    }
920    system_meta(&extra)
921}
922
923/// Fold raw event payloads (reserved keys included) into one
924/// [`ContextSavings`]. Every accumulation saturates — an aggregate must
925/// never panic, whatever the stored numbers.
926fn aggregate_events(payloads: &[Option<Metadata>]) -> ContextSavings {
927    let mut savings = ContextSavings {
928        events: payloads.len() as u64,
929        truncated: payloads.len() >= crate::limits::MAX_RECALL_LIMIT,
930        ..ContextSavings::default()
931    };
932    for payload in payloads {
933        let Some(meta) = payload else { continue };
934        savings.tokens_in = savings
935            .tokens_in
936            .saturating_add(meta_u64(meta, CTX_TOKENS_IN_FIELD));
937        savings.tokens_out = savings
938            .tokens_out
939            .saturating_add(meta_u64(meta, CTX_TOKENS_OUT_FIELD));
940        savings.tokens_saved = savings
941            .tokens_saved
942            .saturating_add(meta_u64(meta, CTX_TOKENS_SAVED_FIELD));
943        if let (Some(Value::String(currency)), micros) =
944            (meta.get(CTX_CURRENCY_FIELD), meta_u64(meta, CTX_COST_FIELD))
945        {
946            if micros > 0 {
947                let entry = savings
948                    .cost_saved_micros_by_currency
949                    .entry(currency.clone())
950                    .or_insert(0);
951                *entry = entry.saturating_add(micros);
952            }
953        }
954    }
955    savings
956}
957
958/// A `u64` metadata field, `0` when absent or non-numeric.
959fn meta_u64(meta: &Metadata, key: &str) -> u64 {
960    meta.get(key).and_then(Value::as_u64).unwrap_or(0)
961}
962
963/// The salted system-fact id of a stored source.
964fn source_id(content_hash: u64) -> u64 {
965    stable_id(&format!("{SOURCE_ID_SALT}{content_hash}"))
966}
967
968/// The handle-identity hash of one request fragment — the bridge-side twin
969/// of `Analysis::handle_hash` in `context.rs` (kept in lockstep; the two
970/// must key the same identity or stored slots and minted handles drift
971/// apart): raw decoded media bytes for a media fragment, caption/content
972/// [`stable_id`] otherwise.
973fn fragment_handle_hash(fragment: &ContextFragment) -> u64 {
974    fragment.media.as_ref().map_or_else(
975        || stable_id(&fragment.content),
976        |media_ref| media::analyze(media_ref).raw_hash,
977    )
978}
979
980/// A stored source's media payload (US-009, PR2), when its metadata carries
981/// one — absent (or malformed, which should never happen for a payload this
982/// bridge wrote itself) round-trips as `None` rather than an error, so a
983/// media decode hiccup degrades to "text-only", never breaks the whole
984/// retrieval.
985fn source_media(meta: &Metadata) -> Option<MediaRef> {
986    meta.get(CTX_SOURCE_MEDIA_FIELD)
987        .cloned()
988        .and_then(|value| serde_json::from_value(value).ok())
989}
990
991/// The salted, deterministic system-fact id of a working context.
992fn working_id(project: &str, session: &str) -> u64 {
993    stable_id(&format!("{WORKING_ID_SALT}{project}\u{1f}{session}"))
994}
995
996#[cfg(all(test, feature = "persistence"))]
997#[path = "memory_bridge_tests.rs"]
998mod tests;