Skip to main content

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}