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