zeph_config/memory/retrieval.rs
1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Retrieval, ranking, and admission-control configuration.
5//!
6//! Hybrid vector/keyword retrieval, `MemFlow` tiered retrieval, A-MAC admission
7//! control, store routing, consolidation sweeps, and retrieval-failure capture.
8
9use crate::providers::ProviderName;
10use serde::{Deserialize, Serialize};
11use zeph_common::memory::MemoryRoute;
12
13use super::default_embed_timeout_secs;
14
15fn validate_tier_similarity_threshold<'de, D>(deserializer: D) -> Result<f32, D::Error>
16where
17 D: serde::Deserializer<'de>,
18{
19 let value = <f32 as serde::Deserialize>::deserialize(deserializer)?;
20 if value.is_nan() || value.is_infinite() {
21 return Err(serde::de::Error::custom(
22 "similarity_threshold must be a finite number",
23 ));
24 }
25 if !(0.5..=1.0).contains(&value) {
26 return Err(serde::de::Error::custom(
27 "similarity_threshold must be in [0.5, 1.0]",
28 ));
29 }
30 Ok(value)
31}
32
33fn validate_tier_promotion_min_sessions<'de, D>(deserializer: D) -> Result<u32, D::Error>
34where
35 D: serde::Deserializer<'de>,
36{
37 let value = <u32 as serde::Deserialize>::deserialize(deserializer)?;
38 if value < 2 {
39 return Err(serde::de::Error::custom(
40 "promotion_min_sessions must be >= 2",
41 ));
42 }
43 Ok(value)
44}
45
46fn validate_tier_sweep_batch_size<'de, D>(deserializer: D) -> Result<usize, D::Error>
47where
48 D: serde::Deserializer<'de>,
49{
50 let value = <usize as serde::Deserialize>::deserialize(deserializer)?;
51 if value == 0 {
52 return Err(serde::de::Error::custom("sweep_batch_size must be >= 1"));
53 }
54 Ok(value)
55}
56
57fn default_tier_promotion_min_sessions() -> u32 {
58 3
59}
60
61fn default_tier_similarity_threshold() -> f32 {
62 0.92
63}
64
65fn default_tier_sweep_interval_secs() -> u64 {
66 3600
67}
68
69fn default_tier_sweep_batch_size() -> usize {
70 100
71}
72
73fn default_scene_similarity_threshold() -> f32 {
74 0.80
75}
76
77fn default_scene_batch_size() -> usize {
78 50
79}
80
81fn validate_scene_similarity_threshold<'de, D>(deserializer: D) -> Result<f32, D::Error>
82where
83 D: serde::Deserializer<'de>,
84{
85 let value = <f32 as serde::Deserialize>::deserialize(deserializer)?;
86 if value.is_nan() || value.is_infinite() {
87 return Err(serde::de::Error::custom(
88 "scene_similarity_threshold must be a finite number",
89 ));
90 }
91 if !(0.5..=1.0).contains(&value) {
92 return Err(serde::de::Error::custom(
93 "scene_similarity_threshold must be in [0.5, 1.0]",
94 ));
95 }
96 Ok(value)
97}
98
99fn validate_scene_batch_size<'de, D>(deserializer: D) -> Result<usize, D::Error>
100where
101 D: serde::Deserializer<'de>,
102{
103 let value = <usize as serde::Deserialize>::deserialize(deserializer)?;
104 if value == 0 {
105 return Err(serde::de::Error::custom("scene_batch_size must be >= 1"));
106 }
107 Ok(value)
108}
109
110/// Configuration for the AOI three-layer memory tier promotion system (`[memory.tiers]`).
111///
112/// When `enabled = true`, a background sweep promotes frequently-accessed episodic messages
113/// to semantic tier by clustering near-duplicates and distilling them via an LLM call.
114///
115/// # Validation
116///
117/// Constraints enforced at deserialization time:
118/// - `similarity_threshold` in `[0.5, 1.0]`
119/// - `promotion_min_sessions >= 2`
120/// - `sweep_batch_size >= 1`
121/// - `scene_similarity_threshold` in `[0.5, 1.0]`
122/// - `scene_batch_size >= 1`
123#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
124#[serde(default)]
125pub struct TierConfig {
126 /// Enable the tier promotion system. When `false`, all messages remain episodic.
127 /// Default: `false`.
128 pub enabled: bool,
129 /// Minimum number of distinct sessions a fact must appear in before promotion.
130 /// Must be `>= 2`. Default: `3`.
131 #[serde(deserialize_with = "validate_tier_promotion_min_sessions")]
132 pub promotion_min_sessions: u32,
133 /// Cosine similarity threshold for clustering near-duplicate facts during sweep.
134 /// Must be in `[0.5, 1.0]`. Default: `0.92`.
135 #[serde(deserialize_with = "validate_tier_similarity_threshold")]
136 pub similarity_threshold: f32,
137 /// How often the background promotion sweep runs, in seconds. Default: `3600`.
138 pub sweep_interval_secs: u64,
139 /// Maximum number of messages to evaluate per sweep cycle. Must be `>= 1`. Default: `100`.
140 #[serde(deserialize_with = "validate_tier_sweep_batch_size")]
141 pub sweep_batch_size: usize,
142 /// Enable `MemScene` consolidation of semantic-tier messages. Default: `false`.
143 pub scene_enabled: bool,
144 /// Cosine similarity threshold for `MemScene` clustering. Must be in `[0.5, 1.0]`. Default: `0.80`.
145 #[serde(deserialize_with = "validate_scene_similarity_threshold")]
146 pub scene_similarity_threshold: f32,
147 /// Maximum unassigned semantic messages processed per scene consolidation sweep. Default: `50`.
148 #[serde(deserialize_with = "validate_scene_batch_size")]
149 pub scene_batch_size: usize,
150 /// Provider name from `[[llm.providers]]` for scene label/profile generation.
151 /// Falls back to the primary provider when empty. Default: `""`.
152 pub scene_provider: ProviderName,
153 /// How often the background scene consolidation sweep runs, in seconds. Default: `7200`.
154 pub scene_sweep_interval_secs: u64,
155}
156
157fn default_scene_sweep_interval_secs() -> u64 {
158 7200
159}
160
161impl Default for TierConfig {
162 fn default() -> Self {
163 Self {
164 enabled: false,
165 promotion_min_sessions: default_tier_promotion_min_sessions(),
166 similarity_threshold: default_tier_similarity_threshold(),
167 sweep_interval_secs: default_tier_sweep_interval_secs(),
168 sweep_batch_size: default_tier_sweep_batch_size(),
169 scene_enabled: false,
170 scene_similarity_threshold: default_scene_similarity_threshold(),
171 scene_batch_size: default_scene_batch_size(),
172 scene_provider: ProviderName::default(),
173 scene_sweep_interval_secs: default_scene_sweep_interval_secs(),
174 }
175 }
176}
177
178// ── MemFlow tiered retrieval config (issue #3712) ──────────────────────────────
179
180/// `MemFlow` tiered intent-driven retrieval configuration.
181///
182/// Classifies each recall query into one of three intent tiers (`ProfileLookup`,
183/// `TargetedRetrieval`, `DeepReasoning`) and dispatches to the cheapest sufficient backend.
184/// An optional validation step can escalate to a heavier tier when evidence confidence is low.
185///
186/// # Example (TOML)
187///
188/// ```toml
189/// [memory.tiered_retrieval]
190/// enabled = false
191/// classifier_provider = ""
192/// validator_provider = ""
193/// token_budget = 4096
194/// validation_enabled = false
195/// validation_threshold = 0.6
196/// max_escalations = 1
197/// classifier_timeout_secs = 5
198/// validator_timeout_secs = 5
199///
200/// # Signal weights (all default to 0.0; set to activate each signal)
201/// similarity_weight = 1.0
202/// recency_weight = 0.0
203/// recency_half_life_days = 7
204/// tfidf_weight = 0.0
205/// cognitive_signal_weight = 0.0
206/// tier_boost_weight = 0.0
207/// semantic_tier_boost = 1.0
208/// ```
209#[derive(Debug, Clone, Deserialize, Serialize)]
210#[serde(default)]
211pub struct TieredRetrievalConfig {
212 /// Enable `MemFlow` tiered retrieval. Default: `false`.
213 pub enabled: bool,
214 /// Provider name from `[[llm.providers]]` for intent classification.
215 ///
216 /// When empty, the `HeuristicRouter` is used (no LLM call). When a provider
217 /// is set but the call fails, falls back to the heuristic (fail-open).
218 pub classifier_provider: ProviderName,
219 /// Provider name from `[[llm.providers]]` for evidence validation.
220 ///
221 /// When empty or when `validation_enabled = false`, no validation call is made.
222 pub validator_provider: ProviderName,
223 /// Maximum tokens to gather for evidence per query. Default: `4096`.
224 pub token_budget: usize,
225 /// Enable evidence validation and tier escalation. Default: `false`.
226 pub validation_enabled: bool,
227 /// Confidence threshold below which validation triggers tier escalation. Default: `0.6`.
228 pub validation_threshold: f32,
229 /// Maximum tier escalations per query. Default: `1`.
230 pub max_escalations: u8,
231 /// Timeout in seconds for the classifier LLM call. Default: `5`.
232 ///
233 /// On timeout the pipeline falls back to the `HeuristicRouter` (fail-open).
234 pub classifier_timeout_secs: u64,
235 /// Timeout in seconds for the validator LLM call. Default: `5`.
236 ///
237 /// On timeout the validator is treated as sufficient (fail-open).
238 pub validator_timeout_secs: u64,
239
240 // ── Signal weights ────────────────────────────────────────────────────────
241 /// Weight applied to the raw similarity score from vector/keyword recall. Default: `1.0`.
242 ///
243 /// Set to `1.0` and all other weights to `0.0` to reproduce pre-signal behaviour.
244 pub similarity_weight: f64,
245 /// Weight applied to the recency decay signal. Default: `0.0` (disabled).
246 pub recency_weight: f64,
247 /// Half-life for recency decay in days. Default: `7`.
248 ///
249 /// A message that is `recency_half_life_days` old receives a recency score of `0.5`.
250 /// Set `recency_weight = 0.0` to disable recency scoring entirely.
251 pub recency_half_life_days: u32,
252 /// Weight applied to the TF-IDF signal. Default: `0.0` (disabled).
253 pub tfidf_weight: f64,
254 /// Weight applied to the cognitive signal (message access frequency). Default: `0.0` (disabled).
255 pub cognitive_signal_weight: f64,
256 /// Weight applied to the tier boost signal for consolidated/semantic entries. Default: `0.0` (disabled).
257 pub tier_boost_weight: f64,
258 /// Additive score awarded to entries in the `semantic` tier when `tier_boost_weight > 0`. Default: `1.0`.
259 ///
260 /// The final contribution is `tier_boost_weight * semantic_tier_boost` for semantic entries
261 /// and `0.0` for episodic entries.
262 pub semantic_tier_boost: f64,
263 /// Route the `DeepReasoning` tier graph step through query-conditioned recall (#3994).
264 ///
265 /// When `true`, the graph recall step for `IntentClass::DeepReasoning` uses
266 /// `recall_graph_hela` (HELA spreading activation) instead of static-weight BFS,
267 /// producing query-aligned results. Requires an embedding store. Default: `false` (opt-in).
268 #[serde(default)]
269 pub deep_reasoning_query_conditioned: bool,
270}
271
272impl Default for TieredRetrievalConfig {
273 fn default() -> Self {
274 Self {
275 enabled: false,
276 classifier_provider: ProviderName::default(),
277 validator_provider: ProviderName::default(),
278 token_budget: 4096,
279 validation_enabled: false,
280 validation_threshold: 0.6,
281 max_escalations: 1,
282 classifier_timeout_secs: 5,
283 validator_timeout_secs: 5,
284 similarity_weight: 1.0,
285 recency_weight: 0.0,
286 recency_half_life_days: 7,
287 tfidf_weight: 0.0,
288 cognitive_signal_weight: 0.0,
289 tier_boost_weight: 0.0,
290 semantic_tier_boost: 1.0,
291 deep_reasoning_query_conditioned: false,
292 }
293 }
294}
295
296fn default_retrieval_failures_low_confidence_threshold() -> f32 {
297 0.3
298}
299
300fn default_retrieval_failures_retention_days() -> u32 {
301 90
302}
303
304fn default_retrieval_failures_channel_capacity() -> usize {
305 256
306}
307
308fn default_retrieval_failures_batch_size() -> usize {
309 16
310}
311
312fn default_retrieval_failures_flush_interval_ms() -> u64 {
313 100
314}
315
316/// Memory snippet rendering format injected into agent context (MM-F5, #3340).
317///
318/// Controls how each recalled memory entry is presented in the assembled prompt.
319/// Flipping this value does not affect stored content — `SQLite` rows and Qdrant points
320/// always contain the raw message text. The format is applied exclusively during
321/// context assembly and is never persisted.
322///
323/// # Token cost
324///
325/// `Structured` headers add roughly 2–3× more tokens per entry than `Plain`.
326/// Consider raising `memory.recall_tokens` proportionally when switching to `Structured`.
327#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq, Hash)]
328#[serde(rename_all = "snake_case")]
329#[non_exhaustive]
330pub enum ContextFormat {
331 /// Emit a labeled header per snippet:
332 /// `[Memory | <source> | <date> | relevance: <score>]` followed by the content.
333 ///
334 /// This is the default. Gives the LLM structured provenance metadata for each recalled
335 /// memory without re-parsing the recall body.
336 #[default]
337 Structured,
338 /// Legacy plain format: `- [role] content` per snippet, byte-identical to pre-#3340.
339 ///
340 /// Use `Plain` when downstream consumers rely on the old format or when token budget
341 /// is tight and provenance headers are not needed.
342 Plain,
343}
344
345/// Retrieval-stage tuning for semantic memory (MemMachine-inspired, #3340).
346///
347/// Controls ANN candidate depth, search-prompt template, and memory snippet rendering.
348/// Nested under `[memory.retrieval]` in TOML. All fields have defaults so existing
349/// configs parse unchanged.
350///
351/// # Example (TOML)
352///
353/// ```toml
354/// [memory.retrieval]
355/// # depth = 0 # 0 = legacy (recall_limit * 2); set ≥ 1 to override directly
356/// # search_prompt_template = ""
357/// # context_format = "structured"
358/// ```
359#[derive(Debug, Clone, Deserialize, Serialize)]
360#[serde(default)]
361pub struct RetrievalConfig {
362 /// Number of ANN candidates fetched from the vector store before keyword merge,
363 /// temporal decay, and MMR re-ranking.
364 ///
365 /// - `0` (default): legacy behavior — `recall_limit * 2` candidates, byte-identical
366 /// to pre-#3340 deployments.
367 /// - `≥ 1`: the configured value is passed directly to `qdrant.search` /
368 /// `keyword_search`. Set to at least `recall_limit * 2` to match the legacy pool
369 /// size, or higher for better MMR diversity.
370 ///
371 /// A value below `recall_limit` triggers a one-shot WARN because the ANN pool
372 /// cannot saturate the requested top-k.
373 pub depth: u32,
374 /// Template applied to the raw user query before embedding.
375 ///
376 /// Supports a single `{query}` placeholder which is replaced with the raw query string.
377 /// Empty string (default) = identity: the query is embedded as-is.
378 ///
379 /// Applied **only** at query-side embedding sites — stored content (summaries, documents)
380 /// is never wrapped. Use this for asymmetric embedding models (e.g. E5 `"query: {query}"`).
381 pub search_prompt_template: String,
382 /// Shape of memory snippets injected into agent context.
383 ///
384 /// See [`ContextFormat`] for the exact rendering and token-cost implications.
385 /// Default: `Structured`.
386 pub context_format: ContextFormat,
387 /// Enable query-bias correction towards the user's profile centroid (MM-F3, #3341).
388 ///
389 /// When `true` and the query is classified as first-person, the query embedding is
390 /// shifted towards the centroid of persona-fact embeddings. This nudges recall results
391 /// towards persona-relevant content for self-referential queries.
392 ///
393 /// Default: `true` (low blast-radius: no-op when the persona table is empty).
394 #[serde(default = "default_query_bias_correction")]
395 pub query_bias_correction: bool,
396 /// Blend weight for query-bias correction (MM-F3, #3341).
397 ///
398 /// Controls how much the query embedding shifts towards the profile centroid.
399 /// `0.0` = no shift; `1.0` = full centroid. Clamped to `[0.0, 1.0]`. Default: `0.25`.
400 #[serde(default = "default_query_bias_profile_weight")]
401 pub query_bias_profile_weight: f32,
402 /// Centroid TTL in seconds (MM-F3, #3341).
403 ///
404 /// The profile centroid computed from persona facts is cached for this many seconds.
405 /// After expiry it is recomputed on the next first-person query. Default: 300 (5 min).
406 #[serde(default = "default_query_bias_centroid_ttl_secs")]
407 pub query_bias_centroid_ttl_secs: u64,
408}
409
410fn default_query_bias_correction() -> bool {
411 true
412}
413
414fn default_query_bias_profile_weight() -> f32 {
415 0.25
416}
417
418fn default_query_bias_centroid_ttl_secs() -> u64 {
419 300
420}
421
422impl Default for RetrievalConfig {
423 fn default() -> Self {
424 Self {
425 depth: 0,
426 search_prompt_template: String::new(),
427 context_format: ContextFormat::default(),
428 query_bias_correction: default_query_bias_correction(),
429 query_bias_profile_weight: default_query_bias_profile_weight(),
430 query_bias_centroid_ttl_secs: default_query_bias_centroid_ttl_secs(),
431 }
432 }
433}
434
435fn default_consolidation_confidence_threshold() -> f32 {
436 0.7
437}
438
439fn default_consolidation_sweep_interval_secs() -> u64 {
440 3600
441}
442
443fn default_consolidation_sweep_batch_size() -> usize {
444 50
445}
446
447fn default_consolidation_similarity_threshold() -> f32 {
448 0.85
449}
450
451/// Configuration for the All-Mem lifelong memory consolidation sweep (`[memory.consolidation]`).
452///
453/// When `enabled = true`, a background loop periodically clusters semantically similar messages
454/// and merges them into consolidated entries via an LLM call. Originals are never deleted —
455/// they are marked as consolidated and deprioritized in recall via temporal decay.
456#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
457#[serde(default)]
458pub struct ConsolidationConfig {
459 /// Enable the consolidation background loop. Default: `false`.
460 pub enabled: bool,
461 /// Provider name from `[[llm.providers]]` for consolidation LLM calls.
462 /// Falls back to the primary provider when empty. Default: `""`.
463 #[serde(default)]
464 pub consolidation_provider: ProviderName,
465 /// Minimum LLM-assigned confidence for a topology op to be applied. Default: `0.7`.
466 #[serde(default = "default_consolidation_confidence_threshold")]
467 pub confidence_threshold: f32,
468 /// How often the background consolidation sweep runs, in seconds. Default: `3600`.
469 #[serde(default = "default_consolidation_sweep_interval_secs")]
470 pub sweep_interval_secs: u64,
471 /// Maximum number of messages to evaluate per sweep cycle. Default: `50`.
472 #[serde(default = "default_consolidation_sweep_batch_size")]
473 pub sweep_batch_size: usize,
474 /// Minimum cosine similarity for two messages to be considered consolidation candidates.
475 /// Default: `0.85`.
476 #[serde(default = "default_consolidation_similarity_threshold")]
477 pub similarity_threshold: f32,
478 /// LLM call timeout per `propose_merge_op` invocation, in seconds. Default: `30`.
479 #[serde(default = "default_consolidation_llm_timeout_secs")]
480 pub llm_timeout_secs: u64,
481 /// Per-call timeout for every `embed()` invocation in the consolidation sweep, in seconds.
482 /// Default: `5`.
483 #[serde(default = "default_embed_timeout_secs")]
484 pub embed_timeout_secs: u64,
485}
486
487impl Default for ConsolidationConfig {
488 fn default() -> Self {
489 Self {
490 enabled: false,
491 consolidation_provider: ProviderName::default(),
492 confidence_threshold: default_consolidation_confidence_threshold(),
493 sweep_interval_secs: default_consolidation_sweep_interval_secs(),
494 sweep_batch_size: default_consolidation_sweep_batch_size(),
495 similarity_threshold: default_consolidation_similarity_threshold(),
496 llm_timeout_secs: default_consolidation_llm_timeout_secs(),
497 embed_timeout_secs: default_embed_timeout_secs(),
498 }
499 }
500}
501
502fn default_consolidation_llm_timeout_secs() -> u64 {
503 30
504}
505
506fn default_admission_threshold() -> f32 {
507 0.40
508}
509
510fn default_admission_fast_path_margin() -> f32 {
511 0.15
512}
513
514fn default_rl_min_samples() -> u32 {
515 500
516}
517
518fn default_rl_retrain_interval_secs() -> u64 {
519 3600
520}
521
522/// Admission decision strategy.
523///
524/// `Heuristic` uses the existing multi-factor weighted score with an optional LLM call.
525/// `Rl` replaces the LLM-based `future_utility` factor with a trained logistic regression model.
526#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
527#[serde(rename_all = "snake_case")]
528#[non_exhaustive]
529pub enum AdmissionStrategy {
530 /// Current A-MAC behavior: weighted heuristics + optional LLM call. Default.
531 #[default]
532 Heuristic,
533 /// Learned model: logistic regression trained on recall feedback.
534 ///
535 /// **Not yet wired to a runtime scorer** (#2416/#5543): no admission code path scores
536 /// with a learned model today, so selecting this variant falls back to
537 /// [`AdmissionStrategy::Heuristic`] scoring. `src/bootstrap/mod.rs`'s
538 /// `build_admission_control` emits a `tracing::warn!` at startup when this variant is
539 /// selected, so the fallback is operator-visible rather than silent.
540 ///
541 /// The training write path this strategy would need — `record_admission_training`,
542 /// `save_rl_weights`, `load_rl_weights`, and `cleanup_old_training_data` in
543 /// `zeph_memory::store::admission_training` — has no production caller, so even once a
544 /// scorer is wired, there is no training data to train it on yet. The `rl_min_samples` /
545 /// `rl_retrain_interval_secs` fields and the `admission_training_data` /
546 /// `admission_rl_weights` storage tables remain as scaffolding for that future work.
547 Rl,
548}
549
550fn validate_admission_weight<'de, D>(deserializer: D) -> Result<f32, D::Error>
551where
552 D: serde::Deserializer<'de>,
553{
554 let value = <f32 as serde::Deserialize>::deserialize(deserializer)?;
555 if value < 0.0 {
556 return Err(serde::de::Error::custom(
557 "admission weight must be non-negative (>= 0.0)",
558 ));
559 }
560 Ok(value)
561}
562
563/// Per-factor weights for the A-MAC admission score (`[memory.admission.weights]`).
564///
565/// Weights are normalized at runtime (divided by their sum), so they do not need to sum to 1.0.
566/// All values must be non-negative.
567#[derive(Debug, Clone, Deserialize, Serialize)]
568#[serde(default)]
569pub struct AdmissionWeights {
570 /// LLM-estimated future reuse probability. Default: `0.30`.
571 #[serde(deserialize_with = "validate_admission_weight")]
572 pub future_utility: f32,
573 /// Factual confidence heuristic (inverse of hedging markers). Default: `0.15`.
574 #[serde(deserialize_with = "validate_admission_weight")]
575 pub factual_confidence: f32,
576 /// Semantic novelty: 1 - max similarity to existing memories. Default: `0.30`.
577 #[serde(deserialize_with = "validate_admission_weight")]
578 pub semantic_novelty: f32,
579 /// Temporal recency: always 1.0 at write time. Default: `0.10`.
580 #[serde(deserialize_with = "validate_admission_weight")]
581 pub temporal_recency: f32,
582 /// Content type prior based on role. Default: `0.15`.
583 #[serde(deserialize_with = "validate_admission_weight")]
584 pub content_type_prior: f32,
585 /// Goal-conditioned utility (#2408). `0.0` when `goal_conditioned_write = false`.
586 /// When enabled, set this alongside reducing `future_utility` so total sums remain stable.
587 /// Normalized automatically at runtime. Default: `0.0`.
588 #[serde(deserialize_with = "validate_admission_weight")]
589 pub goal_utility: f32,
590}
591
592impl Default for AdmissionWeights {
593 fn default() -> Self {
594 Self {
595 future_utility: 0.30,
596 factual_confidence: 0.15,
597 semantic_novelty: 0.30,
598 temporal_recency: 0.10,
599 content_type_prior: 0.15,
600 goal_utility: 0.0,
601 }
602 }
603}
604
605impl AdmissionWeights {
606 /// Return weights normalized so they sum to 1.0.
607 ///
608 /// All weights are non-negative; the sum is always > 0 when defaults are used.
609 #[must_use]
610 pub fn normalized(&self) -> Self {
611 let sum = self.future_utility
612 + self.factual_confidence
613 + self.semantic_novelty
614 + self.temporal_recency
615 + self.content_type_prior
616 + self.goal_utility;
617 if sum <= f32::EPSILON {
618 return Self::default();
619 }
620 Self {
621 future_utility: self.future_utility / sum,
622 factual_confidence: self.factual_confidence / sum,
623 semantic_novelty: self.semantic_novelty / sum,
624 temporal_recency: self.temporal_recency / sum,
625 content_type_prior: self.content_type_prior / sum,
626 goal_utility: self.goal_utility / sum,
627 }
628 }
629}
630
631/// Configuration for A-MAC adaptive memory admission control (`[memory.admission]` TOML section).
632///
633/// When `enabled = true`, a write-time gate evaluates each message before saving to memory.
634/// Messages below the composite admission threshold are rejected and not persisted.
635#[derive(Debug, Clone, Deserialize, Serialize)]
636#[serde(default)]
637pub struct AdmissionConfig {
638 /// Enable A-MAC admission control. Default: `false`.
639 pub enabled: bool,
640 /// Composite score threshold below which messages are rejected. Range: `[0.0, 1.0]`.
641 /// Default: `0.40`.
642 #[serde(deserialize_with = "crate::de_helpers::de_unit_closed")]
643 pub threshold: f32,
644 /// Margin above threshold at which the fast path admits without an LLM call. Range: `[0.0, 1.0]`.
645 /// When heuristic score >= threshold + margin, LLM call is skipped. Default: `0.15`.
646 #[serde(deserialize_with = "crate::de_helpers::de_unit_closed")]
647 pub fast_path_margin: f32,
648 /// Provider name from `[[llm.providers]]` for `future_utility` LLM evaluation.
649 /// Falls back to the primary provider when empty. Default: `""`.
650 pub admission_provider: ProviderName,
651 /// Per-factor weights. Normalized at runtime. Default: `{0.30, 0.15, 0.30, 0.10, 0.15}`.
652 pub weights: AdmissionWeights,
653 /// Admission decision strategy. Default: `heuristic`.
654 #[serde(default)]
655 pub admission_strategy: AdmissionStrategy,
656 /// Minimum training samples before the RL model is activated.
657 /// Below this count the system falls back to `Heuristic`. Default: `500`.
658 #[serde(default = "default_rl_min_samples")]
659 pub rl_min_samples: u32,
660 /// Background RL model retraining interval in seconds. Default: `3600`.
661 #[serde(default = "default_rl_retrain_interval_secs")]
662 pub rl_retrain_interval_secs: u64,
663 /// Enable goal-conditioned write gate (#2408). When `true`, memories are scored
664 /// against the current task goal and rejected if relevance is below `goal_utility_threshold`.
665 /// Zero regression when `false`. Default: `false`.
666 #[serde(default)]
667 pub goal_conditioned_write: bool,
668 /// Provider name from `[[llm.providers]]` for goal-utility LLM refinement.
669 /// Used only for borderline cases (similarity within 0.1 of threshold).
670 /// Falls back to the primary provider when empty. Default: `""`.
671 #[serde(default)]
672 pub goal_utility_provider: ProviderName,
673 /// Minimum cosine similarity between goal embedding and candidate memory
674 /// to consider it goal-relevant. Below this, `goal_utility = 0.0`. Default: `0.4`.
675 #[serde(default = "default_goal_utility_threshold")]
676 pub goal_utility_threshold: f32,
677 /// Weight of the `goal_utility` factor in the composite admission score.
678 /// Set to `0.0` to disable (equivalent to `goal_conditioned_write = false`). Default: `0.25`.
679 #[serde(default = "default_goal_utility_weight")]
680 pub goal_utility_weight: f32,
681}
682
683fn default_goal_utility_threshold() -> f32 {
684 0.4
685}
686
687fn default_goal_utility_weight() -> f32 {
688 0.25
689}
690
691impl Default for AdmissionConfig {
692 fn default() -> Self {
693 Self {
694 enabled: false,
695 threshold: default_admission_threshold(),
696 fast_path_margin: default_admission_fast_path_margin(),
697 admission_provider: ProviderName::default(),
698 weights: AdmissionWeights::default(),
699 admission_strategy: AdmissionStrategy::default(),
700 rl_min_samples: default_rl_min_samples(),
701 rl_retrain_interval_secs: default_rl_retrain_interval_secs(),
702 goal_conditioned_write: false,
703 goal_utility_provider: ProviderName::default(),
704 goal_utility_threshold: default_goal_utility_threshold(),
705 goal_utility_weight: default_goal_utility_weight(),
706 }
707 }
708}
709
710/// Routing strategy for `[memory.store_routing]`.
711#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
712#[serde(rename_all = "snake_case")]
713#[non_exhaustive]
714pub enum StoreRoutingStrategy {
715 /// Pure heuristic pattern matching. Zero LLM calls. Default.
716 #[default]
717 Heuristic,
718 /// LLM-based classification via `routing_classifier_provider`.
719 Llm,
720 /// Heuristic first; escalates to LLM only when confidence is low.
721 Hybrid,
722}
723
724/// Configuration for cost-sensitive store routing (`[memory.store_routing]`).
725///
726/// Controls how each query is classified and routed to the appropriate memory
727/// backend(s), avoiding unnecessary store queries for simple lookups.
728#[derive(Debug, Clone, Deserialize, Serialize)]
729#[serde(default)]
730pub struct StoreRoutingConfig {
731 /// Enable configurable store routing. When `false`, `HeuristicRouter` is used
732 /// directly (existing behavior). Default: `false`.
733 pub enabled: bool,
734 /// Routing strategy. Default: `heuristic`.
735 pub strategy: StoreRoutingStrategy,
736 /// Provider name from `[[llm.providers]]` for LLM-based classification.
737 /// Falls back to the primary provider when empty. Default: `""`.
738 pub routing_classifier_provider: ProviderName,
739 /// Route to use when the classifier is uncertain (confidence < threshold).
740 ///
741 /// Defaults to [`MemoryRoute::Hybrid`].
742 pub fallback_route: MemoryRoute,
743 /// Confidence threshold below which `HybridRouter` escalates to LLM.
744 /// Range: `[0.0, 1.0]`. Default: `0.7`.
745 pub confidence_threshold: f32,
746}
747
748impl Default for StoreRoutingConfig {
749 fn default() -> Self {
750 Self {
751 enabled: false,
752 strategy: StoreRoutingStrategy::Heuristic,
753 routing_classifier_provider: ProviderName::default(),
754 fallback_route: MemoryRoute::Hybrid,
755 confidence_threshold: 0.7,
756 }
757 }
758}
759
760/// `OmniMem` retrieval failure tracking configuration (issue #3576).
761///
762/// Controls the async logger that records no-hit and low-confidence recall events
763/// to `memory_retrieval_failures` for closed-loop memory parameter tuning.
764#[derive(Debug, Clone, Deserialize, Serialize)]
765#[serde(default)]
766pub struct RetrievalFailuresConfig {
767 /// Enable retrieval failure logging. Default: `false`.
768 pub enabled: bool,
769 /// Composite recall score below which a result is classified as low-confidence.
770 ///
771 /// The threshold applies to the post-reranking composite score (which incorporates
772 /// MMR, temporal decay, importance weighting, and tier boost). Calibrate against
773 /// the scoring pipeline in use. Default: `0.3`.
774 #[serde(default = "default_retrieval_failures_low_confidence_threshold")]
775 pub low_confidence_threshold: f32,
776 /// Days to retain failure records before automatic cleanup. Default: `90`.
777 #[serde(default = "default_retrieval_failures_retention_days")]
778 pub retention_days: u32,
779 /// Bounded mpsc channel capacity for the fire-and-forget write path. Default: `256`.
780 #[serde(default = "default_retrieval_failures_channel_capacity")]
781 pub channel_capacity: usize,
782 /// Maximum records collected before flushing a batch INSERT. Default: `16`.
783 #[serde(default = "default_retrieval_failures_batch_size")]
784 pub batch_size: usize,
785 /// Maximum milliseconds to wait before flushing a partial batch. Default: `100`.
786 #[serde(default = "default_retrieval_failures_flush_interval_ms")]
787 pub flush_interval_ms: u64,
788}
789
790impl Default for RetrievalFailuresConfig {
791 fn default() -> Self {
792 Self {
793 enabled: false,
794 low_confidence_threshold: default_retrieval_failures_low_confidence_threshold(),
795 retention_days: default_retrieval_failures_retention_days(),
796 channel_capacity: default_retrieval_failures_channel_capacity(),
797 batch_size: default_retrieval_failures_batch_size(),
798 flush_interval_ms: default_retrieval_failures_flush_interval_ms(),
799 }
800 }
801}