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::{FactStore, GraphStore, RecallStore};
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: intra-process — which is the WHOLE problem (#1958).** An earlier
150/// version of this comment claimed two processes opening the same store
151/// still race past this lock. They cannot: `velesdb-core`'s
152/// `Database::open_impl` takes an exclusive `flock` on `velesdb.lock` at
153/// open and holds it for the `Database`'s entire lifetime — not per write —
154/// so a second process fails at `open` with `DatabaseLocked` before it can
155/// reach any read-modify-write, of this index or of anything else. Proven
156/// with real processes by `tests/http_lock_contention.rs` and
157/// `tests/working_index_two_daemons_process.rs` (the latter is #1958's
158/// success criterion verbatim: sessions saved under contention and across a
159/// process handoff, zero index entries lost). A trait-level compare-and-swap
160/// was considered there and declined: flock is the only cross-process
161/// primitive available here, and the store boundary already holds it — a
162/// second one around the index would guard against a concurrency the first
163/// makes unreachable. This mutex therefore covers the only concurrency that
164/// exists: threads of the one process allowed to hold the store (the MCP
165/// server's `spawn_blocking` handlers are exactly what made it reachable).
166///
167/// One global lock rather than one per project: index writes are rare (one
168/// per `save_working_context`), so the contention is negligible, whereas a
169/// `HashMap<String, _>` keyed by caller-supplied project names is an unbounded
170/// slow leak for no measurable gain. Per-project striping is the obvious
171/// upgrade if index writes ever become hot.
172static WORKING_INDEX_WRITE: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
173
174/// The compilation half — `compile_context` and its helpers; see
175/// `memory_bridge_compile.rs`'s module doc for why it is split.
176#[path = "memory_bridge_compile.rs"]
177mod compile;
178
179impl<E: Embedder, S: FactStore> MemoryService<E, S> {
180    /// Persist `working` under `project` + `session` (idempotent upsert:
181    /// saving again replaces the previous state). Returns the system fact id.
182    ///
183    /// Serialized size is capped at [`crate::limits::MAX_FACT_BYTES`] (1
184    /// MiB) — the same ceiling every other stored fact honors — checked
185    /// BEFORE anything is written, so an oversized working context is never
186    /// partially stored.
187    ///
188    /// An entirely empty `working` ([`WorkingContext::is_empty`]) is refused.
189    /// Because the write is an upsert, saving one would replace — destroy —
190    /// the state a previous save stored under the same project and session,
191    /// and the one tool whose job is surviving a context loss must not be
192    /// able to cause one on a call that carries nothing (issue #1654).
193    ///
194    /// # Errors
195    /// Returns [`MemoryError::EmptyWorkingContext`] if `working` records
196    /// nothing, [`MemoryError::WorkingContextCodec`] if serialization fails,
197    /// [`MemoryError::ContextOverLimit`] if the serialized `working` exceeds
198    /// [`crate::limits::MAX_FACT_BYTES`], or a storage/embedding error.
199    pub fn save_working_context(
200        &self,
201        project: &str,
202        session: &str,
203        working: &WorkingContext,
204    ) -> Result<u64, MemoryError> {
205        let _generation = self.enter_generation();
206        if working.is_empty() {
207            return Err(MemoryError::EmptyWorkingContext);
208        }
209        let content =
210            serde_json::to_string(working).map_err(|err| MemoryError::WorkingContextCodec {
211                detail: "encoding the working context for storage".to_owned(),
212                source: Some(Box::new(err)),
213            })?;
214        if content.len() > crate::limits::MAX_FACT_BYTES {
215            return Err(MemoryError::ContextOverLimit(format!(
216                "working context of {} bytes exceeds the cap of {} bytes",
217                content.len(),
218                crate::limits::MAX_FACT_BYTES
219            )));
220        }
221        let id = working_id(project, session);
222        let embedding = self
223            .embedder
224            .embed(&format!("working context {project} {session}"))?;
225        let meta = system_meta(&[
226            (CTX_WORKING_FIELD, Value::Bool(true)),
227            (CTX_PROJECT_FIELD, Value::String(project.to_owned())),
228            (CTX_SESSION_FIELD, Value::String(session.to_owned())),
229        ]);
230        self.store_fact(id, &content, &embedding, Some(&meta), None)?;
231        self.update_working_index(project, session)?;
232        Ok(id)
233    }
234
235    /// The working context previously saved under `project` + `session`,
236    /// `None` when there is none.
237    ///
238    /// Symmetric to [`Self::context_source_metadata`]'s squatter guard: the
239    /// slot is only ever served back when its metadata carries the reserved
240    /// [`CTX_WORKING_FIELD`] marker (set exclusively by
241    /// [`Self::save_working_context`]). A slot occupied by an unmarked caller
242    /// fact — one that happened to land on this salted id, or a forged
243    /// probe — is indistinguishable from "nothing saved" on purpose: `None`,
244    /// never the forged content, and never an error (the caller cannot tell
245    /// a squatted slot from a genuinely empty one, which is the point — it
246    /// must never learn that *something* occupies this id).
247    ///
248    /// A pure read: it never writes, never prunes, never heals. Index
249    /// convergence happens on the WRITE path
250    /// ([`Self::update_working_index`]) — a lookup that rewrites shared state
251    /// turns every transient miss into permanent data loss and cannot safely
252    /// be retried.
253    ///
254    /// # Errors
255    /// Returns [`MemoryError::WorkingContextCodec`] if the stored payload
256    /// does not parse, or if the slot is marked but its body is gone (a torn
257    /// fact is corruption — reporting it as "nothing saved" would tell the
258    /// caller the one thing that is certainly false), or a storage error.
259    pub fn load_working_context(
260        &self,
261        project: &str,
262        session: &str,
263    ) -> Result<Option<WorkingContext>, MemoryError> {
264        let _generation = self.enter_generation();
265        self.load_working_context_inner(project, session)
266    }
267
268    fn load_working_context_inner(
269        &self,
270        project: &str,
271        session: &str,
272    ) -> Result<Option<WorkingContext>, MemoryError> {
273        let slot = working_id(project, session);
274        let payloads = self.store.get_metadata_batch(&[slot])?;
275        let marked = payloads
276            .into_iter()
277            .next()
278            .flatten()
279            .is_some_and(|meta| meta.get(CTX_WORKING_FIELD) == Some(&Value::Bool(true)));
280        if !marked {
281            // The squatter/never-saved guard documented above: silent by
282            // design, and the branch a `forget` lands on (deleting a fact
283            // removes its metadata with it).
284            return Ok(None);
285        }
286        let Some((content, _)) = self.store.get(slot)? else {
287            return Err(MemoryError::WorkingContextCodec {
288                detail: format!(
289                    "working context for project '{project}', session '{session}' is corrupt: \
290                     the reserved marker is present but the stored body is gone"
291                ),
292                source: None,
293            });
294        };
295        serde_json::from_str(&content)
296            .map(Some)
297            .map_err(|err| MemoryError::WorkingContextCodec {
298                detail: format!(
299                    "decoding the stored working context for project '{project}', \
300                     session '{session}'"
301                ),
302                source: Some(Box::new(err)),
303            })
304    }
305
306    /// The full resumption envelope for `project` + `session`: what
307    /// [`Self::load_working_context`] found, plus the OTHER sessions saved
308    /// under the same project so a typo in `session` is recoverable.
309    ///
310    /// This is the ONE place the three policy rules live:
311    ///
312    /// 1. `other_sessions` is listed on a HIT too, not just on a miss — a
313    ///    typo that lands on another REAL session returns `found: true`, and
314    ///    the caller has no other way to notice it resumed the wrong work.
315    ///    Costs one extra O(1) index read per successful load.
316    /// 2. The requested `session` is never echoed back: the field is named
317    ///    `other_sessions`, so returning the requested id would be a
318    ///    contradiction the caller cannot act on.
319    /// 3. An unreadable index is fatal on a MISS and survivable on a HIT —
320    ///    see [`Self::other_sessions_for`].
321    ///
322    /// Every surface (the `load_working_context` MCP tool and the Node,
323    /// Python and WASM bindings) calls this rather than recomposing the
324    /// envelope from [`Self::load_working_context`] +
325    /// [`Self::list_working_contexts`]: four recompositions are four copies
326    /// of those rules, and a copy that stops matching the others fails
327    /// silently — the caller still gets a well-formed envelope, just a
328    /// different one.
329    ///
330    /// # Errors
331    /// Propagates [`Self::load_working_context`]'s errors (a corrupt or
332    /// unparseable stored payload), and [`Self::list_working_contexts`]'s (a
333    /// corrupt index, or a storage failure) **on a miss only** — rule 3.
334    pub fn resume_working_context(
335        &self,
336        project: &str,
337        session: &str,
338    ) -> Result<LoadedWorkingContext, MemoryError> {
339        let _generation = self.enter_generation();
340        let working = self.load_working_context_inner(project, session)?;
341        let other_sessions = self.other_sessions_for(project, session, working.is_some())?;
342        Ok(LoadedWorkingContext {
343            found: working.is_some(),
344            working,
345            other_sessions,
346        })
347    }
348
349    /// The project's OTHER sessions, and what to do when the index that holds
350    /// them cannot be read.
351    ///
352    /// The two answers differ because `other_sessions` plays a different part
353    /// on each path:
354    ///
355    /// - **On a hit** it is a HINT — "you asked for `alpha`, note that
356    ///   `alpha-2` also exists, you may have resumed the wrong one". The
357    ///   answer the caller actually asked for is already in hand and intact.
358    ///   Failing the whole call here would turn a fault in one auxiliary fact
359    ///   into a total loss of resumption for EVERY session of the project,
360    ///   including the many that read back perfectly — which is why an
361    ///   unreadable index degrades to an empty hint instead. Nothing is
362    ///   swallowed: the corruption stays loudly reachable through
363    ///   [`Self::list_working_contexts`], published on every surface.
364    /// - **On a miss** it is the ONLY signal there is. `[]` then reads as the
365    ///   positive assertion "nothing else was ever saved under this project",
366    ///   and an agent told that starts over on top of work sitting right next
367    ///   to where it looked — the exact failure this envelope exists to
368    ///   prevent. An assertion we cannot support must not be manufactured, so
369    ///   the error propagates.
370    ///
371    /// # Errors
372    /// Propagates [`Self::list_working_contexts`]'s errors when `found` is
373    /// false.
374    fn other_sessions_for(
375        &self,
376        project: &str,
377        session: &str,
378        found: bool,
379    ) -> Result<Vec<String>, MemoryError> {
380        let listed = match self.list_working_contexts_inner(project) {
381            Ok(listed) => listed,
382            Err(_) if found => return Ok(Vec::new()),
383            Err(err) => return Err(err),
384        };
385        Ok(listed
386            .into_iter()
387            .map(|entry| entry.session)
388            .filter(|candidate| candidate != session)
389            .collect())
390    }
391
392    /// The sessions of `sessions` whose working-context fact is still there,
393    /// in the same order. One batched metadata lookup for the whole set — not
394    /// a store scan, but not free either (see
395    /// [`Self::list_working_contexts`]'s cost note).
396    ///
397    /// Shared by the read path (filter, persist nothing) and the write path
398    /// (filter, and persist the result), so both agree on what "alive" means.
399    fn live_sessions(
400        &self,
401        project: &str,
402        sessions: Vec<WorkingContextSession>,
403    ) -> Result<Vec<WorkingContextSession>, MemoryError> {
404        if sessions.is_empty() {
405            return Ok(sessions);
406        }
407        let ids: Vec<u64> = sessions
408            .iter()
409            .map(|entry| working_id(project, &entry.session))
410            .collect();
411        let payloads = self.store.get_metadata_batch(&ids)?;
412        if payloads.len() != ids.len() {
413            // The trait promises one result per id. A backend that breaks
414            // that promise must not be silently read as "these sessions are
415            // dead" — that would delete real entries on the write path.
416            return Err(MemoryError::WorkingContextCodec {
417                detail: format!(
418                    "storage returned {} metadata rows for {} working-context ids",
419                    payloads.len(),
420                    ids.len()
421                ),
422                source: None,
423            });
424        }
425        Ok(sessions
426            .into_iter()
427            .zip(payloads)
428            .filter(|(_, meta)| {
429                meta.as_ref()
430                    .is_some_and(|meta| meta.get(CTX_WORKING_FIELD) == Some(&Value::Bool(true)))
431            })
432            .map(|(entry, _)| entry)
433            .collect())
434    }
435
436    /// Every session still resumable under `project`'s working-context index
437    /// (V2a-1 quick win), most-recently-saved first. Empty when the project
438    /// never saved anything — that, and only that, is the empty case.
439    ///
440    /// Cost: one O(1) index read plus ONE batched metadata lookup of the
441    /// listed ids — never a store scan, but no longer a single read either.
442    /// The lookup is what drops sessions whose fact was forgotten since;
443    /// unlike the previous read-path prune it persists nothing, so a listing
444    /// can be retried and a transient miss costs nothing durable.
445    ///
446    /// # Errors
447    /// Returns a storage error if the index fact cannot be read, or
448    /// [`MemoryError::WorkingContextCodec`] if it does not parse or is
449    /// corrupt (marked, but with no body).
450    pub fn list_working_contexts(
451        &self,
452        project: &str,
453    ) -> Result<Vec<WorkingContextSession>, MemoryError> {
454        let _generation = self.enter_generation();
455        self.list_working_contexts_inner(project)
456    }
457
458    fn list_working_contexts_inner(
459        &self,
460        project: &str,
461    ) -> Result<Vec<WorkingContextSession>, MemoryError> {
462        let Some(index) = self.working_index(project)? else {
463            // The genuine "this project never saved anything" case — the only
464            // one that reaches here now that a corrupt index is an `Err`.
465            return Ok(Vec::new());
466        };
467        let mut sessions = self.live_sessions(project, index.sessions)?;
468        sessions.sort_by(|a, b| {
469            b.saved_at
470                .cmp(&a.saved_at)
471                .then_with(|| a.session.cmp(&b.session))
472        });
473        Ok(sessions)
474    }
475
476    /// The raw working-context index fact for `project`, `None` when nothing
477    /// was ever saved under it. Symmetric squatter guard to
478    /// [`Self::load_working_context`]: a slot occupied without the reserved
479    /// [`CTX_WORKING_INDEX_FIELD`] marker is treated as empty, never as a
480    /// forged index.
481    ///
482    /// `None` means "absent". "Corrupt" is an `Err` — collapsing the two
483    /// would report a store that lost the index body as a project that never
484    /// saved anything, and an agent told that starts over instead of raising
485    /// a problem a human could fix.
486    fn working_index(&self, project: &str) -> Result<Option<WorkingContextIndex>, MemoryError> {
487        let slot = working_index_id(project);
488        let payloads = self.store.get_metadata_batch(&[slot])?;
489        let marked = payloads
490            .into_iter()
491            .next()
492            .flatten()
493            .is_some_and(|meta| meta.get(CTX_WORKING_INDEX_FIELD) == Some(&Value::Bool(true)));
494        if !marked {
495            return Ok(None);
496        }
497        match self.store.get(slot)? {
498            Some((content, _)) => serde_json::from_str(&content).map(Some).map_err(|err| {
499                MemoryError::WorkingContextCodec {
500                    detail: format!("decoding the working-context index for project '{project}'"),
501                    source: Some(Box::new(err)),
502                }
503            }),
504            None => Err(MemoryError::WorkingContextCodec {
505                detail: format!(
506                    "working-context index for project '{project}' is corrupt: the index \
507                     marker is present but the stored body is gone"
508                ),
509                source: None,
510            }),
511        }
512    }
513
514    /// Append (or refresh) `session`'s entry in `project`'s working-context
515    /// index — called by every [`Self::save_working_context`], so the index
516    /// is always current without a separate maintenance step. A resave of
517    /// the same project+session updates `saved_at` in place rather than
518    /// duplicating the entry.
519    ///
520    /// This is also where the index CONVERGES: entries whose working-context
521    /// fact was forgotten since are dropped here, on the write path, under
522    /// the same lock and in the same read-modify-write that was already
523    /// paid for. Reads never mutate it.
524    fn update_working_index(&self, project: &str, session: &str) -> Result<(), MemoryError> {
525        // The index slot's embedding derives from the PROJECT NAME alone,
526        // never from the index content, so it is computed here, BEFORE the
527        // lock: an embedder can be a network round-trip (or a hung one), and
528        // holding the global write lock across it stalls every working-index
529        // write in the process behind one slow call. Racing saves may embed
530        // concurrently, but they embed the same text, so whichever vector
531        // lands is equivalent — no re-check under the lock is needed. The
532        // index CONTENT read-modify-write stays entirely under the lock.
533        let embedding = self
534            .embedder
535            .embed(&format!("working context index {project}"))?;
536        // Read-modify-write of a single shared fact: held for the whole
537        // sequence, otherwise a concurrent save silently erases this entry.
538        let _guard = WORKING_INDEX_WRITE.lock();
539        // A corrupt index must not brick saving for the whole project. The
540        // read path surfaces the error — that is where a human can act on it
541        // — but propagating it here would make every future save of every
542        // session under this project fail forever, with no way back: the
543        // only writer of the index is this function. Rebuild instead.
544        let mut index = match self.working_index(project) {
545            Ok(index) => index.unwrap_or_default(),
546            Err(MemoryError::WorkingContextCodec { .. }) => WorkingContextIndex::default(),
547            Err(err) => return Err(err),
548        };
549        let now = now_unix_secs();
550        if let Some(entry) = index.sessions.iter_mut().find(|s| s.session == session) {
551            entry.saved_at = now;
552        } else {
553            index.sessions.push(WorkingContextSession {
554                session: session.to_owned(),
555                saved_at: now,
556            });
557        }
558        // The entry just appended is alive by construction (its fact was
559        // stored moments ago, before this call); this only sheds the ones a
560        // `forget` orphaned.
561        index.sessions = self.live_sessions(project, index.sessions)?;
562        let content =
563            serde_json::to_string(&index).map_err(|err| MemoryError::WorkingContextCodec {
564                detail: format!("encoding the working-context index for project '{project}'"),
565                source: Some(Box::new(err)),
566            })?;
567        self.write_working_index(project, &content, &embedding)
568    }
569
570    /// Persist a serialized index into `project`'s reserved index slot —
571    /// always with the [`CTX_WORKING_INDEX_FIELD`] marker, since an index
572    /// written without it would be treated as a squatter and read back as
573    /// empty. Only [`Self::update_working_index`] (which holds
574    /// [`WORKING_INDEX_WRITE`] and supplies the slot `embedding` it computed
575    /// before taking that lock) calls this: nothing in here may call the
576    /// embedder, or the lock would again be held across a network hop.
577    fn write_working_index(
578        &self,
579        project: &str,
580        content: &str,
581        embedding: &[f32],
582    ) -> Result<(), MemoryError> {
583        let slot = working_index_id(project);
584        let meta = system_meta(&[
585            (CTX_WORKING_INDEX_FIELD, Value::Bool(true)),
586            (CTX_PROJECT_FIELD, Value::String(project.to_owned())),
587        ]);
588        self.store_fact(slot, content, embedding, Some(&meta), None)?;
589        Ok(())
590    }
591}
592
593/// How many memories a scope pulls when it does not say (`k` absent).
594const DEFAULT_MEMORY_K: usize = 5;
595
596/// The request's memory scope plus the clamped pull count — `None` when
597/// there is no scope or no room: pulled memories must never push the
598/// request over the fragment cap (the cap is validated after augmentation,
599/// and a rejection there would blame the caller for fragments the bridge
600/// itself added).
601fn scope_and_k(request: &CompileRequest) -> Option<(&MemoryScope, usize)> {
602    let scope = request.memory_scope.as_ref()?;
603    let room = crate::limits::MAX_FRAGMENTS.saturating_sub(request.fragments.len());
604    let k = crate::limits::clamp_recall_limit(scope.k.unwrap_or(DEFAULT_MEMORY_K)).min(room);
605    (k > 0).then_some((scope, k))
606}
607
608/// The recall filter a scope narrows to (its project facet), if any.
609fn scope_filter(scope: &MemoryScope) -> Option<Metadata> {
610    scope.project.as_ref().map(|project| {
611        let mut meta = Map::new();
612        meta.insert("project".to_owned(), Value::String(project.clone()));
613        meta
614    })
615}
616
617/// One memory the scope pulled in, with its full ranking ventilation.
618struct PulledMemory {
619    fragment: ContextFragment,
620    memory_id: u64,
621    /// Fused score normalised over the pulled batch, in `[0, 1]` — the
622    /// importance-blended key (clamped) when the blend is active.
623    relevance: f32,
624    /// Normalised vector term of the fused score.
625    vector_norm: f64,
626    /// Graph promotion weight of the fused score.
627    graph_weight: f64,
628    /// Learned RL confidence the blend used (neutral `0.5` when the memory
629    /// never received feedback).
630    confidence: f64,
631    /// Batch-relative recency contribution in `[0, 1]` (`0` when the term
632    /// is inactive, the key is absent, or the batch is degenerate).
633    recency: f64,
634    /// Whether the importance blend ran — drives the extended four-signal
635    /// reason ventilation; `false` keeps the exact 0.8.0 reason bytes.
636    ventilated: bool,
637}
638
639/// A selected memory before the importance blend: its similarity base, its
640/// fused ventilation, and the caller-visible metadata the recency term reads.
641struct MemoryCandidate {
642    memory_id: u64,
643    /// Fused-normalised (or rank-based) similarity in `[0, 1]`.
644    base: f64,
645    vector_norm: f64,
646    graph_weight: f64,
647    metadata: Option<Metadata>,
648    content: String,
649}
650
651impl MemoryCandidate {
652    /// The unblended [`PulledMemory`] — bytes identical to the 0.8.0 pull.
653    fn into_pulled(self) -> PulledMemory {
654        #[allow(clippy::cast_possible_truncation)] // base is clamped into [0, 1]
655        let relevance = self.base as f32;
656        PulledMemory {
657            fragment: ContextFragment {
658                id: None,
659                content: self.content,
660                path: None,
661                kind: Some("memory".to_owned()),
662                priority: None,
663                metadata: None,
664                media: None,
665            },
666            memory_id: self.memory_id,
667            relevance,
668            vector_norm: self.vector_norm,
669            graph_weight: self.graph_weight,
670            confidence: NEUTRAL_CONFIDENCE,
671            recency: 0.0,
672            ventilated: false,
673        }
674    }
675}
676
677/// The neutral confidence of a memory with no feedback history — mirrors
678/// `reinforce::RL_NEUTRAL_CONFIDENCE`, whose module is `persistence`-gated:
679/// its contribution to the blend is exactly `0`.
680const NEUTRAL_CONFIDENCE: f64 = 0.5;
681
682/// The learned RL confidence off a raw payload, in `[0, 1]`. Without the
683/// `persistence` feature the RL module (and thus `feedback`) does not exist,
684/// so every memory reads neutral.
685#[cfg(feature = "persistence")]
686fn payload_confidence(payload: Option<&Metadata>) -> f64 {
687    f64::from(payload.map_or(
688        super::reinforce::RL_NEUTRAL_CONFIDENCE,
689        super::reinforce::read_confidence,
690    ))
691}
692
693/// See the `persistence` twin: no RL module, always neutral.
694#[cfg(not(feature = "persistence"))]
695fn payload_confidence(_payload: Option<&Metadata>) -> f64 {
696    NEUTRAL_CONFIDENCE
697}
698
699/// Whether the policy's importance weights change anything at all: a
700/// non-zero confidence weight, or a non-zero recency weight WITH a field to
701/// read. Zero weights must cost nothing and change nothing (0.8.0 parity).
702#[allow(
703    clippy::float_cmp,
704    reason = "an exact zero weight is the documented off switch; any non-zero weight, however small, is active"
705)]
706fn importance_active(weights: &ImportanceWeights) -> bool {
707    weights.confidence != 0.0 || (weights.recency != 0.0 && weights.recency_field.is_some())
708}
709
710/// The batch-relative recency contribution of every candidate, in `[0, 1]`:
711/// min-max over the candidates that carry the policy's `recency_field` as a
712/// number (one monotone scale per batch — `YYYYMMDD` or an epoch, the
713/// caller's choice). A candidate without the key contributes `0` (never
714/// penalised), and a degenerate batch (`max == min`) contributes `0` for
715/// all. No clock: recency is relative to the newest of the batch.
716#[allow(
717    clippy::float_cmp,
718    reason = "an exact zero weight is the documented off switch for the recency term"
719)]
720fn recency_norms(candidates: &[MemoryCandidate], weights: &ImportanceWeights) -> Vec<f64> {
721    let field = weights
722        .recency_field
723        .as_ref()
724        .filter(|_| weights.recency != 0.0);
725    let Some(field) = field else {
726        return vec![0.0; candidates.len()];
727    };
728    let values: Vec<Option<f64>> = candidates
729        .iter()
730        .map(|candidate| {
731            candidate
732                .metadata
733                .as_ref()
734                .and_then(|meta| meta.get(field.as_str()))
735                .and_then(Value::as_f64)
736                .filter(|value| value.is_finite())
737        })
738        .collect();
739    let (min, max) = values
740        .iter()
741        .flatten()
742        .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &v| {
743            (lo.min(v), hi.max(v))
744        });
745    if max <= min {
746        return vec![0.0; candidates.len()];
747    }
748    values
749        .into_iter()
750        .map(|value| value.map_or(0.0, |v| ((v - min) / (max - min)).clamp(0.0, 1.0)))
751        .collect()
752}
753
754/// Stamp pulled memories into the compiled provenance: their decisions and
755/// sources gain the backing `memory_id`, the decision's relevance becomes
756/// the normalised (importance-blended, when active) ranking score, and the
757/// reason spells out the full score ventilation — vector and graph always,
758/// plus confidence and recency when the blend ran — so `why this memory` is
759/// answerable from the decision alone.
760fn annotate_memory_provenance(out: &mut CompiledContext, pulled: &BTreeMap<u64, PulledMemory>) {
761    for decision in &mut out.decisions {
762        if let Some(memory) = pulled.get(&decision.content_hash) {
763            decision.memory_id = Some(memory.memory_id);
764            decision.relevance = memory.relevance;
765            decision.reason = if memory.ventilated {
766                format!(
767                    "{} — pulled from memory {} (vector {:.2}, graph {:.2}, confidence {:.2}, recency {:.2})",
768                    decision.reason,
769                    memory.memory_id,
770                    memory.vector_norm,
771                    memory.graph_weight,
772                    memory.confidence,
773                    memory.recency
774                )
775            } else {
776                format!(
777                    "{} — pulled from memory {} (vector {:.2}, graph {:.2})",
778                    decision.reason, memory.memory_id, memory.vector_norm, memory.graph_weight
779                )
780            };
781        }
782    }
783    for source in &mut out.sources {
784        if let Some(hash) = provenance::parse_handle(&source.handle) {
785            if let Some(memory) = pulled.get(&hash) {
786                source.memory_id = Some(memory.memory_id);
787            }
788        }
789    }
790}
791
792/// Base metadata of every bridge-stored system fact: hub-marked (invisible
793/// to normal recall) plus the given extra keys.
794fn system_meta(extra: &[(&str, Value)]) -> Metadata {
795    let mut meta = Map::new();
796    meta.insert(HUB_FIELD.to_owned(), Value::Bool(true));
797    for (key, value) in extra {
798        meta.insert((*key).to_owned(), value.clone());
799    }
800    meta
801}
802
803/// The metadata of one compilation event — counts and identifiers only,
804/// every key reserved.
805fn event_meta(request: &CompileRequest, out: &CompiledContext, nanos: u128) -> Metadata {
806    let mut extra: Vec<(&str, Value)> = vec![
807        (CTX_EVENT_FIELD, Value::Bool(true)),
808        (
809            CTX_TOKENS_IN_FIELD,
810            Value::Number(out.insights.tokens_in.into()),
811        ),
812        (
813            CTX_TOKENS_OUT_FIELD,
814            Value::Number(out.insights.tokens_out.into()),
815        ),
816        (
817            CTX_TOKENS_SAVED_FIELD,
818            Value::Number(out.insights.tokens_saved.into()),
819        ),
820        (
821            CTX_AT_FIELD,
822            Value::Number(Number::from(
823                u64::try_from(nanos / 1_000_000_000).unwrap_or(u64::MAX),
824            )),
825        ),
826    ];
827    if let Some(project) = &request.project {
828        extra.push((CTX_PROJECT_FIELD, Value::String(project.clone())));
829    }
830    if let Some(model) = &request.target_model {
831        extra.push((CTX_MODEL_FIELD, Value::String(model.clone())));
832    }
833    if let (Some(micros), Some(currency)) = (
834        out.insights.estimated_cost_saved_micros,
835        out.insights.currency.as_ref(),
836    ) {
837        extra.push((CTX_COST_FIELD, Value::Number(micros.into())));
838        extra.push((CTX_CURRENCY_FIELD, Value::String(currency.clone())));
839    }
840    system_meta(&extra)
841}
842
843/// Fold raw event payloads (reserved keys included) into one
844/// [`ContextSavings`]. Every accumulation saturates — an aggregate must
845/// never panic, whatever the stored numbers.
846fn aggregate_events(payloads: &[Option<Metadata>]) -> ContextSavings {
847    let mut savings = ContextSavings {
848        events: payloads.len() as u64,
849        truncated: payloads.len() >= crate::limits::MAX_RECALL_LIMIT,
850        ..ContextSavings::default()
851    };
852    for payload in payloads {
853        let Some(meta) = payload else { continue };
854        savings.tokens_in = savings
855            .tokens_in
856            .saturating_add(meta_u64(meta, CTX_TOKENS_IN_FIELD));
857        savings.tokens_out = savings
858            .tokens_out
859            .saturating_add(meta_u64(meta, CTX_TOKENS_OUT_FIELD));
860        savings.tokens_saved = savings
861            .tokens_saved
862            .saturating_add(meta_u64(meta, CTX_TOKENS_SAVED_FIELD));
863        if let (Some(Value::String(currency)), micros) =
864            (meta.get(CTX_CURRENCY_FIELD), meta_u64(meta, CTX_COST_FIELD))
865        {
866            if micros > 0 {
867                let entry = savings
868                    .cost_saved_micros_by_currency
869                    .entry(currency.clone())
870                    .or_insert(0);
871                *entry = entry.saturating_add(micros);
872            }
873        }
874    }
875    savings
876}
877
878/// A `u64` metadata field, `0` when absent or non-numeric.
879fn meta_u64(meta: &Metadata, key: &str) -> u64 {
880    meta.get(key).and_then(Value::as_u64).unwrap_or(0)
881}
882
883/// The salted system-fact id of a stored source.
884fn source_id(content_hash: u64) -> u64 {
885    stable_id(&format!("{SOURCE_ID_SALT}{content_hash}"))
886}
887
888/// The handle-identity hash of one request fragment — the bridge-side twin
889/// of `Analysis::handle_hash` in `context.rs` (kept in lockstep; the two
890/// must key the same identity or stored slots and minted handles drift
891/// apart): raw decoded media bytes for a media fragment, caption/content
892/// [`stable_id`] otherwise.
893fn fragment_handle_hash(fragment: &ContextFragment) -> u64 {
894    fragment.media.as_ref().map_or_else(
895        || stable_id(&fragment.content),
896        |media_ref| media::analyze(media_ref).raw_hash,
897    )
898}
899
900/// Index a request's fragments by the hash their `ctx://source/` handle is
901/// built from, so a handle can be resolved back to the fragment that produced
902/// it. First occurrence wins (see the identity note on
903/// `store_context_sources`): `entry` + `or_insert`, never a blind overwrite.
904fn index_fragments_by_handle_hash(
905    fragments: &[ContextFragment],
906) -> BTreeMap<u64, &ContextFragment> {
907    let mut by_hash: BTreeMap<u64, &ContextFragment> = BTreeMap::new();
908    for fragment in fragments {
909        by_hash
910            .entry(fragment_handle_hash(fragment))
911            .or_insert(fragment);
912    }
913    by_hash
914}
915
916/// A stored source's media payload (US-009, PR2), when its metadata carries
917/// one — absent (or malformed, which should never happen for a payload this
918/// bridge wrote itself) round-trips as `None` rather than an error, so a
919/// media decode hiccup degrades to "text-only", never breaks the whole
920/// retrieval.
921fn source_media(meta: &Metadata) -> Option<MediaRef> {
922    meta.get(CTX_SOURCE_MEDIA_FIELD)
923        .cloned()
924        .and_then(|value| serde_json::from_value(value).ok())
925}
926
927/// The salted, deterministic system-fact id of a working context.
928fn working_id(project: &str, session: &str) -> u64 {
929    stable_id(&format!("{WORKING_ID_SALT}{project}\u{1f}{session}"))
930}
931
932/// The salted, deterministic system-fact id of a project's working-context
933/// index — one per project, so every save updates the same slot.
934fn working_index_id(project: &str) -> u64 {
935    stable_id(&format!("{WORKING_INDEX_ID_SALT}{project}"))
936}
937
938#[cfg(all(test, feature = "persistence"))]
939#[path = "memory_bridge_tests.rs"]
940mod tests;