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}