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