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