velesdb_memory/context/memory_bridge_compile.rs
1//! Context COMPILATION — [`MemoryService::compile_context`] and its scoring,
2//! source-persistence and explanation helpers — split out of
3//! `memory_bridge.rs` to keep that file inside the crate's file budget, the
4//! same child-module pattern as `service.rs`'s `fused_recall.rs`. The
5//! working-context save/load/index half stays in `memory_bridge.rs`; this
6//! half is the one whose methods carry `RecallStore`/`GraphStore` bounds
7//! (#1959), so the budget seam and the facet seam coincide.
8
9use super::{
10 aggregate_events, annotate_memory_provenance, event_meta, importance_active,
11 index_fragments_by_handle_hash, now_nanos, now_unix_secs, payload_confidence, positive_ttl,
12 provenance, recency_norms, scope_and_k, scope_filter, source_id, source_media, stable_id,
13 system_meta, BTreeMap, CompilePolicy, CompileRequest, CompiledContext, ContextCompiler,
14 ContextDecision, ContextFragment, ContextSavings, ContextSource, Embedder, FactStore,
15 FusionOptions, GraphStore, ImportanceWeights, Map, MemoryCandidate, MemoryError, MemoryService,
16 Metadata, Ordering, PulledMemory, RecallStore, Value, CTX_EVENT_FIELD, CTX_PROJECT_FIELD,
17 CTX_SOURCE_FIELD, CTX_SOURCE_MEDIA_FIELD, EVENT_ANCHOR, EVENT_ID_SALT, EVENT_SEQ,
18 EXPIRES_AT_FIELD, NEUTRAL_CONFIDENCE,
19};
20use crate::service::embeddable_prefix;
21
22impl<E: Embedder, S: FactStore> MemoryService<E, S> {
23 /// [`ContextCompiler::compile`] with this service's memory folded in:
24 /// when the request carries a [`MemoryScope`], relevant memories are
25 /// pulled through the fused vector+graph recall and compiled alongside
26 /// the caller's fragments, each with its `memory_id` and a normalised
27 /// fused-ranking relevance recorded in provenance. Afterwards (policy
28 /// permitting) the distinct originals are stored so every
29 /// `ctx://source/<hash>` handle round-trips, and a metadata-only
30 /// compilation event is recorded for [`Self::context_savings`].
31 ///
32 /// # Errors
33 /// Returns [`MemoryError`] if compilation itself fails (budget, caps),
34 /// or if recall, embedding, or storage fails.
35 pub fn compile_context(
36 &self,
37 compiler: &ContextCompiler,
38 request: &CompileRequest,
39 ) -> Result<CompiledContext, MemoryError>
40 where
41 S: GraphStore + RecallStore,
42 {
43 let _generation = self.enter_generation();
44 self.compile_context_inner(compiler, request)
45 }
46
47 fn compile_context_inner(
48 &self,
49 compiler: &ContextCompiler,
50 request: &CompileRequest,
51 ) -> Result<CompiledContext, MemoryError>
52 where
53 S: GraphStore + RecallStore,
54 {
55 let importance = compiler.effective_policy(request).importance.clone();
56 let memories = self.context_memories(request, &importance)?;
57 self.compile_with_memories(compiler, request, memories)
58 }
59
60 /// [`Self::compile_context`] with a caller-supplied [`crate::Reranker`] driving
61 /// memory selection: the reranker receives the FULL fused candidate pool
62 /// (vector + graph, before the `k` cutoff) and its ordering decides
63 /// which `k` memories are compiled in — the seam for a semantic
64 /// cross-encoder or LLM judge a Rust embedder brings along. Not exposed
65 /// on the wire (a reranker is code, not JSON), and never a default: the
66 /// shipped [`crate::context::DeterministicReranker`] is *lexical*, and a
67 /// lexical second stage demotes exactly the zero-vocabulary-overlap
68 /// evidence the graph walk rescues (measured in the BDD suite) — bring
69 /// a semantic one.
70 ///
71 /// # Errors
72 /// Returns [`MemoryError`] if compilation, recall, the reranker itself,
73 /// or storage fails.
74 pub fn compile_context_reranked<R: crate::Reranker>(
75 &self,
76 compiler: &ContextCompiler,
77 request: &CompileRequest,
78 reranker: &R,
79 ) -> Result<CompiledContext, MemoryError>
80 where
81 S: GraphStore + RecallStore,
82 {
83 let _generation = self.enter_generation();
84 let importance = compiler.effective_policy(request).importance.clone();
85 let memories = self.context_memories_reranked(request, reranker, &importance)?;
86 self.compile_with_memories(compiler, request, memories)
87 }
88
89 /// The shared back half of every compile flavour: augment the request
90 /// with the pulled memories, compile, annotate provenance, persist
91 /// sources/events per policy.
92 fn compile_with_memories(
93 &self,
94 compiler: &ContextCompiler,
95 request: &CompileRequest,
96 memories: Vec<PulledMemory>,
97 ) -> Result<CompiledContext, MemoryError> {
98 let mut augmented = request.clone();
99 let mut pulled: BTreeMap<u64, PulledMemory> = BTreeMap::new();
100 for memory in memories {
101 augmented.fragments.push(memory.fragment.clone());
102 pulled.insert(stable_id(&memory.fragment.content), memory);
103 }
104 // `compile_raw`, not `compile`: annotating memory provenance below
105 // can rewrite a pulled fragment's `relevance`/`reason` (and thus
106 // whether it crosses the `warnings` threshold), so `decisions` must
107 // stay full until that has happened and `warnings` is recomputed —
108 // `slim_response` (if requested) is applied as the LAST step.
109 let mut out = compiler.compile_raw(&augmented)?;
110 annotate_memory_provenance(&mut out, &pulled);
111 out.warnings = crate::context::warnings_for(&out.decisions);
112 let policy = compiler.effective_policy(request);
113 if policy.store_sources {
114 self.store_context_sources(&augmented, &out, policy.source_ttl_seconds)?;
115 }
116 if policy.record_events {
117 self.record_context_event(request, &out, policy.event_ttl_seconds)?;
118 }
119 Ok(crate::context::apply_slim(out, policy))
120 }
121
122 /// The memories a request's scope pulls in, as compile fragments plus
123 /// their id and normalised fused relevance, importance-blended
124 /// ([`Self::blend_importance`]) when the policy's weights are active.
125 fn context_memories(
126 &self,
127 request: &CompileRequest,
128 importance: &ImportanceWeights,
129 ) -> Result<Vec<PulledMemory>, MemoryError>
130 where
131 S: GraphStore + RecallStore,
132 {
133 let Some((scope, k)) = scope_and_k(request) else {
134 return Ok(Vec::new());
135 };
136 let filter = scope_filter(scope);
137 // The scope's fusion knobs (clamped by from_knobs); absent ones fall
138 // back to the crate defaults — raising graph_boost lets a curated
139 // relate-chain out-rank lexically-noisy near-misses (see MemoryScope).
140 let opts = FusionOptions::from_knobs(scope.hops, scope.graph_boost, None);
141 let scored = self.recall_fused_scored(&request.query, k, filter.as_ref(), opts)?;
142 let max_fused = scored
143 .iter()
144 .map(|s| s.fused)
145 .fold(f64::MIN, f64::max)
146 .max(f64::EPSILON);
147 let candidates = scored
148 .into_iter()
149 .map(|scored| {
150 // Sanitise a non-finite fused score to 0 before normalising:
151 // `f32::clamp` returns NaN for a NaN input (it does not clamp),
152 // which would put a non-`[0, 1]` value — serialising as JSON
153 // `null` — into an output sold as deterministic and auditable.
154 let fused = if scored.fused.is_finite() {
155 scored.fused
156 } else {
157 0.0
158 };
159 MemoryCandidate {
160 memory_id: scored.recollection.id,
161 base: (fused / max_fused).clamp(0.0, 1.0),
162 vector_norm: scored.vector_norm,
163 graph_weight: scored.graph_weight,
164 metadata: scored.recollection.metadata,
165 content: scored.recollection.content,
166 }
167 })
168 .collect();
169 self.blend_importance(candidates, importance)
170 }
171
172 /// Memory selection driven by a caller-supplied reranker: the fused
173 /// candidate pool (at pool depth, vector + graph) is handed to the
174 /// reranker whole, its ordering is truncated to `k`, and relevance is
175 /// rank-based (the reranker defines the ranking; the fused ventilation
176 /// no longer describes it, so vector/graph read 0 in provenance). The
177 /// importance blend then composes with the seam: it re-ranks INSIDE the
178 /// reranker-selected pool, exactly as it does over the fused pool.
179 fn context_memories_reranked<R: crate::Reranker>(
180 &self,
181 request: &CompileRequest,
182 reranker: &R,
183 importance: &ImportanceWeights,
184 ) -> Result<Vec<PulledMemory>, MemoryError>
185 where
186 S: GraphStore + RecallStore,
187 {
188 let Some((scope, k)) = scope_and_k(request) else {
189 return Ok(Vec::new());
190 };
191 let filter = scope_filter(scope);
192 let opts = FusionOptions::from_knobs(scope.hops, scope.graph_boost, None);
193 let ranked =
194 self.recall_fused_reranked_inner(&request.query, k, filter.as_ref(), opts, reranker)?;
195 let count = ranked.len().max(1);
196 let candidates = ranked
197 .into_iter()
198 .enumerate()
199 .map(|(rank, recollection)| {
200 // Computed in f32 exactly as 0.8.0 did, so inactive weights
201 // reproduce the historical relevance bytes.
202 #[allow(clippy::cast_precision_loss)] // rank/count are tiny
203 let relevance = 1.0 - (rank as f32 / count as f32);
204 MemoryCandidate {
205 memory_id: recollection.id,
206 base: f64::from(relevance),
207 vector_norm: 0.0,
208 graph_weight: 0.0,
209 metadata: recollection.metadata,
210 content: recollection.content,
211 }
212 })
213 .collect();
214 self.blend_importance(candidates, importance)
215 }
216
217 /// Fold usage-driven importance into an already-selected memory pool —
218 /// the one ranking the whole engine stack shares (US-002 of EPIC-P-071):
219 /// per candidate the key becomes `base + w_c·(confidence − 0.5)·2 +
220 /// w_r·recency_norm`, where `base` is the fused (or rank-based)
221 /// similarity in `[0, 1]`. Selection is untouched on purpose: confidence
222 /// is not relevance, so a reinforced-but-off-topic fact can never buy
223 /// its way into the pool here. Inactive weights take the zero-cost path
224 /// and reproduce the 0.8.0 output byte for byte (golden-pinned). The
225 /// stable sort keeps equal keys in selection order, and no clock is ever
226 /// read — recency is min-max normalised within the batch.
227 fn blend_importance(
228 &self,
229 candidates: Vec<MemoryCandidate>,
230 weights: &ImportanceWeights,
231 ) -> Result<Vec<PulledMemory>, MemoryError> {
232 if !importance_active(weights) {
233 return Ok(candidates
234 .into_iter()
235 .map(MemoryCandidate::into_pulled)
236 .collect());
237 }
238 let ids: Vec<u64> = candidates.iter().map(|c| c.memory_id).collect();
239 // Raw payloads (reserved keys included): the learned confidence
240 // lives under `_veles_rl_confidence`, which caller-facing metadata
241 // strips.
242 let raw = self.store.get_metadata_batch(&ids)?;
243 let recencies = recency_norms(&candidates, weights);
244 let mut blended: Vec<(f64, PulledMemory)> = candidates
245 .into_iter()
246 .zip(raw)
247 .zip(recencies)
248 .map(|((candidate, payload), recency)| {
249 let confidence = payload_confidence(payload.as_ref());
250 let score = candidate.base
251 + weights.confidence * (confidence - NEUTRAL_CONFIDENCE) * 2.0
252 + weights.recency * recency;
253 let mut pulled = candidate.into_pulled();
254 #[allow(clippy::cast_possible_truncation)] // clamped into [0, 1]
255 {
256 pulled.relevance = score.clamp(0.0, 1.0) as f32;
257 }
258 pulled.confidence = confidence;
259 pulled.recency = recency;
260 pulled.ventilated = true;
261 (score, pulled)
262 })
263 .collect();
264 // Stable: equal blended keys keep the selection order.
265 blended.sort_by(|a, b| b.0.total_cmp(&a.0));
266 Ok(blended.into_iter().map(|(_, pulled)| pulled).collect())
267 }
268
269 /// Store every distinct fragment's original as a hub-marked system fact
270 /// keyed by its salted handle hash, so its handle can be resolved later.
271 /// A fragment carrying media (US-009, PR2) has its base64 payload
272 /// persisted alongside the caption under the reserved
273 /// [`CTX_SOURCE_MEDIA_FIELD`] key.
274 ///
275 /// **Identity**: the key mirrors what the compiler mints handles from
276 /// (`Analysis::handle_hash` in `context.rs`) — the caption's
277 /// [`stable_id`] for text, the raw decoded bytes' hash
278 /// ([`media::MediaAnalysis::raw_hash`]) for media, the same identity
279 /// PR1's dedup keys on. Keying media on the caption instead was the PR2
280 /// review's proven blocker: every captionless image collided onto one
281 /// slot and one handle, serving arbitrary wrong bytes back. The slot
282 /// stays inside the salted system-fact namespace ([`source_id`] applies
283 /// `SOURCE_ID_SALT` to the hash) — same salt, no new namespace. On a
284 /// same-key collision (byte-identical images with different captions)
285 /// the FIRST occurrence wins, matching the dedup twin the compiler
286 /// keeps — a divergent duplicate caption does not survive, exactly as
287 /// its decision reason already says.
288 ///
289 /// Size: [`crate::limits::MAX_MEDIA_BYTES`] /
290 /// [`crate::limits::MAX_TOTAL_MEDIA_BYTES`] already bounded every
291 /// fragment's `bytes_b64` before `compiler.compile` ever ran (see
292 /// `validate_media`, called from `compile`'s `validate`). TEXT is a
293 /// different story, and an earlier revision of this comment got it
294 /// wrong by claiming no size guard was needed on the write path: those
295 /// media caps say nothing about `content`, which a `path` ingestion can
296 /// fill up to 1 MiB — so [`Self::source_vector`] caps what it EMBEDS
297 /// (the stored content stays whole). The lesson stands: "another layer
298 /// already checked" must name which cap, over which field.
299 fn store_context_sources(
300 &self,
301 augmented: &CompileRequest,
302 out: &CompiledContext,
303 ttl_seconds: Option<u64>,
304 ) -> Result<(), MemoryError> {
305 let by_hash = index_fragments_by_handle_hash(&augmented.fragments);
306 let ttl_seconds = positive_ttl(ttl_seconds);
307 for source in &out.sources {
308 self.store_one_source(&source.handle, &by_hash, ttl_seconds)?;
309 }
310 Ok(())
311 }
312
313 /// Write the one slot behind `handle`, if this compile owns it.
314 ///
315 /// A handle whose fragment is no longer in the request (or that does not
316 /// parse) is skipped, not an error: `out.sources` is derived from the
317 /// same request, so a miss can only mean the source was externalized
318 /// under a shape this write path has nothing to store.
319 fn store_one_source(
320 &self,
321 handle: &str,
322 by_hash: &BTreeMap<u64, &ContextFragment>,
323 ttl_seconds: Option<u64>,
324 ) -> Result<(), MemoryError> {
325 let Some(hash) = provenance::parse_handle(handle) else {
326 return Ok(());
327 };
328 let Some(fragment) = by_hash.get(&hash) else {
329 return Ok(());
330 };
331 let slot = source_id(hash);
332 if !self.prepare_source_slot(slot, ttl_seconds)? {
333 return Ok(());
334 }
335 let (embedding, media_meta) = self.source_vector(fragment, hash)?;
336 let mut extra: Vec<(&str, Value)> = vec![(CTX_SOURCE_FIELD, Value::Bool(true))];
337 if let Some(media) = media_meta {
338 extra.push((CTX_SOURCE_MEDIA_FIELD, media));
339 }
340 self.store_fact(
341 slot,
342 fragment.content.as_str(),
343 &embedding,
344 Some(&system_meta(&extra)),
345 ttl_seconds,
346 )
347 }
348
349 /// Whether `slot` may be written for this compile, clearing a stale point
350 /// first when the write upgrades it to permanent.
351 ///
352 /// A slot never marked as ours is never rewritten: it is a caller fact
353 /// squatting the salt preimage, and clobbering it would destroy user
354 /// data. A slot already marked as ours holds these exact bytes — sources
355 /// are content-addressed — so content and embedding never change; only
356 /// durability can, and only upward (never-downgrade TTL upgrade, see
357 /// [`Self::should_store_source`]), so a handle sold as permanent never
358 /// silently expires just because an earlier compile first wrote it under
359 /// a TTL.
360 ///
361 /// Upgrading to permanent needs the old point *gone*, not merely
362 /// overwritten: velesdb-core's store path preserves every `_veles_*` key
363 /// from a prior version of a re-stored id unless the new write explicitly
364 /// sets it (`semantic_memory.rs`'s `store_internal` carry-forward, so
365 /// plain `remember` doesn't silently wipe learned state), and a permanent
366 /// write has no expiry to set (`attach_expiry` is a no-op without one) —
367 /// so without this delete, `_veles_expires_at` would survive the
368 /// "upgrade" untouched. A TTL-to-TTL extension needs no delete: its new
369 /// expiry always overwrites the old one.
370 fn prepare_source_slot(
371 &self,
372 slot: u64,
373 ttl_seconds: Option<u64>,
374 ) -> Result<bool, MemoryError> {
375 if !self.should_store_source(slot, ttl_seconds)? {
376 return Ok(false);
377 }
378 if ttl_seconds.is_none() && self.store.get(slot)?.is_some() {
379 self.store.delete(slot)?;
380 }
381 Ok(true)
382 }
383
384 /// The vector a source slot is indexed by, plus the media descriptor to
385 /// stamp on it when the fragment carries one.
386 ///
387 /// A media fragment's vector is deterministic and derived from the
388 /// DECODED bytes — never the text embedder over `content` (often blank)
389 /// nor over the base64 payload itself (opaque, not language). Correct
390 /// because `retrieve_context_source` resolves a media source EXCLUSIVELY
391 /// by its content-addressed hash/slot, never by vector search: the vector
392 /// only has to be well-formed and non-degenerate for the underlying
393 /// index, never semantically meaningful. For a media fragment `hash` IS
394 /// the raw-bytes hash (see `fragment_handle_hash`), so nothing is
395 /// re-decoded here.
396 ///
397 /// A TEXT fragment is embedded over at most
398 /// [`crate::limits::MAX_EMBEDDABLE_TEXT_BYTES`] of its content
399 /// ([`super::embeddable_prefix`]) — a `path`-ingested file can be 1 MiB,
400 /// far past what the embedding backend accepts, and handing it over
401 /// whole surfaced the backend's raw failure (issue #1654's residue,
402 /// found on this very path). Truncating the *embedded* text, not the
403 /// stored content, is the right trade here: retrieval is hash-addressed
404 /// so the source stays whole, and the vector keeps ranking on the head
405 /// of the text instead of vanishing from semantic recall.
406 fn source_vector(
407 &self,
408 fragment: &ContextFragment,
409 hash: u64,
410 ) -> Result<(Vec<f32>, Option<Value>), MemoryError> {
411 let Some(media_ref) = &fragment.media else {
412 let embeddable = embeddable_prefix(fragment.content.as_str());
413 return Ok((self.embedder.embed(embeddable)?, None));
414 };
415 let descriptor = serde_json::to_value(media_ref).unwrap_or(Value::Null);
416 Ok((self.media_placeholder_embedding(hash), Some(descriptor)))
417 }
418
419 /// Whether [`Self::store_context_sources`] should (re-)write `slot` for
420 /// this compile's requested (already [`positive_ttl`]-normalized —
421 /// `None` means permanent) TTL.
422 ///
423 /// - Not marked as ours (absent, or a caller fact squatting the salt
424 /// preimage): store only if the slot is genuinely empty.
425 /// - Marked as ours: never re-embed or change content (content-addressed);
426 /// only [`Self::should_upgrade_ttl`] decides whether durability changes.
427 pub(super) fn should_store_source(
428 &self,
429 slot: u64,
430 requested_ttl: Option<u64>,
431 ) -> Result<bool, MemoryError> {
432 match self.context_source_metadata(slot)? {
433 Some(existing) => Ok(Self::should_upgrade_ttl(&existing, requested_ttl)),
434 None => Ok(self.store.get(slot)?.is_none()),
435 }
436 }
437
438 /// Never-downgrade TTL upgrade rule for an already-stored source: permanent
439 /// once requested stays permanent, and a TTL only ever extends, never
440 /// shortens. The clock read here is fine — this is the storage/expiry
441 /// layer, not the clock-free `compile` pipeline.
442 fn should_upgrade_ttl(existing: &Metadata, requested_ttl: Option<u64>) -> bool {
443 let existing_expiry = existing.get(EXPIRES_AT_FIELD).and_then(Value::as_u64);
444 match (requested_ttl, existing_expiry) {
445 // Permanent requested, slot still carries a TTL: upgrade.
446 (None, Some(_)) => true,
447 // Already permanent, or a TTL requested against a permanent slot:
448 // never downgrade.
449 (None | Some(_), None) => false,
450 // Both carry a TTL: extend only if the new one outlives what
451 // remains — never shorten.
452 (Some(ttl), Some(existing_exp)) => now_unix_secs().saturating_add(ttl) > existing_exp,
453 }
454 }
455
456 /// A deterministic, non-degenerate embedding for a media source (US-009,
457 /// PR2) — see [`Self::store_context_sources`] for why it is bytes-hash
458 /// derived rather than text-embedded.
459 fn media_placeholder_embedding(&self, raw_hash: u64) -> Vec<f32> {
460 let dim = self.embedder.dimension();
461 let mut vector = vec![0.0_f32; dim];
462 let Ok(dim_u64) = u64::try_from(dim) else {
463 return vector;
464 };
465 if dim_u64 == 0 {
466 return vector;
467 }
468 let bucket = usize::try_from(raw_hash % dim_u64).unwrap_or(0);
469 vector[bucket] = 1.0;
470 velesdb_core::simd_native::normalize_inplace_native(&mut vector);
471 vector
472 }
473
474 /// The fact at `slot`'s metadata, when it carries the stored-source
475 /// marker (`None` otherwise — absent, or a caller fact squatting the
476 /// slot).
477 pub(super) fn context_source_metadata(
478 &self,
479 slot: u64,
480 ) -> Result<Option<Metadata>, MemoryError> {
481 let payloads = self.store.get_metadata_batch(&[slot])?;
482 Ok(payloads
483 .into_iter()
484 .next()
485 .flatten()
486 .filter(|meta| meta.get(CTX_SOURCE_FIELD) == Some(&Value::Bool(true))))
487 }
488
489 /// The original content — and media, when the fragment carried one —
490 /// behind a `ctx://source/<hash>` handle.
491 ///
492 /// # Errors
493 /// Returns [`MemoryError::UnknownHandle`] when the handle is malformed
494 /// or nothing is stored under it (never stored, expired, or forgotten).
495 pub fn retrieve_context_source(&self, handle: &str) -> Result<ContextSource, MemoryError> {
496 let _generation = self.enter_generation();
497 let unknown = || MemoryError::UnknownHandle(handle.to_owned());
498 let hash = provenance::parse_handle(handle).ok_or_else(unknown)?;
499 let slot = source_id(hash);
500 // Only marker-bearing facts are sources: a caller fact squatting the
501 // salted slot is never served back as compiled provenance.
502 let meta = self.context_source_metadata(slot)?.ok_or_else(unknown)?;
503 let content = self
504 .store
505 .get(slot)?
506 .map(|(content, _embedding)| content)
507 .ok_or_else(unknown)?;
508 Ok(ContextSource {
509 content,
510 media: source_media(&meta),
511 })
512 }
513
514 /// Explain why one fragment of `request` was preserved, abstracted,
515 /// externalized, dropped, or cached — the selection primitive the MCP
516 /// `explain_compilation` tool delegates to, extracted here so every
517 /// adapter (MCP, Node, Python) shares one implementation instead of
518 /// reimplementing it. Compilation is deterministic, so `request` is
519 /// simply re-compiled — with event/source recording forced off, since an
520 /// explanation must not have side effects — and the matching decision is
521 /// returned.
522 ///
523 /// `fragment_index` (0-based position in `request.fragments`), when
524 /// given, TAKES PRIORITY over `fragment_id` for locating the decision:
525 /// `compile_context` records exactly one decision per input fragment, in
526 /// order, so `decisions[fragment_index]` is unambiguous even when
527 /// several fragments are byte-identical and therefore share the same
528 /// content-addressed `fragment_id` — a plain `fragment_id` lookup always
529 /// resolves to the FIRST such decision (the deduplication survivor's),
530 /// never a dropped twin's.
531 ///
532 /// Caveat inherited from re-compiling rather than replaying stored
533 /// state: with a `memory_scope` the re-compile recalls from CURRENT
534 /// memory, so the decision reflects memory as it is now, not as it was
535 /// at the original `compile_context` call; a caller that already
536 /// resolved a `path` fragment to `content` is unaffected (this method
537 /// does no I/O of its own).
538 ///
539 /// # Errors
540 /// Returns [`MemoryError::FragmentIndexOutOfBounds`] when `fragment_index`
541 /// is beyond `request.fragments`, [`MemoryError::FragmentNotFound`] when
542 /// no decision matches the selector, or any error [`Self::compile_context`]
543 /// itself can return (budget, caps, recall, embedding, storage).
544 pub fn explain_compilation(
545 &self,
546 request: &CompileRequest,
547 fragment_id: u64,
548 fragment_index: Option<usize>,
549 ) -> Result<ContextDecision, MemoryError>
550 where
551 S: GraphStore + RecallStore,
552 {
553 let _generation = self.enter_generation();
554 if let Some(index) = fragment_index {
555 let len = request.fragments.len();
556 if index >= len {
557 return Err(MemoryError::FragmentIndexOutOfBounds { index, len });
558 }
559 }
560 let mut request = request.clone();
561 let mut policy = request.policy.take().unwrap_or_default();
562 // Three options neutralised for one reason: an explanation must not
563 // inherit the side effects, nor the presentation, of the compilation it
564 // explains. The caller asked "why this fragment?", not "compile this".
565 policy.record_events = false;
566 policy.store_sources = false;
567 // `slim_response` empties `sections` and `decisions` to save tokens
568 // (see `apply_slim`). Applied here it would not trim the answer, it
569 // would DELETE it: `decisions` is cleared, the lookup below finds
570 // nothing, and the caller is told `FragmentNotFound` about a fragment
571 // that compiled perfectly well (#1745).
572 //
573 // The option exists to save tokens, so a caller under a tight budget
574 // turns it on by default — and lost the audit tool exactly when they
575 // most needed it, with a message that sent them looking for a typo in
576 // an id that was correct.
577 policy.slim_response = false;
578 request.policy = Some(policy);
579 let compiled =
580 self.compile_context_inner(&ContextCompiler::new(CompilePolicy::default()), &request)?;
581 let decision = if let Some(index) = fragment_index {
582 compiled.decisions.into_iter().nth(index)
583 } else {
584 compiled
585 .decisions
586 .into_iter()
587 .find(|decision| decision.fragment_id == fragment_id)
588 };
589 decision.ok_or(MemoryError::FragmentNotFound(fragment_id))
590 }
591
592 /// Record one compilation's savings as a metadata-only system fact
593 /// (hashes and token counts — never fragment content). Wall-clock time
594 /// is stamped here, outside the deterministic compile pipeline.
595 fn record_context_event(
596 &self,
597 request: &CompileRequest,
598 out: &CompiledContext,
599 ttl_seconds: Option<u64>,
600 ) -> Result<(), MemoryError> {
601 let occurred_at_nanos = now_nanos();
602 // The per-process sequence keeps ids unique even when two compiles
603 // land on the same (possibly coarse) clock tick.
604 let seq = EVENT_SEQ.fetch_add(1, Ordering::Relaxed);
605 let content = format!("{EVENT_ANCHOR} {occurred_at_nanos}-{seq}");
606 let id = stable_id(&format!("{EVENT_ID_SALT}{occurred_at_nanos}:{seq}"));
607 let embedding = self.embedder.embed(&content)?;
608 let meta = event_meta(request, out, occurred_at_nanos);
609 self.store_fact(
610 id,
611 &content,
612 &embedding,
613 Some(&meta),
614 positive_ttl(ttl_seconds),
615 )?;
616 Ok(())
617 }
618
619 /// Aggregate the recorded compilation events, optionally per project.
620 /// Sweeps at most [`crate::limits::MAX_RECALL_LIMIT`] events (newest
621 /// need not be first — the sweep is similarity-ordered over a constant
622 /// anchor, i.e. effectively the whole family until the cap);
623 /// [`ContextSavings::truncated`] reports when the cap was hit.
624 ///
625 /// # Errors
626 /// Returns [`MemoryError`] if the underlying filtered recall fails.
627 pub fn context_savings(&self, project: Option<&str>) -> Result<ContextSavings, MemoryError>
628 where
629 S: RecallStore,
630 {
631 let _generation = self.enter_generation();
632 // Filter at the STORAGE layer on the reserved event marker: callers
633 // can neither set nor query `_veles_*` keys, so only genuine bridge
634 // events can ever match — a caller fact posing as an event counts
635 // for nothing.
636 let mut filter = Map::new();
637 filter.insert(CTX_EVENT_FIELD.to_owned(), Value::Bool(true));
638 if let Some(project) = project {
639 filter.insert(
640 CTX_PROJECT_FIELD.to_owned(),
641 Value::String(project.to_owned()),
642 );
643 }
644 let embedding = self.embedder.embed(EVENT_ANCHOR)?;
645 let hits =
646 self.store
647 .query_filtered(&embedding, crate::limits::MAX_RECALL_LIMIT, &filter, 0)?;
648 let ids: Vec<u64> = hits.iter().map(|(id, _, _)| *id).collect();
649 let payloads = self.store.get_metadata_batch(&ids)?;
650 Ok(aggregate_events(&payloads))
651 }
652}