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, LoadedWorkingContext, MediaRef, MemoryScope,
68    WorkingContext, 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, and
97/// [`MemoryService::context_savings`] aggregates only genuine events (it
98/// filters at the storage layer, below the caller-facing validation).
99///
100/// Being unfilterable was once claimed here to make these facts "invisible to
101/// every caller-facing recall path". It did not (#1737). A caller cannot
102/// filter ON a reserved key, but `field != value` MATCHES a fact that has no
103/// such field — and a system fact has none of the caller's columns, so every
104/// `!=` predicate swept all of them in. Invisibility is now an exclusion
105/// [`crate::storage::INTERNAL_MARKER_FIELDS`] states and each backend
106/// applies, not a side effect of the naming rule.
107///
108/// The four markers below are therefore imported rather than redeclared: they
109/// ARE entries of that list, and a local copy could drift from it silently.
110use crate::storage::{
111    CTX_EVENT_FIELD, CTX_SOURCE_FIELD, CTX_WORKING_FIELD, CTX_WORKING_INDEX_FIELD,
112};
113
114const CTX_PROJECT_FIELD: &str = "_veles_ctx_project";
115const CTX_MODEL_FIELD: &str = "_veles_ctx_model";
116/// A stored source's media payload (US-009, PR2): `{"mime", "bytes_b64"}`,
117/// the exact [`MediaRef`] shape, set only when the source fragment carried
118/// one. Reserved like every other `_veles_ctx_*` key — a caller can neither
119/// set nor filter on it.
120const CTX_SOURCE_MEDIA_FIELD: &str = "_veles_ctx_source_media";
121/// The durable-TTL payload key set by [`super::positive_ttl`]-backed writes
122/// (`store_with_ttl`, via `store_fact`). Mirrors `velesdb_core::EXPIRES_AT_KEY`
123/// as a literal rather than an import: that re-export is `persistence`-gated,
124/// and this module (unlike `NativeStore`) must keep compiling under `context`
125/// alone (e.g. `velesdb-wasm`, which never enables `persistence`).
126const EXPIRES_AT_FIELD: &str = "_veles_expires_at";
127const CTX_SESSION_FIELD: &str = "_veles_ctx_session";
128const CTX_TOKENS_IN_FIELD: &str = "_veles_ctx_tokens_in";
129const CTX_TOKENS_OUT_FIELD: &str = "_veles_ctx_tokens_out";
130const CTX_TOKENS_SAVED_FIELD: &str = "_veles_ctx_tokens_saved";
131const CTX_COST_FIELD: &str = "_veles_ctx_cost_micros";
132const CTX_CURRENCY_FIELD: &str = "_veles_ctx_currency";
133const CTX_AT_FIELD: &str = "_veles_ctx_at";
134
135/// Per-process sequence folded into event ids so two compilations landing on
136/// the same clock tick (coarse timers, concurrent calls) never collide.
137static EVENT_SEQ: AtomicU64 = AtomicU64::new(0);
138
139/// Serializes the read-modify-write of the per-project working-context index.
140///
141/// The index is ONE fact per project, rewritten wholesale on every
142/// `save_working_context`. Without this, two saves racing on the same project
143/// both read the same pre-state and the second write erases the first
144/// session's entry — a silent loss: the erased session's own fact is still on
145/// disk and still loadable by exact id, but `list_working_contexts` (and
146/// therefore `load_working_context`'s `other_sessions` recovery hint) no
147/// longer knows it exists, and nothing anywhere returns an error.
148///
149/// **Scope, honestly: this is an INTRA-PROCESS lock only.** Two processes
150/// opening the same store still race, because nothing below this layer offers
151/// a compare-and-swap. The durable fix is a CAS or a transaction on the
152/// [`MemoryStore`] trait itself; until then, the single-process case (the MCP
153/// server, whose `spawn_blocking` handlers are exactly what made this
154/// reachable) is covered and the multi-process case is not.
155///
156/// One global lock rather than one per project: index writes are rare (one
157/// per `save_working_context`), so the contention is negligible, whereas a
158/// `HashMap<String, _>` keyed by caller-supplied project names is an unbounded
159/// slow leak for no measurable gain. Per-project striping is the obvious
160/// upgrade if index writes ever become hot.
161static WORKING_INDEX_WRITE: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
162
163impl<E: Embedder, S: MemoryStore> MemoryService<E, S> {
164    /// [`ContextCompiler::compile`] with this service's memory folded in:
165    /// when the request carries a [`MemoryScope`], relevant memories are
166    /// pulled through the fused vector+graph recall and compiled alongside
167    /// the caller's fragments, each with its `memory_id` and a normalised
168    /// fused-ranking relevance recorded in provenance. Afterwards (policy
169    /// permitting) the distinct originals are stored so every
170    /// `ctx://source/<hash>` handle round-trips, and a metadata-only
171    /// compilation event is recorded for [`Self::context_savings`].
172    ///
173    /// # Errors
174    /// Returns [`MemoryError`] if compilation itself fails (budget, caps),
175    /// or if recall, embedding, or storage fails.
176    pub fn compile_context(
177        &self,
178        compiler: &ContextCompiler,
179        request: &CompileRequest,
180    ) -> Result<CompiledContext, MemoryError> {
181        let importance = compiler.effective_policy(request).importance.clone();
182        let memories = self.context_memories(request, &importance)?;
183        self.compile_with_memories(compiler, request, memories)
184    }
185
186    /// [`Self::compile_context`] with a caller-supplied [`crate::Reranker`] driving
187    /// memory selection: the reranker receives the FULL fused candidate pool
188    /// (vector + graph, before the `k` cutoff) and its ordering decides
189    /// which `k` memories are compiled in — the seam for a semantic
190    /// cross-encoder or LLM judge a Rust embedder brings along. Not exposed
191    /// on the wire (a reranker is code, not JSON), and never a default: the
192    /// shipped [`crate::context::DeterministicReranker`] is *lexical*, and a
193    /// lexical second stage demotes exactly the zero-vocabulary-overlap
194    /// evidence the graph walk rescues (measured in the BDD suite) — bring
195    /// a semantic one.
196    ///
197    /// # Errors
198    /// Returns [`MemoryError`] if compilation, recall, the reranker itself,
199    /// or storage fails.
200    pub fn compile_context_reranked<R: crate::Reranker>(
201        &self,
202        compiler: &ContextCompiler,
203        request: &CompileRequest,
204        reranker: &R,
205    ) -> Result<CompiledContext, MemoryError> {
206        let importance = compiler.effective_policy(request).importance.clone();
207        let memories = self.context_memories_reranked(request, reranker, &importance)?;
208        self.compile_with_memories(compiler, request, memories)
209    }
210
211    /// The shared back half of every compile flavour: augment the request
212    /// with the pulled memories, compile, annotate provenance, persist
213    /// sources/events per policy.
214    fn compile_with_memories(
215        &self,
216        compiler: &ContextCompiler,
217        request: &CompileRequest,
218        memories: Vec<PulledMemory>,
219    ) -> Result<CompiledContext, MemoryError> {
220        let mut augmented = request.clone();
221        let mut pulled: BTreeMap<u64, PulledMemory> = BTreeMap::new();
222        for memory in memories {
223            augmented.fragments.push(memory.fragment.clone());
224            pulled.insert(stable_id(&memory.fragment.content), memory);
225        }
226        // `compile_raw`, not `compile`: annotating memory provenance below
227        // can rewrite a pulled fragment's `relevance`/`reason` (and thus
228        // whether it crosses the `warnings` threshold), so `decisions` must
229        // stay full until that has happened and `warnings` is recomputed —
230        // `slim_response` (if requested) is applied as the LAST step.
231        let mut out = compiler.compile_raw(&augmented)?;
232        annotate_memory_provenance(&mut out, &pulled);
233        out.warnings = crate::context::warnings_for(&out.decisions);
234        let policy = compiler.effective_policy(request);
235        if policy.store_sources {
236            self.store_context_sources(&augmented, &out, policy.source_ttl_seconds)?;
237        }
238        if policy.record_events {
239            self.record_context_event(request, &out, policy.event_ttl_seconds)?;
240        }
241        Ok(crate::context::apply_slim(out, policy))
242    }
243
244    /// The memories a request's scope pulls in, as compile fragments plus
245    /// their id and normalised fused relevance, importance-blended
246    /// ([`Self::blend_importance`]) when the policy's weights are active.
247    fn context_memories(
248        &self,
249        request: &CompileRequest,
250        importance: &ImportanceWeights,
251    ) -> Result<Vec<PulledMemory>, MemoryError> {
252        let Some((scope, k)) = scope_and_k(request) else {
253            return Ok(Vec::new());
254        };
255        let filter = scope_filter(scope);
256        // The scope's fusion knobs (clamped by from_knobs); absent ones fall
257        // back to the crate defaults — raising graph_boost lets a curated
258        // relate-chain out-rank lexically-noisy near-misses (see MemoryScope).
259        let opts = FusionOptions::from_knobs(scope.hops, scope.graph_boost, None);
260        let scored = self.recall_fused_scored(&request.query, k, filter.as_ref(), opts)?;
261        let max_fused = scored
262            .iter()
263            .map(|s| s.fused)
264            .fold(f64::MIN, f64::max)
265            .max(f64::EPSILON);
266        let candidates = scored
267            .into_iter()
268            .map(|scored| {
269                // Sanitise a non-finite fused score to 0 before normalising:
270                // `f32::clamp` returns NaN for a NaN input (it does not clamp),
271                // which would put a non-`[0, 1]` value — serialising as JSON
272                // `null` — into an output sold as deterministic and auditable.
273                let fused = if scored.fused.is_finite() {
274                    scored.fused
275                } else {
276                    0.0
277                };
278                MemoryCandidate {
279                    memory_id: scored.recollection.id,
280                    base: (fused / max_fused).clamp(0.0, 1.0),
281                    vector_norm: scored.vector_norm,
282                    graph_weight: scored.graph_weight,
283                    metadata: scored.recollection.metadata,
284                    content: scored.recollection.content,
285                }
286            })
287            .collect();
288        self.blend_importance(candidates, importance)
289    }
290
291    /// Memory selection driven by a caller-supplied reranker: the fused
292    /// candidate pool (at pool depth, vector + graph) is handed to the
293    /// reranker whole, its ordering is truncated to `k`, and relevance is
294    /// rank-based (the reranker defines the ranking; the fused ventilation
295    /// no longer describes it, so vector/graph read 0 in provenance). The
296    /// importance blend then composes with the seam: it re-ranks INSIDE the
297    /// reranker-selected pool, exactly as it does over the fused pool.
298    fn context_memories_reranked<R: crate::Reranker>(
299        &self,
300        request: &CompileRequest,
301        reranker: &R,
302        importance: &ImportanceWeights,
303    ) -> Result<Vec<PulledMemory>, MemoryError> {
304        let Some((scope, k)) = scope_and_k(request) else {
305            return Ok(Vec::new());
306        };
307        let filter = scope_filter(scope);
308        let opts = FusionOptions::from_knobs(scope.hops, scope.graph_boost, None);
309        let ranked =
310            self.recall_fused_reranked(&request.query, k, filter.as_ref(), opts, reranker)?;
311        let count = ranked.len().max(1);
312        let candidates = ranked
313            .into_iter()
314            .enumerate()
315            .map(|(rank, recollection)| {
316                // Computed in f32 exactly as 0.8.0 did, so inactive weights
317                // reproduce the historical relevance bytes.
318                #[allow(clippy::cast_precision_loss)] // rank/count are tiny
319                let relevance = 1.0 - (rank as f32 / count as f32);
320                MemoryCandidate {
321                    memory_id: recollection.id,
322                    base: f64::from(relevance),
323                    vector_norm: 0.0,
324                    graph_weight: 0.0,
325                    metadata: recollection.metadata,
326                    content: recollection.content,
327                }
328            })
329            .collect();
330        self.blend_importance(candidates, importance)
331    }
332
333    /// Fold usage-driven importance into an already-selected memory pool —
334    /// the one ranking the whole engine stack shares (US-002 of EPIC-P-071):
335    /// per candidate the key becomes `base + w_c·(confidence − 0.5)·2 +
336    /// w_r·recency_norm`, where `base` is the fused (or rank-based)
337    /// similarity in `[0, 1]`. Selection is untouched on purpose: confidence
338    /// is not relevance, so a reinforced-but-off-topic fact can never buy
339    /// its way into the pool here. Inactive weights take the zero-cost path
340    /// and reproduce the 0.8.0 output byte for byte (golden-pinned). The
341    /// stable sort keeps equal keys in selection order, and no clock is ever
342    /// read — recency is min-max normalised within the batch.
343    fn blend_importance(
344        &self,
345        candidates: Vec<MemoryCandidate>,
346        weights: &ImportanceWeights,
347    ) -> Result<Vec<PulledMemory>, MemoryError> {
348        if !importance_active(weights) {
349            return Ok(candidates
350                .into_iter()
351                .map(MemoryCandidate::into_pulled)
352                .collect());
353        }
354        let ids: Vec<u64> = candidates.iter().map(|c| c.memory_id).collect();
355        // Raw payloads (reserved keys included): the learned confidence
356        // lives under `_veles_rl_confidence`, which caller-facing metadata
357        // strips.
358        let raw = self.store.get_metadata_batch(&ids)?;
359        let recencies = recency_norms(&candidates, weights);
360        let mut blended: Vec<(f64, PulledMemory)> = candidates
361            .into_iter()
362            .zip(raw)
363            .zip(recencies)
364            .map(|((candidate, payload), recency)| {
365                let confidence = payload_confidence(payload.as_ref());
366                let score = candidate.base
367                    + weights.confidence * (confidence - NEUTRAL_CONFIDENCE) * 2.0
368                    + weights.recency * recency;
369                let mut pulled = candidate.into_pulled();
370                #[allow(clippy::cast_possible_truncation)] // clamped into [0, 1]
371                {
372                    pulled.relevance = score.clamp(0.0, 1.0) as f32;
373                }
374                pulled.confidence = confidence;
375                pulled.recency = recency;
376                pulled.ventilated = true;
377                (score, pulled)
378            })
379            .collect();
380        // Stable: equal blended keys keep the selection order.
381        blended.sort_by(|a, b| b.0.total_cmp(&a.0));
382        Ok(blended.into_iter().map(|(_, pulled)| pulled).collect())
383    }
384
385    /// Store every distinct fragment's original as a hub-marked system fact
386    /// keyed by its salted handle hash, so its handle can be resolved later.
387    /// A fragment carrying media (US-009, PR2) has its base64 payload
388    /// persisted alongside the caption under the reserved
389    /// [`CTX_SOURCE_MEDIA_FIELD`] key.
390    ///
391    /// **Identity**: the key mirrors what the compiler mints handles from
392    /// (`Analysis::handle_hash` in `context.rs`) — the caption's
393    /// [`stable_id`] for text, the raw decoded bytes' hash
394    /// ([`media::MediaAnalysis::raw_hash`]) for media, the same identity
395    /// PR1's dedup keys on. Keying media on the caption instead was the PR2
396    /// review's proven blocker: every captionless image collided onto one
397    /// slot and one handle, serving arbitrary wrong bytes back. The slot
398    /// stays inside the salted system-fact namespace ([`source_id`] applies
399    /// `SOURCE_ID_SALT` to the hash) — same salt, no new namespace. On a
400    /// same-key collision (byte-identical images with different captions)
401    /// the FIRST occurrence wins, matching the dedup twin the compiler
402    /// keeps — a divergent duplicate caption does not survive, exactly as
403    /// its decision reason already says.
404    ///
405    /// Size: [`crate::limits::MAX_MEDIA_BYTES`] /
406    /// [`crate::limits::MAX_TOTAL_MEDIA_BYTES`] already bounded every
407    /// fragment's `bytes_b64` before `compiler.compile` ever ran (see
408    /// `validate_media`, called from `compile`'s `validate`). TEXT is a
409    /// different story, and an earlier revision of this comment got it
410    /// wrong by claiming no size guard was needed on the write path: those
411    /// media caps say nothing about `content`, which a `path` ingestion can
412    /// fill up to 1 MiB — so [`Self::source_vector`] caps what it EMBEDS
413    /// (the stored content stays whole). The lesson stands: "another layer
414    /// already checked" must name which cap, over which field.
415    fn store_context_sources(
416        &self,
417        augmented: &CompileRequest,
418        out: &CompiledContext,
419        ttl_seconds: Option<u64>,
420    ) -> Result<(), MemoryError> {
421        let by_hash = index_fragments_by_handle_hash(&augmented.fragments);
422        let ttl_seconds = positive_ttl(ttl_seconds);
423        for source in &out.sources {
424            self.store_one_source(&source.handle, &by_hash, ttl_seconds)?;
425        }
426        Ok(())
427    }
428
429    /// Write the one slot behind `handle`, if this compile owns it.
430    ///
431    /// A handle whose fragment is no longer in the request (or that does not
432    /// parse) is skipped, not an error: `out.sources` is derived from the
433    /// same request, so a miss can only mean the source was externalized
434    /// under a shape this write path has nothing to store.
435    fn store_one_source(
436        &self,
437        handle: &str,
438        by_hash: &BTreeMap<u64, &ContextFragment>,
439        ttl_seconds: Option<u64>,
440    ) -> Result<(), MemoryError> {
441        let Some(hash) = provenance::parse_handle(handle) else {
442            return Ok(());
443        };
444        let Some(fragment) = by_hash.get(&hash) else {
445            return Ok(());
446        };
447        let slot = source_id(hash);
448        if !self.prepare_source_slot(slot, ttl_seconds)? {
449            return Ok(());
450        }
451        let (embedding, media_meta) = self.source_vector(fragment, hash)?;
452        let mut extra: Vec<(&str, Value)> = vec![(CTX_SOURCE_FIELD, Value::Bool(true))];
453        if let Some(media) = media_meta {
454            extra.push((CTX_SOURCE_MEDIA_FIELD, media));
455        }
456        self.store_fact(
457            slot,
458            fragment.content.as_str(),
459            &embedding,
460            Some(&system_meta(&extra)),
461            ttl_seconds,
462        )
463    }
464
465    /// Whether `slot` may be written for this compile, clearing a stale point
466    /// first when the write upgrades it to permanent.
467    ///
468    /// A slot never marked as ours is never rewritten: it is a caller fact
469    /// squatting the salt preimage, and clobbering it would destroy user
470    /// data. A slot already marked as ours holds these exact bytes — sources
471    /// are content-addressed — so content and embedding never change; only
472    /// durability can, and only upward (never-downgrade TTL upgrade, see
473    /// [`Self::should_store_source`]), so a handle sold as permanent never
474    /// silently expires just because an earlier compile first wrote it under
475    /// a TTL.
476    ///
477    /// Upgrading to permanent needs the old point *gone*, not merely
478    /// overwritten: velesdb-core's store path preserves every `_veles_*` key
479    /// from a prior version of a re-stored id unless the new write explicitly
480    /// sets it (`semantic_memory.rs`'s `store_internal` carry-forward, so
481    /// plain `remember` doesn't silently wipe learned state), and a permanent
482    /// write has no expiry to set (`attach_expiry` is a no-op without one) —
483    /// so without this delete, `_veles_expires_at` would survive the
484    /// "upgrade" untouched. A TTL-to-TTL extension needs no delete: its new
485    /// expiry always overwrites the old one.
486    fn prepare_source_slot(
487        &self,
488        slot: u64,
489        ttl_seconds: Option<u64>,
490    ) -> Result<bool, MemoryError> {
491        if !self.should_store_source(slot, ttl_seconds)? {
492            return Ok(false);
493        }
494        if ttl_seconds.is_none() && self.store.get(slot)?.is_some() {
495            self.store.delete(slot)?;
496        }
497        Ok(true)
498    }
499
500    /// The vector a source slot is indexed by, plus the media descriptor to
501    /// stamp on it when the fragment carries one.
502    ///
503    /// A media fragment's vector is deterministic and derived from the
504    /// DECODED bytes — never the text embedder over `content` (often blank)
505    /// nor over the base64 payload itself (opaque, not language). Correct
506    /// because `retrieve_context_source` resolves a media source EXCLUSIVELY
507    /// by its content-addressed hash/slot, never by vector search: the vector
508    /// only has to be well-formed and non-degenerate for the underlying
509    /// index, never semantically meaningful. For a media fragment `hash` IS
510    /// the raw-bytes hash (see `fragment_handle_hash`), so nothing is
511    /// re-decoded here.
512    ///
513    /// A TEXT fragment is embedded over at most
514    /// [`crate::limits::MAX_EMBEDDABLE_TEXT_BYTES`] of its content
515    /// ([`super::embeddable_prefix`]) — a `path`-ingested file can be 1 MiB,
516    /// far past what the embedding backend accepts, and handing it over
517    /// whole surfaced the backend's raw failure (issue #1654's residue,
518    /// found on this very path). Truncating the *embedded* text, not the
519    /// stored content, is the right trade here: retrieval is hash-addressed
520    /// so the source stays whole, and the vector keeps ranking on the head
521    /// of the text instead of vanishing from semantic recall.
522    fn source_vector(
523        &self,
524        fragment: &ContextFragment,
525        hash: u64,
526    ) -> Result<(Vec<f32>, Option<Value>), MemoryError> {
527        let Some(media_ref) = &fragment.media else {
528            let embeddable = super::embeddable_prefix(fragment.content.as_str());
529            return Ok((self.embedder.embed(embeddable)?, None));
530        };
531        let descriptor = serde_json::to_value(media_ref).unwrap_or(Value::Null);
532        Ok((self.media_placeholder_embedding(hash), Some(descriptor)))
533    }
534
535    /// Whether [`Self::store_context_sources`] should (re-)write `slot` for
536    /// this compile's requested (already [`positive_ttl`]-normalized —
537    /// `None` means permanent) TTL.
538    ///
539    /// - Not marked as ours (absent, or a caller fact squatting the salt
540    ///   preimage): store only if the slot is genuinely empty.
541    /// - Marked as ours: never re-embed or change content (content-addressed);
542    ///   only [`Self::should_upgrade_ttl`] decides whether durability changes.
543    fn should_store_source(
544        &self,
545        slot: u64,
546        requested_ttl: Option<u64>,
547    ) -> Result<bool, MemoryError> {
548        match self.context_source_metadata(slot)? {
549            Some(existing) => Ok(Self::should_upgrade_ttl(&existing, requested_ttl)),
550            None => Ok(self.store.get(slot)?.is_none()),
551        }
552    }
553
554    /// Never-downgrade TTL upgrade rule for an already-stored source: permanent
555    /// once requested stays permanent, and a TTL only ever extends, never
556    /// shortens. The clock read here is fine — this is the storage/expiry
557    /// layer, not the clock-free `compile` pipeline.
558    fn should_upgrade_ttl(existing: &Metadata, requested_ttl: Option<u64>) -> bool {
559        let existing_expiry = existing.get(EXPIRES_AT_FIELD).and_then(Value::as_u64);
560        match (requested_ttl, existing_expiry) {
561            // Permanent requested, slot still carries a TTL: upgrade.
562            (None, Some(_)) => true,
563            // Already permanent, or a TTL requested against a permanent slot:
564            // never downgrade.
565            (None | Some(_), None) => false,
566            // Both carry a TTL: extend only if the new one outlives what
567            // remains — never shorten.
568            (Some(ttl), Some(existing_exp)) => now_unix_secs().saturating_add(ttl) > existing_exp,
569        }
570    }
571
572    /// A deterministic, non-degenerate embedding for a media source (US-009,
573    /// PR2) — see [`Self::store_context_sources`] for why it is bytes-hash
574    /// derived rather than text-embedded.
575    fn media_placeholder_embedding(&self, raw_hash: u64) -> Vec<f32> {
576        let dim = self.embedder.dimension();
577        let mut vector = vec![0.0_f32; dim];
578        let Ok(dim_u64) = u64::try_from(dim) else {
579            return vector;
580        };
581        if dim_u64 == 0 {
582            return vector;
583        }
584        let bucket = usize::try_from(raw_hash % dim_u64).unwrap_or(0);
585        vector[bucket] = 1.0;
586        velesdb_core::simd_native::normalize_inplace_native(&mut vector);
587        vector
588    }
589
590    /// The fact at `slot`'s metadata, when it carries the stored-source
591    /// marker (`None` otherwise — absent, or a caller fact squatting the
592    /// slot).
593    fn context_source_metadata(&self, slot: u64) -> Result<Option<Metadata>, MemoryError> {
594        let payloads = self.store.get_metadata_batch(&[slot])?;
595        Ok(payloads
596            .into_iter()
597            .next()
598            .flatten()
599            .filter(|meta| meta.get(CTX_SOURCE_FIELD) == Some(&Value::Bool(true))))
600    }
601
602    /// The original content — and media, when the fragment carried one —
603    /// behind a `ctx://source/<hash>` handle.
604    ///
605    /// # Errors
606    /// Returns [`MemoryError::UnknownHandle`] when the handle is malformed
607    /// or nothing is stored under it (never stored, expired, or forgotten).
608    pub fn retrieve_context_source(&self, handle: &str) -> Result<ContextSource, MemoryError> {
609        let unknown = || MemoryError::UnknownHandle(handle.to_owned());
610        let hash = provenance::parse_handle(handle).ok_or_else(unknown)?;
611        let slot = source_id(hash);
612        // Only marker-bearing facts are sources: a caller fact squatting the
613        // salted slot is never served back as compiled provenance.
614        let meta = self.context_source_metadata(slot)?.ok_or_else(unknown)?;
615        let content = self
616            .store
617            .get(slot)?
618            .map(|(content, _embedding)| content)
619            .ok_or_else(unknown)?;
620        Ok(ContextSource {
621            content,
622            media: source_media(&meta),
623        })
624    }
625
626    /// Explain why one fragment of `request` was preserved, abstracted,
627    /// externalized, dropped, or cached — the selection primitive the MCP
628    /// `explain_compilation` tool delegates to, extracted here so every
629    /// adapter (MCP, Node, Python) shares one implementation instead of
630    /// reimplementing it. Compilation is deterministic, so `request` is
631    /// simply re-compiled — with event/source recording forced off, since an
632    /// explanation must not have side effects — and the matching decision is
633    /// returned.
634    ///
635    /// `fragment_index` (0-based position in `request.fragments`), when
636    /// given, TAKES PRIORITY over `fragment_id` for locating the decision:
637    /// `compile_context` records exactly one decision per input fragment, in
638    /// order, so `decisions[fragment_index]` is unambiguous even when
639    /// several fragments are byte-identical and therefore share the same
640    /// content-addressed `fragment_id` — a plain `fragment_id` lookup always
641    /// resolves to the FIRST such decision (the deduplication survivor's),
642    /// never a dropped twin's.
643    ///
644    /// Caveat inherited from re-compiling rather than replaying stored
645    /// state: with a `memory_scope` the re-compile recalls from CURRENT
646    /// memory, so the decision reflects memory as it is now, not as it was
647    /// at the original `compile_context` call; a caller that already
648    /// resolved a `path` fragment to `content` is unaffected (this method
649    /// does no I/O of its own).
650    ///
651    /// # Errors
652    /// Returns [`MemoryError::FragmentIndexOutOfBounds`] when `fragment_index`
653    /// is beyond `request.fragments`, [`MemoryError::FragmentNotFound`] when
654    /// no decision matches the selector, or any error [`Self::compile_context`]
655    /// itself can return (budget, caps, recall, embedding, storage).
656    pub fn explain_compilation(
657        &self,
658        request: &CompileRequest,
659        fragment_id: u64,
660        fragment_index: Option<usize>,
661    ) -> Result<ContextDecision, MemoryError> {
662        if let Some(index) = fragment_index {
663            let len = request.fragments.len();
664            if index >= len {
665                return Err(MemoryError::FragmentIndexOutOfBounds { index, len });
666            }
667        }
668        let mut request = request.clone();
669        let mut policy = request.policy.take().unwrap_or_default();
670        // Three options neutralised for one reason: an explanation must not
671        // inherit the side effects, nor the presentation, of the compilation it
672        // explains. The caller asked "why this fragment?", not "compile this".
673        policy.record_events = false;
674        policy.store_sources = false;
675        // `slim_response` empties `sections` and `decisions` to save tokens
676        // (see `apply_slim`). Applied here it would not trim the answer, it
677        // would DELETE it: `decisions` is cleared, the lookup below finds
678        // nothing, and the caller is told `FragmentNotFound` about a fragment
679        // that compiled perfectly well (#1745).
680        //
681        // The option exists to save tokens, so a caller under a tight budget
682        // turns it on by default — and lost the audit tool exactly when they
683        // most needed it, with a message that sent them looking for a typo in
684        // an id that was correct.
685        policy.slim_response = false;
686        request.policy = Some(policy);
687        let compiled =
688            self.compile_context(&ContextCompiler::new(CompilePolicy::default()), &request)?;
689        let decision = if let Some(index) = fragment_index {
690            compiled.decisions.into_iter().nth(index)
691        } else {
692            compiled
693                .decisions
694                .into_iter()
695                .find(|decision| decision.fragment_id == fragment_id)
696        };
697        decision.ok_or(MemoryError::FragmentNotFound(fragment_id))
698    }
699
700    /// Record one compilation's savings as a metadata-only system fact
701    /// (hashes and token counts — never fragment content). Wall-clock time
702    /// is stamped here, outside the deterministic compile pipeline.
703    fn record_context_event(
704        &self,
705        request: &CompileRequest,
706        out: &CompiledContext,
707        ttl_seconds: Option<u64>,
708    ) -> Result<(), MemoryError> {
709        let occurred_at_nanos = now_nanos();
710        // The per-process sequence keeps ids unique even when two compiles
711        // land on the same (possibly coarse) clock tick.
712        let seq = EVENT_SEQ.fetch_add(1, Ordering::Relaxed);
713        let content = format!("{EVENT_ANCHOR} {occurred_at_nanos}-{seq}");
714        let id = stable_id(&format!("{EVENT_ID_SALT}{occurred_at_nanos}:{seq}"));
715        let embedding = self.embedder.embed(&content)?;
716        let meta = event_meta(request, out, occurred_at_nanos);
717        self.store_fact(
718            id,
719            &content,
720            &embedding,
721            Some(&meta),
722            positive_ttl(ttl_seconds),
723        )?;
724        Ok(())
725    }
726
727    /// Aggregate the recorded compilation events, optionally per project.
728    /// Sweeps at most [`crate::limits::MAX_RECALL_LIMIT`] events (newest
729    /// need not be first — the sweep is similarity-ordered over a constant
730    /// anchor, i.e. effectively the whole family until the cap);
731    /// [`ContextSavings::truncated`] reports when the cap was hit.
732    ///
733    /// # Errors
734    /// Returns [`MemoryError`] if the underlying filtered recall fails.
735    pub fn context_savings(&self, project: Option<&str>) -> Result<ContextSavings, MemoryError> {
736        // Filter at the STORAGE layer on the reserved event marker: callers
737        // can neither set nor query `_veles_*` keys, so only genuine bridge
738        // events can ever match — a caller fact posing as an event counts
739        // for nothing.
740        let mut filter = Map::new();
741        filter.insert(CTX_EVENT_FIELD.to_owned(), Value::Bool(true));
742        if let Some(project) = project {
743            filter.insert(
744                CTX_PROJECT_FIELD.to_owned(),
745                Value::String(project.to_owned()),
746            );
747        }
748        let embedding = self.embedder.embed(EVENT_ANCHOR)?;
749        let hits =
750            self.store
751                .query_filtered(&embedding, crate::limits::MAX_RECALL_LIMIT, &filter, 0)?;
752        let ids: Vec<u64> = hits.iter().map(|(id, _, _)| *id).collect();
753        let payloads = self.store.get_metadata_batch(&ids)?;
754        Ok(aggregate_events(&payloads))
755    }
756
757    /// Persist `working` under `project` + `session` (idempotent upsert:
758    /// saving again replaces the previous state). Returns the system fact id.
759    ///
760    /// Serialized size is capped at [`crate::limits::MAX_FACT_BYTES`] (1
761    /// MiB) — the same ceiling every other stored fact honors — checked
762    /// BEFORE anything is written, so an oversized working context is never
763    /// partially stored.
764    ///
765    /// An entirely empty `working` ([`WorkingContext::is_empty`]) is refused.
766    /// Because the write is an upsert, saving one would replace — destroy —
767    /// the state a previous save stored under the same project and session,
768    /// and the one tool whose job is surviving a context loss must not be
769    /// able to cause one on a call that carries nothing (issue #1654).
770    ///
771    /// # Errors
772    /// Returns [`MemoryError::EmptyWorkingContext`] if `working` records
773    /// nothing, [`MemoryError::WorkingContextCodec`] if serialization fails,
774    /// [`MemoryError::ContextOverLimit`] if the serialized `working` exceeds
775    /// [`crate::limits::MAX_FACT_BYTES`], or a storage/embedding error.
776    pub fn save_working_context(
777        &self,
778        project: &str,
779        session: &str,
780        working: &WorkingContext,
781    ) -> Result<u64, MemoryError> {
782        if working.is_empty() {
783            return Err(MemoryError::EmptyWorkingContext);
784        }
785        let content = serde_json::to_string(working)
786            .map_err(|err| MemoryError::WorkingContextCodec(err.to_string()))?;
787        if content.len() > crate::limits::MAX_FACT_BYTES {
788            return Err(MemoryError::ContextOverLimit(format!(
789                "working context of {} bytes exceeds the cap of {} bytes",
790                content.len(),
791                crate::limits::MAX_FACT_BYTES
792            )));
793        }
794        let id = working_id(project, session);
795        let embedding = self
796            .embedder
797            .embed(&format!("working context {project} {session}"))?;
798        let meta = system_meta(&[
799            (CTX_WORKING_FIELD, Value::Bool(true)),
800            (CTX_PROJECT_FIELD, Value::String(project.to_owned())),
801            (CTX_SESSION_FIELD, Value::String(session.to_owned())),
802        ]);
803        self.store_fact(id, &content, &embedding, Some(&meta), None)?;
804        self.update_working_index(project, session)?;
805        Ok(id)
806    }
807
808    /// The working context previously saved under `project` + `session`,
809    /// `None` when there is none.
810    ///
811    /// Symmetric to [`Self::context_source_metadata`]'s squatter guard: the
812    /// slot is only ever served back when its metadata carries the reserved
813    /// [`CTX_WORKING_FIELD`] marker (set exclusively by
814    /// [`Self::save_working_context`]). A slot occupied by an unmarked caller
815    /// fact — one that happened to land on this salted id, or a forged
816    /// probe — is indistinguishable from "nothing saved" on purpose: `None`,
817    /// never the forged content, and never an error (the caller cannot tell
818    /// a squatted slot from a genuinely empty one, which is the point — it
819    /// must never learn that *something* occupies this id).
820    ///
821    /// A pure read: it never writes, never prunes, never heals. Index
822    /// convergence happens on the WRITE path
823    /// ([`Self::update_working_index`]) — a lookup that rewrites shared state
824    /// turns every transient miss into permanent data loss and cannot safely
825    /// be retried.
826    ///
827    /// # Errors
828    /// Returns [`MemoryError::WorkingContextCodec`] if the stored payload
829    /// does not parse, or if the slot is marked but its body is gone (a torn
830    /// fact is corruption — reporting it as "nothing saved" would tell the
831    /// caller the one thing that is certainly false), or a storage error.
832    pub fn load_working_context(
833        &self,
834        project: &str,
835        session: &str,
836    ) -> Result<Option<WorkingContext>, MemoryError> {
837        let slot = working_id(project, session);
838        let payloads = self.store.get_metadata_batch(&[slot])?;
839        let marked = payloads
840            .into_iter()
841            .next()
842            .flatten()
843            .is_some_and(|meta| meta.get(CTX_WORKING_FIELD) == Some(&Value::Bool(true)));
844        if !marked {
845            // The squatter/never-saved guard documented above: silent by
846            // design, and the branch a `forget` lands on (deleting a fact
847            // removes its metadata with it).
848            return Ok(None);
849        }
850        let Some((content, _)) = self.store.get(slot)? else {
851            return Err(MemoryError::WorkingContextCodec(format!(
852                "working context for project '{project}', session '{session}' is corrupt: \
853                 the reserved marker is present but the stored body is gone"
854            )));
855        };
856        serde_json::from_str(&content)
857            .map(Some)
858            .map_err(|err| MemoryError::WorkingContextCodec(err.to_string()))
859    }
860
861    /// The full resumption envelope for `project` + `session`: what
862    /// [`Self::load_working_context`] found, plus the OTHER sessions saved
863    /// under the same project so a typo in `session` is recoverable.
864    ///
865    /// This is the ONE place the three policy rules live:
866    ///
867    /// 1. `other_sessions` is listed on a HIT too, not just on a miss — a
868    ///    typo that lands on another REAL session returns `found: true`, and
869    ///    the caller has no other way to notice it resumed the wrong work.
870    ///    Costs one extra O(1) index read per successful load.
871    /// 2. The requested `session` is never echoed back: the field is named
872    ///    `other_sessions`, so returning the requested id would be a
873    ///    contradiction the caller cannot act on.
874    /// 3. An unreadable index is fatal on a MISS and survivable on a HIT —
875    ///    see [`Self::other_sessions_for`].
876    ///
877    /// Every surface (the `load_working_context` MCP tool and the Node,
878    /// Python and WASM bindings) calls this rather than recomposing the
879    /// envelope from [`Self::load_working_context`] +
880    /// [`Self::list_working_contexts`]: four recompositions are four copies
881    /// of those rules, and a copy that stops matching the others fails
882    /// silently — the caller still gets a well-formed envelope, just a
883    /// different one.
884    ///
885    /// # Errors
886    /// Propagates [`Self::load_working_context`]'s errors (a corrupt or
887    /// unparseable stored payload), and [`Self::list_working_contexts`]'s (a
888    /// corrupt index, or a storage failure) **on a miss only** — rule 3.
889    pub fn resume_working_context(
890        &self,
891        project: &str,
892        session: &str,
893    ) -> Result<LoadedWorkingContext, MemoryError> {
894        let working = self.load_working_context(project, session)?;
895        let other_sessions = self.other_sessions_for(project, session, working.is_some())?;
896        Ok(LoadedWorkingContext {
897            found: working.is_some(),
898            working,
899            other_sessions,
900        })
901    }
902
903    /// The project's OTHER sessions, and what to do when the index that holds
904    /// them cannot be read.
905    ///
906    /// The two answers differ because `other_sessions` plays a different part
907    /// on each path:
908    ///
909    /// - **On a hit** it is a HINT — "you asked for `alpha`, note that
910    ///   `alpha-2` also exists, you may have resumed the wrong one". The
911    ///   answer the caller actually asked for is already in hand and intact.
912    ///   Failing the whole call here would turn a fault in one auxiliary fact
913    ///   into a total loss of resumption for EVERY session of the project,
914    ///   including the many that read back perfectly — which is why an
915    ///   unreadable index degrades to an empty hint instead. Nothing is
916    ///   swallowed: the corruption stays loudly reachable through
917    ///   [`Self::list_working_contexts`], published on every surface.
918    /// - **On a miss** it is the ONLY signal there is. `[]` then reads as the
919    ///   positive assertion "nothing else was ever saved under this project",
920    ///   and an agent told that starts over on top of work sitting right next
921    ///   to where it looked — the exact failure this envelope exists to
922    ///   prevent. An assertion we cannot support must not be manufactured, so
923    ///   the error propagates.
924    ///
925    /// # Errors
926    /// Propagates [`Self::list_working_contexts`]'s errors when `found` is
927    /// false.
928    fn other_sessions_for(
929        &self,
930        project: &str,
931        session: &str,
932        found: bool,
933    ) -> Result<Vec<String>, MemoryError> {
934        let listed = match self.list_working_contexts(project) {
935            Ok(listed) => listed,
936            Err(_) if found => return Ok(Vec::new()),
937            Err(err) => return Err(err),
938        };
939        Ok(listed
940            .into_iter()
941            .map(|entry| entry.session)
942            .filter(|candidate| candidate != session)
943            .collect())
944    }
945
946    /// The sessions of `sessions` whose working-context fact is still there,
947    /// in the same order. One batched metadata lookup for the whole set — not
948    /// a store scan, but not free either (see
949    /// [`Self::list_working_contexts`]'s cost note).
950    ///
951    /// Shared by the read path (filter, persist nothing) and the write path
952    /// (filter, and persist the result), so both agree on what "alive" means.
953    fn live_sessions(
954        &self,
955        project: &str,
956        sessions: Vec<WorkingContextSession>,
957    ) -> Result<Vec<WorkingContextSession>, MemoryError> {
958        if sessions.is_empty() {
959            return Ok(sessions);
960        }
961        let ids: Vec<u64> = sessions
962            .iter()
963            .map(|entry| working_id(project, &entry.session))
964            .collect();
965        let payloads = self.store.get_metadata_batch(&ids)?;
966        if payloads.len() != ids.len() {
967            // The trait promises one result per id. A backend that breaks
968            // that promise must not be silently read as "these sessions are
969            // dead" — that would delete real entries on the write path.
970            return Err(MemoryError::WorkingContextCodec(format!(
971                "storage returned {} metadata rows for {} working-context ids",
972                payloads.len(),
973                ids.len()
974            )));
975        }
976        Ok(sessions
977            .into_iter()
978            .zip(payloads)
979            .filter(|(_, meta)| {
980                meta.as_ref()
981                    .is_some_and(|meta| meta.get(CTX_WORKING_FIELD) == Some(&Value::Bool(true)))
982            })
983            .map(|(entry, _)| entry)
984            .collect())
985    }
986
987    /// Every session still resumable under `project`'s working-context index
988    /// (V2a-1 quick win), most-recently-saved first. Empty when the project
989    /// never saved anything — that, and only that, is the empty case.
990    ///
991    /// Cost: one O(1) index read plus ONE batched metadata lookup of the
992    /// listed ids — never a store scan, but no longer a single read either.
993    /// The lookup is what drops sessions whose fact was forgotten since;
994    /// unlike the previous read-path prune it persists nothing, so a listing
995    /// can be retried and a transient miss costs nothing durable.
996    ///
997    /// # Errors
998    /// Returns a storage error if the index fact cannot be read, or
999    /// [`MemoryError::WorkingContextCodec`] if it does not parse or is
1000    /// corrupt (marked, but with no body).
1001    pub fn list_working_contexts(
1002        &self,
1003        project: &str,
1004    ) -> Result<Vec<WorkingContextSession>, MemoryError> {
1005        let Some(index) = self.working_index(project)? else {
1006            // The genuine "this project never saved anything" case — the only
1007            // one that reaches here now that a corrupt index is an `Err`.
1008            return Ok(Vec::new());
1009        };
1010        let mut sessions = self.live_sessions(project, index.sessions)?;
1011        sessions.sort_by(|a, b| {
1012            b.saved_at
1013                .cmp(&a.saved_at)
1014                .then_with(|| a.session.cmp(&b.session))
1015        });
1016        Ok(sessions)
1017    }
1018
1019    /// The raw working-context index fact for `project`, `None` when nothing
1020    /// was ever saved under it. Symmetric squatter guard to
1021    /// [`Self::load_working_context`]: a slot occupied without the reserved
1022    /// [`CTX_WORKING_INDEX_FIELD`] marker is treated as empty, never as a
1023    /// forged index.
1024    ///
1025    /// `None` means "absent". "Corrupt" is an `Err` — collapsing the two
1026    /// would report a store that lost the index body as a project that never
1027    /// saved anything, and an agent told that starts over instead of raising
1028    /// a problem a human could fix.
1029    fn working_index(&self, project: &str) -> Result<Option<WorkingContextIndex>, MemoryError> {
1030        let slot = working_index_id(project);
1031        let payloads = self.store.get_metadata_batch(&[slot])?;
1032        let marked = payloads
1033            .into_iter()
1034            .next()
1035            .flatten()
1036            .is_some_and(|meta| meta.get(CTX_WORKING_INDEX_FIELD) == Some(&Value::Bool(true)));
1037        if !marked {
1038            return Ok(None);
1039        }
1040        match self.store.get(slot)? {
1041            Some((content, _)) => serde_json::from_str(&content)
1042                .map(Some)
1043                .map_err(|err| MemoryError::WorkingContextCodec(err.to_string())),
1044            None => Err(MemoryError::WorkingContextCodec(format!(
1045                "working-context index for project '{project}' is corrupt: the index \
1046                 marker is present but the stored body is gone"
1047            ))),
1048        }
1049    }
1050
1051    /// Append (or refresh) `session`'s entry in `project`'s working-context
1052    /// index — called by every [`Self::save_working_context`], so the index
1053    /// is always current without a separate maintenance step. A resave of
1054    /// the same project+session updates `saved_at` in place rather than
1055    /// duplicating the entry.
1056    ///
1057    /// This is also where the index CONVERGES: entries whose working-context
1058    /// fact was forgotten since are dropped here, on the write path, under
1059    /// the same lock and in the same read-modify-write that was already
1060    /// paid for. Reads never mutate it.
1061    fn update_working_index(&self, project: &str, session: &str) -> Result<(), MemoryError> {
1062        // The index slot's embedding derives from the PROJECT NAME alone,
1063        // never from the index content, so it is computed here, BEFORE the
1064        // lock: an embedder can be a network round-trip (or a hung one), and
1065        // holding the global write lock across it stalls every working-index
1066        // write in the process behind one slow call. Racing saves may embed
1067        // concurrently, but they embed the same text, so whichever vector
1068        // lands is equivalent — no re-check under the lock is needed. The
1069        // index CONTENT read-modify-write stays entirely under the lock.
1070        let embedding = self
1071            .embedder
1072            .embed(&format!("working context index {project}"))?;
1073        // Read-modify-write of a single shared fact: held for the whole
1074        // sequence, otherwise a concurrent save silently erases this entry.
1075        let _guard = WORKING_INDEX_WRITE.lock();
1076        // A corrupt index must not brick saving for the whole project. The
1077        // read path surfaces the error — that is where a human can act on it
1078        // — but propagating it here would make every future save of every
1079        // session under this project fail forever, with no way back: the
1080        // only writer of the index is this function. Rebuild instead.
1081        let mut index = match self.working_index(project) {
1082            Ok(index) => index.unwrap_or_default(),
1083            Err(MemoryError::WorkingContextCodec(_)) => WorkingContextIndex::default(),
1084            Err(err) => return Err(err),
1085        };
1086        let now = now_unix_secs();
1087        if let Some(entry) = index.sessions.iter_mut().find(|s| s.session == session) {
1088            entry.saved_at = now;
1089        } else {
1090            index.sessions.push(WorkingContextSession {
1091                session: session.to_owned(),
1092                saved_at: now,
1093            });
1094        }
1095        // The entry just appended is alive by construction (its fact was
1096        // stored moments ago, before this call); this only sheds the ones a
1097        // `forget` orphaned.
1098        index.sessions = self.live_sessions(project, index.sessions)?;
1099        let content = serde_json::to_string(&index)
1100            .map_err(|err| MemoryError::WorkingContextCodec(err.to_string()))?;
1101        self.write_working_index(project, &content, &embedding)
1102    }
1103
1104    /// Persist a serialized index into `project`'s reserved index slot —
1105    /// always with the [`CTX_WORKING_INDEX_FIELD`] marker, since an index
1106    /// written without it would be treated as a squatter and read back as
1107    /// empty. Only [`Self::update_working_index`] (which holds
1108    /// [`WORKING_INDEX_WRITE`] and supplies the slot `embedding` it computed
1109    /// before taking that lock) calls this: nothing in here may call the
1110    /// embedder, or the lock would again be held across a network hop.
1111    fn write_working_index(
1112        &self,
1113        project: &str,
1114        content: &str,
1115        embedding: &[f32],
1116    ) -> Result<(), MemoryError> {
1117        let slot = working_index_id(project);
1118        let meta = system_meta(&[
1119            (CTX_WORKING_INDEX_FIELD, Value::Bool(true)),
1120            (CTX_PROJECT_FIELD, Value::String(project.to_owned())),
1121        ]);
1122        self.store_fact(slot, content, embedding, Some(&meta), None)?;
1123        Ok(())
1124    }
1125}
1126
1127/// How many memories a scope pulls when it does not say (`k` absent).
1128const DEFAULT_MEMORY_K: usize = 5;
1129
1130/// The request's memory scope plus the clamped pull count — `None` when
1131/// there is no scope or no room: pulled memories must never push the
1132/// request over the fragment cap (the cap is validated after augmentation,
1133/// and a rejection there would blame the caller for fragments the bridge
1134/// itself added).
1135fn scope_and_k(request: &CompileRequest) -> Option<(&MemoryScope, usize)> {
1136    let scope = request.memory_scope.as_ref()?;
1137    let room = crate::limits::MAX_FRAGMENTS.saturating_sub(request.fragments.len());
1138    let k = crate::limits::clamp_recall_limit(scope.k.unwrap_or(DEFAULT_MEMORY_K)).min(room);
1139    (k > 0).then_some((scope, k))
1140}
1141
1142/// The recall filter a scope narrows to (its project facet), if any.
1143fn scope_filter(scope: &MemoryScope) -> Option<Metadata> {
1144    scope.project.as_ref().map(|project| {
1145        let mut meta = Map::new();
1146        meta.insert("project".to_owned(), Value::String(project.clone()));
1147        meta
1148    })
1149}
1150
1151/// One memory the scope pulled in, with its full ranking ventilation.
1152struct PulledMemory {
1153    fragment: ContextFragment,
1154    memory_id: u64,
1155    /// Fused score normalised over the pulled batch, in `[0, 1]` — the
1156    /// importance-blended key (clamped) when the blend is active.
1157    relevance: f32,
1158    /// Normalised vector term of the fused score.
1159    vector_norm: f64,
1160    /// Graph promotion weight of the fused score.
1161    graph_weight: f64,
1162    /// Learned RL confidence the blend used (neutral `0.5` when the memory
1163    /// never received feedback).
1164    confidence: f64,
1165    /// Batch-relative recency contribution in `[0, 1]` (`0` when the term
1166    /// is inactive, the key is absent, or the batch is degenerate).
1167    recency: f64,
1168    /// Whether the importance blend ran — drives the extended four-signal
1169    /// reason ventilation; `false` keeps the exact 0.8.0 reason bytes.
1170    ventilated: bool,
1171}
1172
1173/// A selected memory before the importance blend: its similarity base, its
1174/// fused ventilation, and the caller-visible metadata the recency term reads.
1175struct MemoryCandidate {
1176    memory_id: u64,
1177    /// Fused-normalised (or rank-based) similarity in `[0, 1]`.
1178    base: f64,
1179    vector_norm: f64,
1180    graph_weight: f64,
1181    metadata: Option<Metadata>,
1182    content: String,
1183}
1184
1185impl MemoryCandidate {
1186    /// The unblended [`PulledMemory`] — bytes identical to the 0.8.0 pull.
1187    fn into_pulled(self) -> PulledMemory {
1188        #[allow(clippy::cast_possible_truncation)] // base is clamped into [0, 1]
1189        let relevance = self.base as f32;
1190        PulledMemory {
1191            fragment: ContextFragment {
1192                id: None,
1193                content: self.content,
1194                path: None,
1195                kind: Some("memory".to_owned()),
1196                priority: None,
1197                metadata: None,
1198                media: None,
1199            },
1200            memory_id: self.memory_id,
1201            relevance,
1202            vector_norm: self.vector_norm,
1203            graph_weight: self.graph_weight,
1204            confidence: NEUTRAL_CONFIDENCE,
1205            recency: 0.0,
1206            ventilated: false,
1207        }
1208    }
1209}
1210
1211/// The neutral confidence of a memory with no feedback history — mirrors
1212/// `reinforce::RL_NEUTRAL_CONFIDENCE`, whose module is `persistence`-gated:
1213/// its contribution to the blend is exactly `0`.
1214const NEUTRAL_CONFIDENCE: f64 = 0.5;
1215
1216/// The learned RL confidence off a raw payload, in `[0, 1]`. Without the
1217/// `persistence` feature the RL module (and thus `feedback`) does not exist,
1218/// so every memory reads neutral.
1219#[cfg(feature = "persistence")]
1220fn payload_confidence(payload: Option<&Metadata>) -> f64 {
1221    f64::from(payload.map_or(
1222        super::reinforce::RL_NEUTRAL_CONFIDENCE,
1223        super::reinforce::read_confidence,
1224    ))
1225}
1226
1227/// See the `persistence` twin: no RL module, always neutral.
1228#[cfg(not(feature = "persistence"))]
1229fn payload_confidence(_payload: Option<&Metadata>) -> f64 {
1230    NEUTRAL_CONFIDENCE
1231}
1232
1233/// Whether the policy's importance weights change anything at all: a
1234/// non-zero confidence weight, or a non-zero recency weight WITH a field to
1235/// read. Zero weights must cost nothing and change nothing (0.8.0 parity).
1236#[allow(
1237    clippy::float_cmp,
1238    reason = "an exact zero weight is the documented off switch; any non-zero weight, however small, is active"
1239)]
1240fn importance_active(weights: &ImportanceWeights) -> bool {
1241    weights.confidence != 0.0 || (weights.recency != 0.0 && weights.recency_field.is_some())
1242}
1243
1244/// The batch-relative recency contribution of every candidate, in `[0, 1]`:
1245/// min-max over the candidates that carry the policy's `recency_field` as a
1246/// number (one monotone scale per batch — `YYYYMMDD` or an epoch, the
1247/// caller's choice). A candidate without the key contributes `0` (never
1248/// penalised), and a degenerate batch (`max == min`) contributes `0` for
1249/// all. No clock: recency is relative to the newest of the batch.
1250#[allow(
1251    clippy::float_cmp,
1252    reason = "an exact zero weight is the documented off switch for the recency term"
1253)]
1254fn recency_norms(candidates: &[MemoryCandidate], weights: &ImportanceWeights) -> Vec<f64> {
1255    let field = weights
1256        .recency_field
1257        .as_ref()
1258        .filter(|_| weights.recency != 0.0);
1259    let Some(field) = field else {
1260        return vec![0.0; candidates.len()];
1261    };
1262    let values: Vec<Option<f64>> = candidates
1263        .iter()
1264        .map(|candidate| {
1265            candidate
1266                .metadata
1267                .as_ref()
1268                .and_then(|meta| meta.get(field.as_str()))
1269                .and_then(Value::as_f64)
1270                .filter(|value| value.is_finite())
1271        })
1272        .collect();
1273    let (min, max) = values
1274        .iter()
1275        .flatten()
1276        .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &v| {
1277            (lo.min(v), hi.max(v))
1278        });
1279    if max <= min {
1280        return vec![0.0; candidates.len()];
1281    }
1282    values
1283        .into_iter()
1284        .map(|value| value.map_or(0.0, |v| ((v - min) / (max - min)).clamp(0.0, 1.0)))
1285        .collect()
1286}
1287
1288/// Stamp pulled memories into the compiled provenance: their decisions and
1289/// sources gain the backing `memory_id`, the decision's relevance becomes
1290/// the normalised (importance-blended, when active) ranking score, and the
1291/// reason spells out the full score ventilation — vector and graph always,
1292/// plus confidence and recency when the blend ran — so `why this memory` is
1293/// answerable from the decision alone.
1294fn annotate_memory_provenance(out: &mut CompiledContext, pulled: &BTreeMap<u64, PulledMemory>) {
1295    for decision in &mut out.decisions {
1296        if let Some(memory) = pulled.get(&decision.content_hash) {
1297            decision.memory_id = Some(memory.memory_id);
1298            decision.relevance = memory.relevance;
1299            decision.reason = if memory.ventilated {
1300                format!(
1301                    "{} — pulled from memory {} (vector {:.2}, graph {:.2}, confidence {:.2}, recency {:.2})",
1302                    decision.reason,
1303                    memory.memory_id,
1304                    memory.vector_norm,
1305                    memory.graph_weight,
1306                    memory.confidence,
1307                    memory.recency
1308                )
1309            } else {
1310                format!(
1311                    "{} — pulled from memory {} (vector {:.2}, graph {:.2})",
1312                    decision.reason, memory.memory_id, memory.vector_norm, memory.graph_weight
1313                )
1314            };
1315        }
1316    }
1317    for source in &mut out.sources {
1318        if let Some(hash) = provenance::parse_handle(&source.handle) {
1319            if let Some(memory) = pulled.get(&hash) {
1320                source.memory_id = Some(memory.memory_id);
1321            }
1322        }
1323    }
1324}
1325
1326/// Base metadata of every bridge-stored system fact: hub-marked (invisible
1327/// to normal recall) plus the given extra keys.
1328fn system_meta(extra: &[(&str, Value)]) -> Metadata {
1329    let mut meta = Map::new();
1330    meta.insert(HUB_FIELD.to_owned(), Value::Bool(true));
1331    for (key, value) in extra {
1332        meta.insert((*key).to_owned(), value.clone());
1333    }
1334    meta
1335}
1336
1337/// The metadata of one compilation event — counts and identifiers only,
1338/// every key reserved.
1339fn event_meta(request: &CompileRequest, out: &CompiledContext, nanos: u128) -> Metadata {
1340    let mut extra: Vec<(&str, Value)> = vec![
1341        (CTX_EVENT_FIELD, Value::Bool(true)),
1342        (
1343            CTX_TOKENS_IN_FIELD,
1344            Value::Number(out.insights.tokens_in.into()),
1345        ),
1346        (
1347            CTX_TOKENS_OUT_FIELD,
1348            Value::Number(out.insights.tokens_out.into()),
1349        ),
1350        (
1351            CTX_TOKENS_SAVED_FIELD,
1352            Value::Number(out.insights.tokens_saved.into()),
1353        ),
1354        (
1355            CTX_AT_FIELD,
1356            Value::Number(Number::from(
1357                u64::try_from(nanos / 1_000_000_000).unwrap_or(u64::MAX),
1358            )),
1359        ),
1360    ];
1361    if let Some(project) = &request.project {
1362        extra.push((CTX_PROJECT_FIELD, Value::String(project.clone())));
1363    }
1364    if let Some(model) = &request.target_model {
1365        extra.push((CTX_MODEL_FIELD, Value::String(model.clone())));
1366    }
1367    if let (Some(micros), Some(currency)) = (
1368        out.insights.estimated_cost_saved_micros,
1369        out.insights.currency.as_ref(),
1370    ) {
1371        extra.push((CTX_COST_FIELD, Value::Number(micros.into())));
1372        extra.push((CTX_CURRENCY_FIELD, Value::String(currency.clone())));
1373    }
1374    system_meta(&extra)
1375}
1376
1377/// Fold raw event payloads (reserved keys included) into one
1378/// [`ContextSavings`]. Every accumulation saturates — an aggregate must
1379/// never panic, whatever the stored numbers.
1380fn aggregate_events(payloads: &[Option<Metadata>]) -> ContextSavings {
1381    let mut savings = ContextSavings {
1382        events: payloads.len() as u64,
1383        truncated: payloads.len() >= crate::limits::MAX_RECALL_LIMIT,
1384        ..ContextSavings::default()
1385    };
1386    for payload in payloads {
1387        let Some(meta) = payload else { continue };
1388        savings.tokens_in = savings
1389            .tokens_in
1390            .saturating_add(meta_u64(meta, CTX_TOKENS_IN_FIELD));
1391        savings.tokens_out = savings
1392            .tokens_out
1393            .saturating_add(meta_u64(meta, CTX_TOKENS_OUT_FIELD));
1394        savings.tokens_saved = savings
1395            .tokens_saved
1396            .saturating_add(meta_u64(meta, CTX_TOKENS_SAVED_FIELD));
1397        if let (Some(Value::String(currency)), micros) =
1398            (meta.get(CTX_CURRENCY_FIELD), meta_u64(meta, CTX_COST_FIELD))
1399        {
1400            if micros > 0 {
1401                let entry = savings
1402                    .cost_saved_micros_by_currency
1403                    .entry(currency.clone())
1404                    .or_insert(0);
1405                *entry = entry.saturating_add(micros);
1406            }
1407        }
1408    }
1409    savings
1410}
1411
1412/// A `u64` metadata field, `0` when absent or non-numeric.
1413fn meta_u64(meta: &Metadata, key: &str) -> u64 {
1414    meta.get(key).and_then(Value::as_u64).unwrap_or(0)
1415}
1416
1417/// The salted system-fact id of a stored source.
1418fn source_id(content_hash: u64) -> u64 {
1419    stable_id(&format!("{SOURCE_ID_SALT}{content_hash}"))
1420}
1421
1422/// The handle-identity hash of one request fragment — the bridge-side twin
1423/// of `Analysis::handle_hash` in `context.rs` (kept in lockstep; the two
1424/// must key the same identity or stored slots and minted handles drift
1425/// apart): raw decoded media bytes for a media fragment, caption/content
1426/// [`stable_id`] otherwise.
1427fn fragment_handle_hash(fragment: &ContextFragment) -> u64 {
1428    fragment.media.as_ref().map_or_else(
1429        || stable_id(&fragment.content),
1430        |media_ref| media::analyze(media_ref).raw_hash,
1431    )
1432}
1433
1434/// Index a request's fragments by the hash their `ctx://source/` handle is
1435/// built from, so a handle can be resolved back to the fragment that produced
1436/// it. First occurrence wins (see the identity note on
1437/// `store_context_sources`): `entry` + `or_insert`, never a blind overwrite.
1438fn index_fragments_by_handle_hash(
1439    fragments: &[ContextFragment],
1440) -> BTreeMap<u64, &ContextFragment> {
1441    let mut by_hash: BTreeMap<u64, &ContextFragment> = BTreeMap::new();
1442    for fragment in fragments {
1443        by_hash
1444            .entry(fragment_handle_hash(fragment))
1445            .or_insert(fragment);
1446    }
1447    by_hash
1448}
1449
1450/// A stored source's media payload (US-009, PR2), when its metadata carries
1451/// one — absent (or malformed, which should never happen for a payload this
1452/// bridge wrote itself) round-trips as `None` rather than an error, so a
1453/// media decode hiccup degrades to "text-only", never breaks the whole
1454/// retrieval.
1455fn source_media(meta: &Metadata) -> Option<MediaRef> {
1456    meta.get(CTX_SOURCE_MEDIA_FIELD)
1457        .cloned()
1458        .and_then(|value| serde_json::from_value(value).ok())
1459}
1460
1461/// The salted, deterministic system-fact id of a working context.
1462fn working_id(project: &str, session: &str) -> u64 {
1463    stable_id(&format!("{WORKING_ID_SALT}{project}\u{1f}{session}"))
1464}
1465
1466/// The salted, deterministic system-fact id of a project's working-context
1467/// index — one per project, so every save updates the same slot.
1468fn working_index_id(project: &str) -> u64 {
1469    stable_id(&format!("{WORKING_INDEX_ID_SALT}{project}"))
1470}
1471
1472#[cfg(all(test, feature = "persistence"))]
1473#[path = "memory_bridge_tests.rs"]
1474mod tests;