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;