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    fragment_handle_hash, meta_u64, now_nanos, now_unix_secs, payload_confidence, positive_ttl,
11    provenance, stable_id, system_meta, BTreeMap, CompilePolicy, CompileRequest, CompiledContext,
12    ContextCompiler, ContextDecision, ContextFragment, ContextSavings, ContextSource, Embedder,
13    FactStore, FusionOptions, GraphStore, ImportanceWeights, Map, MediaRef, MemoryCandidate,
14    MemoryError, MemoryScope, MemoryService, Metadata, Number, Ordering, PulledMemory, RecallStore,
15    Value, CTX_AT_FIELD, CTX_COST_FIELD, CTX_CURRENCY_FIELD, CTX_EVENT_FIELD, CTX_MODEL_FIELD,
16    CTX_PROJECT_FIELD, CTX_SOURCE_FIELD, CTX_SOURCE_MEDIA_FIELD, CTX_TOKENS_IN_FIELD,
17    CTX_TOKENS_OUT_FIELD, CTX_TOKENS_SAVED_FIELD, DEFAULT_MEMORY_K, EVENT_ANCHOR, EVENT_ID_SALT,
18    EVENT_SEQ, EXPIRES_AT_FIELD, NEUTRAL_CONFIDENCE, SOURCE_ID_SALT,
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}
653
654// The pure helpers of the compile fold, moved next to their only consumer
655// (#2021): every function below is called from this module alone, and their
656// former home in `memory_bridge.rs` forced its 35-name `use super` seam.
657
658/// The request's memory scope plus the clamped pull count — `None` when
659/// there is no scope or no room: pulled memories must never push the
660/// request over the fragment cap (the cap is validated after augmentation,
661/// and a rejection there would blame the caller for fragments the bridge
662/// itself added).
663fn scope_and_k(request: &CompileRequest) -> Option<(&MemoryScope, usize)> {
664    let scope = request.memory_scope.as_ref()?;
665    let room = crate::limits::MAX_FRAGMENTS.saturating_sub(request.fragments.len());
666    let k = crate::limits::clamp_recall_limit(scope.k.unwrap_or(DEFAULT_MEMORY_K)).min(room);
667    (k > 0).then_some((scope, k))
668}
669
670/// The recall filter a scope narrows to (its project facet), if any.
671fn scope_filter(scope: &MemoryScope) -> Option<Metadata> {
672    scope.project.as_ref().map(|project| {
673        let mut meta = Map::new();
674        meta.insert("project".to_owned(), Value::String(project.clone()));
675        meta
676    })
677}
678
679fn importance_active(weights: &ImportanceWeights) -> bool {
680    weights.confidence != 0.0 || (weights.recency != 0.0 && weights.recency_field.is_some())
681}
682
683fn recency_norms(candidates: &[MemoryCandidate], weights: &ImportanceWeights) -> Vec<f64> {
684    let field = weights
685        .recency_field
686        .as_ref()
687        .filter(|_| weights.recency != 0.0);
688    let Some(field) = field else {
689        return vec![0.0; candidates.len()];
690    };
691    let values: Vec<Option<f64>> = candidates
692        .iter()
693        .map(|candidate| {
694            candidate
695                .metadata
696                .as_ref()
697                .and_then(|meta| meta.get(field.as_str()))
698                .and_then(Value::as_f64)
699                .filter(|value| value.is_finite())
700        })
701        .collect();
702    let (min, max) = values
703        .iter()
704        .flatten()
705        .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &v| {
706            (lo.min(v), hi.max(v))
707        });
708    if max <= min {
709        return vec![0.0; candidates.len()];
710    }
711    values
712        .into_iter()
713        .map(|value| value.map_or(0.0, |v| ((v - min) / (max - min)).clamp(0.0, 1.0)))
714        .collect()
715}
716
717/// Stamp pulled memories into the compiled provenance: their decisions and
718/// sources gain the backing `memory_id`, the decision's relevance becomes
719/// the normalised (importance-blended, when active) ranking score, and the
720/// reason spells out the full score ventilation — vector and graph always,
721/// plus confidence and recency when the blend ran — so `why this memory` is
722/// answerable from the decision alone.
723fn annotate_memory_provenance(out: &mut CompiledContext, pulled: &BTreeMap<u64, PulledMemory>) {
724    for decision in &mut out.decisions {
725        if let Some(memory) = pulled.get(&decision.content_hash) {
726            decision.memory_id = Some(memory.memory_id);
727            decision.relevance = memory.relevance;
728            decision.reason = if memory.ventilated {
729                format!(
730                    "{} — pulled from memory {} (vector {:.2}, graph {:.2}, confidence {:.2}, recency {:.2})",
731                    decision.reason,
732                    memory.memory_id,
733                    memory.vector_norm,
734                    memory.graph_weight,
735                    memory.confidence,
736                    memory.recency
737                )
738            } else {
739                format!(
740                    "{} — pulled from memory {} (vector {:.2}, graph {:.2})",
741                    decision.reason, memory.memory_id, memory.vector_norm, memory.graph_weight
742                )
743            };
744        }
745    }
746    for source in &mut out.sources {
747        if let Some(hash) = provenance::parse_handle(&source.handle) {
748            if let Some(memory) = pulled.get(&hash) {
749                source.memory_id = Some(memory.memory_id);
750            }
751        }
752    }
753}
754
755/// The metadata of one compilation event — counts and identifiers only,
756/// every key reserved.
757fn event_meta(request: &CompileRequest, out: &CompiledContext, nanos: u128) -> Metadata {
758    let mut extra: Vec<(&str, Value)> = vec![
759        (CTX_EVENT_FIELD, Value::Bool(true)),
760        (
761            CTX_TOKENS_IN_FIELD,
762            Value::Number(out.insights.tokens_in.into()),
763        ),
764        (
765            CTX_TOKENS_OUT_FIELD,
766            Value::Number(out.insights.tokens_out.into()),
767        ),
768        (
769            CTX_TOKENS_SAVED_FIELD,
770            Value::Number(out.insights.tokens_saved.into()),
771        ),
772        (
773            CTX_AT_FIELD,
774            Value::Number(Number::from(
775                u64::try_from(nanos / 1_000_000_000).unwrap_or(u64::MAX),
776            )),
777        ),
778    ];
779    if let Some(project) = &request.project {
780        extra.push((CTX_PROJECT_FIELD, Value::String(project.clone())));
781    }
782    if let Some(model) = &request.target_model {
783        extra.push((CTX_MODEL_FIELD, Value::String(model.clone())));
784    }
785    if let (Some(micros), Some(currency)) = (
786        out.insights.estimated_cost_saved_micros,
787        out.insights.currency.as_ref(),
788    ) {
789        extra.push((CTX_COST_FIELD, Value::Number(micros.into())));
790        extra.push((CTX_CURRENCY_FIELD, Value::String(currency.clone())));
791    }
792    system_meta(&extra)
793}
794
795/// Fold raw event payloads (reserved keys included) into one
796/// [`ContextSavings`]. Every accumulation saturates — an aggregate must
797/// never panic, whatever the stored numbers.
798fn aggregate_events(payloads: &[Option<Metadata>]) -> ContextSavings {
799    let mut savings = ContextSavings {
800        events: payloads.len() as u64,
801        truncated: payloads.len() >= crate::limits::MAX_RECALL_LIMIT,
802        ..ContextSavings::default()
803    };
804    for payload in payloads {
805        let Some(meta) = payload else { continue };
806        savings.tokens_in = savings
807            .tokens_in
808            .saturating_add(meta_u64(meta, CTX_TOKENS_IN_FIELD));
809        savings.tokens_out = savings
810            .tokens_out
811            .saturating_add(meta_u64(meta, CTX_TOKENS_OUT_FIELD));
812        savings.tokens_saved = savings
813            .tokens_saved
814            .saturating_add(meta_u64(meta, CTX_TOKENS_SAVED_FIELD));
815        if let (Some(Value::String(currency)), micros) =
816            (meta.get(CTX_CURRENCY_FIELD), meta_u64(meta, CTX_COST_FIELD))
817        {
818            if micros > 0 {
819                let entry = savings
820                    .cost_saved_micros_by_currency
821                    .entry(currency.clone())
822                    .or_insert(0);
823                *entry = entry.saturating_add(micros);
824            }
825        }
826    }
827    savings
828}
829
830/// The salted system-fact id of a stored source.
831pub(super) fn source_id(content_hash: u64) -> u64 {
832    stable_id(&format!("{SOURCE_ID_SALT}{content_hash}"))
833}
834
835/// Index a request's fragments by the hash their `ctx://source/` handle is
836/// built from, so a handle can be resolved back to the fragment that produced
837/// it. First occurrence wins (see the identity note on
838/// `store_context_sources`): `entry` + `or_insert`, never a blind overwrite.
839fn index_fragments_by_handle_hash(
840    fragments: &[ContextFragment],
841) -> BTreeMap<u64, &ContextFragment> {
842    let mut by_hash: BTreeMap<u64, &ContextFragment> = BTreeMap::new();
843    for fragment in fragments {
844        by_hash
845            .entry(fragment_handle_hash(fragment))
846            .or_insert(fragment);
847    }
848    by_hash
849}
850
851/// A stored source's media payload (US-009, PR2), when its metadata carries
852/// one — absent (or malformed, which should never happen for a payload this
853/// bridge wrote itself) round-trips as `None` rather than an error, so a
854/// media decode hiccup degrades to "text-only", never breaks the whole
855/// retrieval.
856fn source_media(meta: &Metadata) -> Option<MediaRef> {
857    meta.get(CTX_SOURCE_MEDIA_FIELD)
858        .cloned()
859        .and_then(|value| serde_json::from_value(value).ok())
860}