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::{FunctionalType, 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
296// ── MemGuard type-aware retrieval-composition config (spec 004-16, issue #6086) ──────────────────
297
298/// `MemGuard`-inspired type-aware memory retrieval composition (spec 004-16, issue #6086).
299///
300/// Retrieval-only, fetch-time gate: it does **not** touch write paths, does not add a new
301/// Qdrant collection, and does not migrate stored data. When `enabled = false` (the default),
302/// `schedule_context_fetchers` in `zeph-context` composes exactly the same memory sources it
303/// does today — the empty active-type set produced by `enabled = false` is treated identically
304/// to an empty `default_compose_types`, both meaning "all types" (byte-for-byte no-op).
305///
306/// `BehavioralRule` (past-correction) recall is deliberately excluded from gating: it is
307/// safety-critical and is always composed regardless of this config.
308///
309/// # Example (TOML)
310///
311/// ```toml
312/// [memory.type_aware_compose]
313/// enabled = false
314/// default_compose_types = []
315/// intent_scoped = false
316/// ```
317#[derive(Debug, Clone, Default, Deserialize, Serialize)]
318#[serde(default)]
319pub struct TypeAwareComposeConfig {
320    /// Master switch. When `false` (default), context assembly composes all memory types
321    /// exactly as today. When `true`, only the types in the active set (`default_compose_types`
322    /// plus, when `intent_scoped`, the classified-intent widening) are composed.
323    pub enabled: bool,
324    /// Functional types composed under an un-specialized retrieval need.
325    ///
326    /// Empty (default) means *all types* — the same composition as today. Strict parse:
327    /// an unknown/typo'd type string is a hard config-load error, never a silent fallback
328    /// to "all types" (spec 004-16 §4, critic finding S4).
329    pub default_compose_types: Vec<FunctionalType>,
330    /// When `true`, additionally widen the active set per classified query intent using the
331    /// static `IntentClass -> FunctionalType[]` table (reuses the existing heuristic memory
332    /// router; adds no new LLM call). Default: `false`.
333    pub intent_scoped: bool,
334}
335
336fn default_retrieval_failures_low_confidence_threshold() -> f32 {
337    0.3
338}
339
340fn default_retrieval_failures_retention_days() -> u32 {
341    90
342}
343
344fn default_retrieval_failures_channel_capacity() -> usize {
345    256
346}
347
348fn default_retrieval_failures_batch_size() -> usize {
349    16
350}
351
352fn default_retrieval_failures_flush_interval_ms() -> u64 {
353    100
354}
355
356/// Memory snippet rendering format injected into agent context (MM-F5, #3340).
357///
358/// Controls how each recalled memory entry is presented in the assembled prompt.
359/// Flipping this value does not affect stored content — `SQLite` rows and Qdrant points
360/// always contain the raw message text. The format is applied exclusively during
361/// context assembly and is never persisted.
362///
363/// # Token cost
364///
365/// `Structured` headers add roughly 2–3× more tokens per entry than `Plain`.
366/// Consider raising `memory.recall_tokens` proportionally when switching to `Structured`.
367#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq, Hash)]
368#[serde(rename_all = "snake_case")]
369#[non_exhaustive]
370pub enum ContextFormat {
371    /// Emit a labeled header per snippet:
372    /// `[Memory | <source> | <date> | relevance: <score>]` followed by the content.
373    ///
374    /// This is the default. Gives the LLM structured provenance metadata for each recalled
375    /// memory without re-parsing the recall body.
376    #[default]
377    Structured,
378    /// Legacy plain format: `- [role] content` per snippet, byte-identical to pre-#3340.
379    ///
380    /// Use `Plain` when downstream consumers rely on the old format or when token budget
381    /// is tight and provenance headers are not needed.
382    Plain,
383}
384
385/// Retrieval-stage tuning for semantic memory (MemMachine-inspired, #3340).
386///
387/// Controls ANN candidate depth, search-prompt template, and memory snippet rendering.
388/// Nested under `[memory.retrieval]` in TOML.  All fields have defaults so existing
389/// configs parse unchanged.
390///
391/// # Example (TOML)
392///
393/// ```toml
394/// [memory.retrieval]
395/// # depth = 0          # 0 = legacy (recall_limit * 2); set ≥ 1 to override directly
396/// # search_prompt_template = ""
397/// # context_format = "structured"
398/// ```
399#[derive(Debug, Clone, Deserialize, Serialize)]
400#[serde(default)]
401pub struct RetrievalConfig {
402    /// Number of ANN candidates fetched from the vector store before keyword merge,
403    /// temporal decay, and MMR re-ranking.
404    ///
405    /// - `0` (default): legacy behavior — `recall_limit * 2` candidates, byte-identical
406    ///   to pre-#3340 deployments.
407    /// - `≥ 1`: the configured value is passed directly to `qdrant.search` /
408    ///   `keyword_search`. Set to at least `recall_limit * 2` to match the legacy pool
409    ///   size, or higher for better MMR diversity.
410    ///
411    /// A value below `recall_limit` triggers a one-shot WARN because the ANN pool
412    /// cannot saturate the requested top-k.
413    pub depth: u32,
414    /// Template applied to the raw user query before embedding.
415    ///
416    /// Supports a single `{query}` placeholder which is replaced with the raw query string.
417    /// Empty string (default) = identity: the query is embedded as-is.
418    ///
419    /// Applied **only** at query-side embedding sites — stored content (summaries, documents)
420    /// is never wrapped.  Use this for asymmetric embedding models (e.g. E5 `"query: {query}"`).
421    pub search_prompt_template: String,
422    /// Shape of memory snippets injected into agent context.
423    ///
424    /// See [`ContextFormat`] for the exact rendering and token-cost implications.
425    /// Default: `Structured`.
426    pub context_format: ContextFormat,
427    /// Enable query-bias correction towards the user's profile centroid (MM-F3, #3341).
428    ///
429    /// When `true` and the query is classified as first-person, the query embedding is
430    /// shifted towards the centroid of persona-fact embeddings. This nudges recall results
431    /// towards persona-relevant content for self-referential queries.
432    ///
433    /// Default: `true` (low blast-radius: no-op when the persona table is empty).
434    #[serde(default = "default_query_bias_correction")]
435    pub query_bias_correction: bool,
436    /// Blend weight for query-bias correction (MM-F3, #3341).
437    ///
438    /// Controls how much the query embedding shifts towards the profile centroid.
439    /// `0.0` = no shift; `1.0` = full centroid. Clamped to `[0.0, 1.0]`. Default: `0.25`.
440    #[serde(default = "default_query_bias_profile_weight")]
441    pub query_bias_profile_weight: f32,
442    /// Centroid TTL in seconds (MM-F3, #3341).
443    ///
444    /// The profile centroid computed from persona facts is cached for this many seconds.
445    /// After expiry it is recomputed on the next first-person query. Default: 300 (5 min).
446    #[serde(default = "default_query_bias_centroid_ttl_secs")]
447    pub query_bias_centroid_ttl_secs: u64,
448}
449
450fn default_query_bias_correction() -> bool {
451    true
452}
453
454fn default_query_bias_profile_weight() -> f32 {
455    0.25
456}
457
458fn default_query_bias_centroid_ttl_secs() -> u64 {
459    300
460}
461
462impl Default for RetrievalConfig {
463    fn default() -> Self {
464        Self {
465            depth: 0,
466            search_prompt_template: String::new(),
467            context_format: ContextFormat::default(),
468            query_bias_correction: default_query_bias_correction(),
469            query_bias_profile_weight: default_query_bias_profile_weight(),
470            query_bias_centroid_ttl_secs: default_query_bias_centroid_ttl_secs(),
471        }
472    }
473}
474
475fn default_consolidation_confidence_threshold() -> f32 {
476    0.7
477}
478
479fn default_consolidation_sweep_interval_secs() -> u64 {
480    3600
481}
482
483fn default_consolidation_sweep_batch_size() -> usize {
484    50
485}
486
487fn default_consolidation_similarity_threshold() -> f32 {
488    0.85
489}
490
491/// Configuration for the All-Mem lifelong memory consolidation sweep (`[memory.consolidation]`).
492///
493/// When `enabled = true`, a background loop periodically clusters semantically similar messages
494/// and merges them into consolidated entries via an LLM call. Originals are never deleted —
495/// they are marked as consolidated and deprioritized in recall via temporal decay.
496#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
497#[serde(default)]
498pub struct ConsolidationConfig {
499    /// Enable the consolidation background loop. Default: `false`.
500    pub enabled: bool,
501    /// Provider name from `[[llm.providers]]` for consolidation LLM calls.
502    /// Falls back to the primary provider when empty. Default: `""`.
503    #[serde(default)]
504    pub consolidation_provider: ProviderName,
505    /// Minimum LLM-assigned confidence for a topology op to be applied. Default: `0.7`.
506    #[serde(default = "default_consolidation_confidence_threshold")]
507    pub confidence_threshold: f32,
508    /// How often the background consolidation sweep runs, in seconds. Default: `3600`.
509    #[serde(default = "default_consolidation_sweep_interval_secs")]
510    pub sweep_interval_secs: u64,
511    /// Maximum number of messages to evaluate per sweep cycle. Default: `50`.
512    #[serde(default = "default_consolidation_sweep_batch_size")]
513    pub sweep_batch_size: usize,
514    /// Minimum cosine similarity for two messages to be considered consolidation candidates.
515    /// Default: `0.85`.
516    #[serde(default = "default_consolidation_similarity_threshold")]
517    pub similarity_threshold: f32,
518    /// LLM call timeout per `propose_merge_op` invocation, in seconds. Default: `30`.
519    #[serde(default = "default_consolidation_llm_timeout_secs")]
520    pub llm_timeout_secs: u64,
521    /// Per-call timeout for every `embed()` invocation in the consolidation sweep, in seconds.
522    /// Default: `5`.
523    #[serde(default = "default_embed_timeout_secs")]
524    pub embed_timeout_secs: u64,
525}
526
527impl Default for ConsolidationConfig {
528    fn default() -> Self {
529        Self {
530            enabled: false,
531            consolidation_provider: ProviderName::default(),
532            confidence_threshold: default_consolidation_confidence_threshold(),
533            sweep_interval_secs: default_consolidation_sweep_interval_secs(),
534            sweep_batch_size: default_consolidation_sweep_batch_size(),
535            similarity_threshold: default_consolidation_similarity_threshold(),
536            llm_timeout_secs: default_consolidation_llm_timeout_secs(),
537            embed_timeout_secs: default_embed_timeout_secs(),
538        }
539    }
540}
541
542fn default_consolidation_llm_timeout_secs() -> u64 {
543    30
544}
545
546fn default_admission_threshold() -> f32 {
547    0.40
548}
549
550fn default_admission_fast_path_margin() -> f32 {
551    0.15
552}
553
554fn default_rl_min_samples() -> u32 {
555    500
556}
557
558fn default_rl_retrain_interval_secs() -> u64 {
559    3600
560}
561
562/// Admission decision strategy.
563///
564/// `Heuristic` uses the existing multi-factor weighted score with an optional LLM call.
565/// `Rl` replaces the LLM-based `future_utility` factor with a trained logistic regression model.
566#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
567#[serde(rename_all = "snake_case")]
568#[non_exhaustive]
569pub enum AdmissionStrategy {
570    /// Current A-MAC behavior: weighted heuristics + optional LLM call. Default.
571    #[default]
572    Heuristic,
573    /// Learned model: logistic regression trained on recall feedback.
574    ///
575    /// **Not yet wired to a runtime scorer** (#2416/#5543): no admission code path scores
576    /// with a learned model today, so selecting this variant falls back to
577    /// [`AdmissionStrategy::Heuristic`] scoring. `src/bootstrap/mod.rs`'s
578    /// `build_admission_control` emits a `tracing::warn!` at startup when this variant is
579    /// selected, so the fallback is operator-visible rather than silent.
580    ///
581    /// The training write path this strategy would need — `record_admission_training`,
582    /// `save_rl_weights`, `load_rl_weights`, and `cleanup_old_training_data` in
583    /// `zeph_memory::store::admission_training` — has no production caller, so even once a
584    /// scorer is wired, there is no training data to train it on yet. The `rl_min_samples` /
585    /// `rl_retrain_interval_secs` fields and the `admission_training_data` /
586    /// `admission_rl_weights` storage tables remain as scaffolding for that future work.
587    Rl,
588}
589
590fn validate_admission_weight<'de, D>(deserializer: D) -> Result<f32, D::Error>
591where
592    D: serde::Deserializer<'de>,
593{
594    let value = <f32 as serde::Deserialize>::deserialize(deserializer)?;
595    if value < 0.0 {
596        return Err(serde::de::Error::custom(
597            "admission weight must be non-negative (>= 0.0)",
598        ));
599    }
600    Ok(value)
601}
602
603/// Per-factor weights for the A-MAC admission score (`[memory.admission.weights]`).
604///
605/// Weights are normalized at runtime (divided by their sum), so they do not need to sum to 1.0.
606/// All values must be non-negative.
607#[derive(Debug, Clone, Deserialize, Serialize)]
608#[serde(default)]
609pub struct AdmissionWeights {
610    /// LLM-estimated future reuse probability. Default: `0.30`.
611    #[serde(deserialize_with = "validate_admission_weight")]
612    pub future_utility: f32,
613    /// Factual confidence heuristic (inverse of hedging markers). Default: `0.15`.
614    #[serde(deserialize_with = "validate_admission_weight")]
615    pub factual_confidence: f32,
616    /// Semantic novelty: 1 - max similarity to existing memories. Default: `0.30`.
617    #[serde(deserialize_with = "validate_admission_weight")]
618    pub semantic_novelty: f32,
619    /// Temporal recency: always 1.0 at write time. Default: `0.10`.
620    #[serde(deserialize_with = "validate_admission_weight")]
621    pub temporal_recency: f32,
622    /// Content type prior based on role. Default: `0.15`.
623    #[serde(deserialize_with = "validate_admission_weight")]
624    pub content_type_prior: f32,
625    /// Goal-conditioned utility (#2408). `0.0` when `goal_conditioned_write = false`.
626    /// When enabled, set this alongside reducing `future_utility` so total sums remain stable.
627    /// Normalized automatically at runtime. Default: `0.0`.
628    #[serde(deserialize_with = "validate_admission_weight")]
629    pub goal_utility: f32,
630}
631
632impl Default for AdmissionWeights {
633    fn default() -> Self {
634        Self {
635            future_utility: 0.30,
636            factual_confidence: 0.15,
637            semantic_novelty: 0.30,
638            temporal_recency: 0.10,
639            content_type_prior: 0.15,
640            goal_utility: 0.0,
641        }
642    }
643}
644
645impl AdmissionWeights {
646    /// Return weights normalized so they sum to 1.0.
647    ///
648    /// All weights are non-negative; the sum is always > 0 when defaults are used.
649    #[must_use]
650    pub fn normalized(&self) -> Self {
651        let sum = self.future_utility
652            + self.factual_confidence
653            + self.semantic_novelty
654            + self.temporal_recency
655            + self.content_type_prior
656            + self.goal_utility;
657        if sum <= f32::EPSILON {
658            return Self::default();
659        }
660        Self {
661            future_utility: self.future_utility / sum,
662            factual_confidence: self.factual_confidence / sum,
663            semantic_novelty: self.semantic_novelty / sum,
664            temporal_recency: self.temporal_recency / sum,
665            content_type_prior: self.content_type_prior / sum,
666            goal_utility: self.goal_utility / sum,
667        }
668    }
669}
670
671/// Configuration for A-MAC adaptive memory admission control (`[memory.admission]` TOML section).
672///
673/// When `enabled = true`, a write-time gate evaluates each message before saving to memory.
674/// Messages below the composite admission threshold are rejected and not persisted.
675#[derive(Debug, Clone, Deserialize, Serialize)]
676#[serde(default)]
677pub struct AdmissionConfig {
678    /// Enable A-MAC admission control. Default: `false`.
679    pub enabled: bool,
680    /// Composite score threshold below which messages are rejected. Range: `[0.0, 1.0]`.
681    /// Default: `0.40`.
682    #[serde(deserialize_with = "crate::de_helpers::de_unit_closed")]
683    pub threshold: f32,
684    /// Margin above threshold at which the fast path admits without an LLM call. Range: `[0.0, 1.0]`.
685    /// When heuristic score >= threshold + margin, LLM call is skipped. Default: `0.15`.
686    #[serde(deserialize_with = "crate::de_helpers::de_unit_closed")]
687    pub fast_path_margin: f32,
688    /// Provider name from `[[llm.providers]]` for `future_utility` LLM evaluation.
689    /// Falls back to the primary provider when empty. Default: `""`.
690    pub admission_provider: ProviderName,
691    /// Per-factor weights. Normalized at runtime. Default: `{0.30, 0.15, 0.30, 0.10, 0.15}`.
692    pub weights: AdmissionWeights,
693    /// Admission decision strategy. Default: `heuristic`.
694    #[serde(default)]
695    pub admission_strategy: AdmissionStrategy,
696    /// Minimum training samples before the RL model is activated.
697    /// Below this count the system falls back to `Heuristic`. Default: `500`.
698    #[serde(default = "default_rl_min_samples")]
699    pub rl_min_samples: u32,
700    /// Background RL model retraining interval in seconds. Default: `3600`.
701    #[serde(default = "default_rl_retrain_interval_secs")]
702    pub rl_retrain_interval_secs: u64,
703    /// Enable goal-conditioned write gate (#2408). When `true`, memories are scored
704    /// against the current task goal and rejected if relevance is below `goal_utility_threshold`.
705    /// Zero regression when `false`. Default: `false`.
706    #[serde(default)]
707    pub goal_conditioned_write: bool,
708    /// Provider name from `[[llm.providers]]` for goal-utility LLM refinement.
709    /// Used only for borderline cases (similarity within 0.1 of threshold).
710    /// Falls back to the primary provider when empty. Default: `""`.
711    #[serde(default)]
712    pub goal_utility_provider: ProviderName,
713    /// Minimum cosine similarity between goal embedding and candidate memory
714    /// to consider it goal-relevant. Below this, `goal_utility = 0.0`. Default: `0.4`.
715    #[serde(default = "default_goal_utility_threshold")]
716    pub goal_utility_threshold: f32,
717    /// Weight of the `goal_utility` factor in the composite admission score.
718    /// Set to `0.0` to disable (equivalent to `goal_conditioned_write = false`). Default: `0.25`.
719    #[serde(default = "default_goal_utility_weight")]
720    pub goal_utility_weight: f32,
721}
722
723fn default_goal_utility_threshold() -> f32 {
724    0.4
725}
726
727fn default_goal_utility_weight() -> f32 {
728    0.25
729}
730
731impl Default for AdmissionConfig {
732    fn default() -> Self {
733        Self {
734            enabled: false,
735            threshold: default_admission_threshold(),
736            fast_path_margin: default_admission_fast_path_margin(),
737            admission_provider: ProviderName::default(),
738            weights: AdmissionWeights::default(),
739            admission_strategy: AdmissionStrategy::default(),
740            rl_min_samples: default_rl_min_samples(),
741            rl_retrain_interval_secs: default_rl_retrain_interval_secs(),
742            goal_conditioned_write: false,
743            goal_utility_provider: ProviderName::default(),
744            goal_utility_threshold: default_goal_utility_threshold(),
745            goal_utility_weight: default_goal_utility_weight(),
746        }
747    }
748}
749
750/// Routing strategy for `[memory.store_routing]`.
751#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
752#[serde(rename_all = "snake_case")]
753#[non_exhaustive]
754pub enum StoreRoutingStrategy {
755    /// Pure heuristic pattern matching. Zero LLM calls. Default.
756    #[default]
757    Heuristic,
758    /// LLM-based classification via `routing_classifier_provider`.
759    Llm,
760    /// Heuristic first; escalates to LLM only when confidence is low.
761    Hybrid,
762}
763
764/// Configuration for cost-sensitive store routing (`[memory.store_routing]`).
765///
766/// Controls how each query is classified and routed to the appropriate memory
767/// backend(s), avoiding unnecessary store queries for simple lookups.
768#[derive(Debug, Clone, Deserialize, Serialize)]
769#[serde(default)]
770pub struct StoreRoutingConfig {
771    /// Enable configurable store routing. When `false`, `HeuristicRouter` is used
772    /// directly (existing behavior). Default: `false`.
773    pub enabled: bool,
774    /// Routing strategy. Default: `heuristic`.
775    pub strategy: StoreRoutingStrategy,
776    /// Provider name from `[[llm.providers]]` for LLM-based classification.
777    /// Falls back to the primary provider when empty. Default: `""`.
778    pub routing_classifier_provider: ProviderName,
779    /// Route to use when the classifier is uncertain (confidence < threshold).
780    ///
781    /// Defaults to [`MemoryRoute::Hybrid`].
782    pub fallback_route: MemoryRoute,
783    /// Confidence threshold below which `HybridRouter` escalates to LLM.
784    /// Range: `[0.0, 1.0]`. Default: `0.7`.
785    pub confidence_threshold: f32,
786}
787
788impl Default for StoreRoutingConfig {
789    fn default() -> Self {
790        Self {
791            enabled: false,
792            strategy: StoreRoutingStrategy::Heuristic,
793            routing_classifier_provider: ProviderName::default(),
794            fallback_route: MemoryRoute::Hybrid,
795            confidence_threshold: 0.7,
796        }
797    }
798}
799
800/// `OmniMem` retrieval failure tracking configuration (issue #3576).
801///
802/// Controls the async logger that records no-hit and low-confidence recall events
803/// to `memory_retrieval_failures` for closed-loop memory parameter tuning.
804#[derive(Debug, Clone, Deserialize, Serialize)]
805#[serde(default)]
806pub struct RetrievalFailuresConfig {
807    /// Enable retrieval failure logging. Default: `false`.
808    pub enabled: bool,
809    /// Composite recall score below which a result is classified as low-confidence.
810    ///
811    /// The threshold applies to the post-reranking composite score (which incorporates
812    /// MMR, temporal decay, importance weighting, and tier boost). Calibrate against
813    /// the scoring pipeline in use. Default: `0.3`.
814    #[serde(default = "default_retrieval_failures_low_confidence_threshold")]
815    pub low_confidence_threshold: f32,
816    /// Days to retain failure records before automatic cleanup. Default: `90`.
817    #[serde(default = "default_retrieval_failures_retention_days")]
818    pub retention_days: u32,
819    /// Bounded mpsc channel capacity for the fire-and-forget write path. Default: `256`.
820    #[serde(default = "default_retrieval_failures_channel_capacity")]
821    pub channel_capacity: usize,
822    /// Maximum records collected before flushing a batch INSERT. Default: `16`.
823    #[serde(default = "default_retrieval_failures_batch_size")]
824    pub batch_size: usize,
825    /// Maximum milliseconds to wait before flushing a partial batch. Default: `100`.
826    #[serde(default = "default_retrieval_failures_flush_interval_ms")]
827    pub flush_interval_ms: u64,
828}
829
830impl Default for RetrievalFailuresConfig {
831    fn default() -> Self {
832        Self {
833            enabled: false,
834            low_confidence_threshold: default_retrieval_failures_low_confidence_threshold(),
835            retention_days: default_retrieval_failures_retention_days(),
836            channel_capacity: default_retrieval_failures_channel_capacity(),
837            batch_size: default_retrieval_failures_batch_size(),
838            flush_interval_ms: default_retrieval_failures_flush_interval_ms(),
839        }
840    }
841}