Skip to main content

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}