Skip to main content

zeph_config/
memory.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use serde::{Deserialize, Serialize};
5
6use crate::defaults::{default_sqlite_path_field, default_true};
7use crate::providers::ProviderName;
8
9fn default_sqlite_pool_size() -> u32 {
10    5
11}
12
13fn default_max_history() -> usize {
14    100
15}
16
17fn default_title_max_chars() -> usize {
18    60
19}
20
21fn default_document_collection() -> String {
22    "zeph_documents".into()
23}
24
25fn default_document_chunk_size() -> usize {
26    1000
27}
28
29fn default_document_chunk_overlap() -> usize {
30    100
31}
32
33fn default_document_top_k() -> usize {
34    3
35}
36
37fn default_autosave_min_length() -> usize {
38    20
39}
40
41fn default_tool_call_cutoff() -> usize {
42    6
43}
44
45fn default_token_safety_margin() -> f32 {
46    1.0
47}
48
49fn default_redact_credentials() -> bool {
50    true
51}
52
53fn default_qdrant_url() -> String {
54    "http://localhost:6334".into()
55}
56
57fn default_summarization_threshold() -> usize {
58    50
59}
60
61fn default_context_budget_tokens() -> usize {
62    0
63}
64
65fn default_soft_compaction_threshold() -> f32 {
66    0.60
67}
68
69fn default_hard_compaction_threshold() -> f32 {
70    0.90
71}
72
73fn default_compaction_preserve_tail() -> usize {
74    6
75}
76
77fn default_compaction_cooldown_turns() -> u8 {
78    2
79}
80
81fn default_auto_budget() -> bool {
82    true
83}
84
85fn default_prune_protect_tokens() -> usize {
86    40_000
87}
88
89fn default_cross_session_score_threshold() -> f32 {
90    0.35
91}
92
93fn default_temporal_decay_half_life_days() -> u32 {
94    30
95}
96
97fn default_mmr_lambda() -> f32 {
98    0.7
99}
100
101fn default_semantic_enabled() -> bool {
102    true
103}
104
105fn default_recall_limit() -> usize {
106    5
107}
108
109fn default_vector_weight() -> f64 {
110    0.7
111}
112
113fn default_keyword_weight() -> f64 {
114    0.3
115}
116
117fn default_graph_max_entities_per_message() -> usize {
118    10
119}
120
121fn default_graph_max_edges_per_message() -> usize {
122    15
123}
124
125fn default_graph_community_refresh_interval() -> usize {
126    100
127}
128
129fn default_graph_community_summary_max_prompt_bytes() -> usize {
130    8192
131}
132
133fn default_graph_community_summary_concurrency() -> usize {
134    4
135}
136
137fn default_lpa_edge_chunk_size() -> usize {
138    10_000
139}
140
141fn default_graph_entity_similarity_threshold() -> f32 {
142    0.85
143}
144
145fn default_graph_entity_ambiguous_threshold() -> f32 {
146    0.70
147}
148
149fn default_graph_extraction_timeout_secs() -> u64 {
150    15
151}
152
153fn default_graph_max_hops() -> u32 {
154    2
155}
156
157fn default_graph_recall_limit() -> usize {
158    10
159}
160
161fn default_graph_expired_edge_retention_days() -> u32 {
162    90
163}
164
165fn default_graph_temporal_decay_rate() -> f64 {
166    0.0
167}
168
169fn default_graph_edge_history_limit() -> usize {
170    100
171}
172
173fn default_spreading_activation_decay_lambda() -> f32 {
174    0.85
175}
176
177fn default_spreading_activation_max_hops() -> u32 {
178    3
179}
180
181fn default_spreading_activation_activation_threshold() -> f32 {
182    0.1
183}
184
185fn default_spreading_activation_inhibition_threshold() -> f32 {
186    0.8
187}
188
189fn default_spreading_activation_max_activated_nodes() -> usize {
190    50
191}
192
193fn default_spreading_activation_recall_timeout_ms() -> u64 {
194    1000
195}
196
197fn default_note_linking_similarity_threshold() -> f32 {
198    0.85
199}
200
201fn default_note_linking_top_k() -> usize {
202    10
203}
204
205fn default_note_linking_timeout_secs() -> u64 {
206    5
207}
208
209fn default_shutdown_summary() -> bool {
210    true
211}
212
213fn default_shutdown_summary_min_messages() -> usize {
214    4
215}
216
217fn default_shutdown_summary_max_messages() -> usize {
218    20
219}
220
221fn default_shutdown_summary_timeout_secs() -> u64 {
222    10
223}
224
225fn validate_tier_similarity_threshold<'de, D>(deserializer: D) -> Result<f32, D::Error>
226where
227    D: serde::Deserializer<'de>,
228{
229    let value = <f32 as serde::Deserialize>::deserialize(deserializer)?;
230    if value.is_nan() || value.is_infinite() {
231        return Err(serde::de::Error::custom(
232            "similarity_threshold must be a finite number",
233        ));
234    }
235    if !(0.5..=1.0).contains(&value) {
236        return Err(serde::de::Error::custom(
237            "similarity_threshold must be in [0.5, 1.0]",
238        ));
239    }
240    Ok(value)
241}
242
243fn validate_tier_promotion_min_sessions<'de, D>(deserializer: D) -> Result<u32, D::Error>
244where
245    D: serde::Deserializer<'de>,
246{
247    let value = <u32 as serde::Deserialize>::deserialize(deserializer)?;
248    if value < 2 {
249        return Err(serde::de::Error::custom(
250            "promotion_min_sessions must be >= 2",
251        ));
252    }
253    Ok(value)
254}
255
256fn validate_tier_sweep_batch_size<'de, D>(deserializer: D) -> Result<usize, D::Error>
257where
258    D: serde::Deserializer<'de>,
259{
260    let value = <usize as serde::Deserialize>::deserialize(deserializer)?;
261    if value == 0 {
262        return Err(serde::de::Error::custom("sweep_batch_size must be >= 1"));
263    }
264    Ok(value)
265}
266
267fn default_tier_promotion_min_sessions() -> u32 {
268    3
269}
270
271fn default_tier_similarity_threshold() -> f32 {
272    0.92
273}
274
275fn default_tier_sweep_interval_secs() -> u64 {
276    3600
277}
278
279fn default_tier_sweep_batch_size() -> usize {
280    100
281}
282
283fn default_scene_similarity_threshold() -> f32 {
284    0.80
285}
286
287fn default_scene_batch_size() -> usize {
288    50
289}
290
291fn validate_scene_similarity_threshold<'de, D>(deserializer: D) -> Result<f32, D::Error>
292where
293    D: serde::Deserializer<'de>,
294{
295    let value = <f32 as serde::Deserialize>::deserialize(deserializer)?;
296    if value.is_nan() || value.is_infinite() {
297        return Err(serde::de::Error::custom(
298            "scene_similarity_threshold must be a finite number",
299        ));
300    }
301    if !(0.5..=1.0).contains(&value) {
302        return Err(serde::de::Error::custom(
303            "scene_similarity_threshold must be in [0.5, 1.0]",
304        ));
305    }
306    Ok(value)
307}
308
309fn validate_scene_batch_size<'de, D>(deserializer: D) -> Result<usize, D::Error>
310where
311    D: serde::Deserializer<'de>,
312{
313    let value = <usize as serde::Deserialize>::deserialize(deserializer)?;
314    if value == 0 {
315        return Err(serde::de::Error::custom("scene_batch_size must be >= 1"));
316    }
317    Ok(value)
318}
319
320/// Configuration for the AOI three-layer memory tier promotion system (`[memory.tiers]`).
321///
322/// When `enabled = true`, a background sweep promotes frequently-accessed episodic messages
323/// to semantic tier by clustering near-duplicates and distilling them via an LLM call.
324///
325/// # Validation
326///
327/// Constraints enforced at deserialization time:
328/// - `similarity_threshold` in `[0.5, 1.0]`
329/// - `promotion_min_sessions >= 2`
330/// - `sweep_batch_size >= 1`
331/// - `scene_similarity_threshold` in `[0.5, 1.0]`
332/// - `scene_batch_size >= 1`
333#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
334#[serde(default)]
335pub struct TierConfig {
336    /// Enable the tier promotion system. When `false`, all messages remain episodic.
337    /// Default: `false`.
338    pub enabled: bool,
339    /// Minimum number of distinct sessions a fact must appear in before promotion.
340    /// Must be `>= 2`. Default: `3`.
341    #[serde(deserialize_with = "validate_tier_promotion_min_sessions")]
342    pub promotion_min_sessions: u32,
343    /// Cosine similarity threshold for clustering near-duplicate facts during sweep.
344    /// Must be in `[0.5, 1.0]`. Default: `0.92`.
345    #[serde(deserialize_with = "validate_tier_similarity_threshold")]
346    pub similarity_threshold: f32,
347    /// How often the background promotion sweep runs, in seconds. Default: `3600`.
348    pub sweep_interval_secs: u64,
349    /// Maximum number of messages to evaluate per sweep cycle. Must be `>= 1`. Default: `100`.
350    #[serde(deserialize_with = "validate_tier_sweep_batch_size")]
351    pub sweep_batch_size: usize,
352    /// Enable `MemScene` consolidation of semantic-tier messages. Default: `false`.
353    pub scene_enabled: bool,
354    /// Cosine similarity threshold for `MemScene` clustering. Must be in `[0.5, 1.0]`. Default: `0.80`.
355    #[serde(deserialize_with = "validate_scene_similarity_threshold")]
356    pub scene_similarity_threshold: f32,
357    /// Maximum unassigned semantic messages processed per scene consolidation sweep. Default: `50`.
358    #[serde(deserialize_with = "validate_scene_batch_size")]
359    pub scene_batch_size: usize,
360    /// Provider name from `[[llm.providers]]` for scene label/profile generation.
361    /// Falls back to the primary provider when empty. Default: `""`.
362    pub scene_provider: ProviderName,
363    /// How often the background scene consolidation sweep runs, in seconds. Default: `7200`.
364    pub scene_sweep_interval_secs: u64,
365}
366
367fn default_scene_sweep_interval_secs() -> u64 {
368    7200
369}
370
371impl Default for TierConfig {
372    fn default() -> Self {
373        Self {
374            enabled: false,
375            promotion_min_sessions: default_tier_promotion_min_sessions(),
376            similarity_threshold: default_tier_similarity_threshold(),
377            sweep_interval_secs: default_tier_sweep_interval_secs(),
378            sweep_batch_size: default_tier_sweep_batch_size(),
379            scene_enabled: false,
380            scene_similarity_threshold: default_scene_similarity_threshold(),
381            scene_batch_size: default_scene_batch_size(),
382            scene_provider: ProviderName::default(),
383            scene_sweep_interval_secs: default_scene_sweep_interval_secs(),
384        }
385    }
386}
387
388fn validate_temporal_decay_rate<'de, D>(deserializer: D) -> Result<f64, D::Error>
389where
390    D: serde::Deserializer<'de>,
391{
392    let value = <f64 as serde::Deserialize>::deserialize(deserializer)?;
393    if value.is_nan() || value.is_infinite() {
394        return Err(serde::de::Error::custom(
395            "temporal_decay_rate must be a finite number",
396        ));
397    }
398    if !(0.0..=10.0).contains(&value) {
399        return Err(serde::de::Error::custom(
400            "temporal_decay_rate must be in [0.0, 10.0]",
401        ));
402    }
403    Ok(value)
404}
405
406fn validate_similarity_threshold<'de, D>(deserializer: D) -> Result<f32, D::Error>
407where
408    D: serde::Deserializer<'de>,
409{
410    let value = <f32 as serde::Deserialize>::deserialize(deserializer)?;
411    if value.is_nan() || value.is_infinite() {
412        return Err(serde::de::Error::custom(
413            "similarity_threshold must be a finite number",
414        ));
415    }
416    if !(0.0..=1.0).contains(&value) {
417        return Err(serde::de::Error::custom(
418            "similarity_threshold must be in [0.0, 1.0]",
419        ));
420    }
421    Ok(value)
422}
423
424fn validate_importance_weight<'de, D>(deserializer: D) -> Result<f64, D::Error>
425where
426    D: serde::Deserializer<'de>,
427{
428    let value = <f64 as serde::Deserialize>::deserialize(deserializer)?;
429    if value.is_nan() || value.is_infinite() {
430        return Err(serde::de::Error::custom(
431            "importance_weight must be a finite number",
432        ));
433    }
434    if value < 0.0 {
435        return Err(serde::de::Error::custom(
436            "importance_weight must be non-negative",
437        ));
438    }
439    if value > 1.0 {
440        return Err(serde::de::Error::custom("importance_weight must be <= 1.0"));
441    }
442    Ok(value)
443}
444
445fn default_importance_weight() -> f64 {
446    0.15
447}
448
449/// Configuration for SYNAPSE spreading activation retrieval over the entity graph.
450///
451/// When `enabled = true`, spreading activation replaces BFS-based graph recall.
452/// Seeds are initialized from fuzzy entity matches, then activation propagates
453/// hop-by-hop with exponential decay and lateral inhibition.
454///
455/// # Validation
456///
457/// Constraints enforced at deserialization time:
458/// - `0.0 < decay_lambda <= 1.0`
459/// - `max_hops >= 1`
460/// - `activation_threshold < inhibition_threshold`
461/// - `recall_timeout_ms >= 1` (clamped to 100 with a warning if set to 0)
462#[derive(Debug, Clone, Deserialize, Serialize)]
463#[serde(default)]
464pub struct SpreadingActivationConfig {
465    /// Enable spreading activation (replaces BFS in graph recall when `true`). Default: `false`.
466    pub enabled: bool,
467    /// Per-hop activation decay factor. Range: `(0.0, 1.0]`. Default: `0.85`.
468    #[serde(deserialize_with = "validate_decay_lambda")]
469    pub decay_lambda: f32,
470    /// Maximum propagation depth. Must be `>= 1`. Default: `3`.
471    #[serde(deserialize_with = "validate_max_hops")]
472    pub max_hops: u32,
473    /// Minimum activation score to include a node in results. Default: `0.1`.
474    pub activation_threshold: f32,
475    /// Activation level at which a node stops receiving more activation. Default: `0.8`.
476    pub inhibition_threshold: f32,
477    /// Cap on total activated nodes per spread pass. Default: `50`.
478    pub max_activated_nodes: usize,
479    /// Weight of structural score in hybrid seed ranking. Range: `[0.0, 1.0]`. Default: `0.4`.
480    #[serde(default = "default_seed_structural_weight")]
481    pub seed_structural_weight: f32,
482    /// Maximum seeds per community. `0` = unlimited. Default: `3`.
483    #[serde(default = "default_seed_community_cap")]
484    pub seed_community_cap: usize,
485    /// Timeout in milliseconds for a single spreading activation recall call. Default: `1000`.
486    /// Values below 1 are clamped to 100ms at runtime. Benchmark data shows FTS5 + graph
487    /// traversal completes within 200–400ms; 1000ms provides headroom for cold caches.
488    #[serde(default = "default_spreading_activation_recall_timeout_ms")]
489    pub recall_timeout_ms: u64,
490}
491
492fn validate_decay_lambda<'de, D>(deserializer: D) -> Result<f32, D::Error>
493where
494    D: serde::Deserializer<'de>,
495{
496    let value = <f32 as serde::Deserialize>::deserialize(deserializer)?;
497    if value.is_nan() || value.is_infinite() {
498        return Err(serde::de::Error::custom(
499            "decay_lambda must be a finite number",
500        ));
501    }
502    if !(value > 0.0 && value <= 1.0) {
503        return Err(serde::de::Error::custom(
504            "decay_lambda must be in (0.0, 1.0]",
505        ));
506    }
507    Ok(value)
508}
509
510fn validate_max_hops<'de, D>(deserializer: D) -> Result<u32, D::Error>
511where
512    D: serde::Deserializer<'de>,
513{
514    let value = <u32 as serde::Deserialize>::deserialize(deserializer)?;
515    if value == 0 {
516        return Err(serde::de::Error::custom("max_hops must be >= 1"));
517    }
518    Ok(value)
519}
520
521impl SpreadingActivationConfig {
522    /// Validate cross-field constraints that cannot be expressed in per-field validators.
523    ///
524    /// # Errors
525    ///
526    /// Returns an error string if `activation_threshold >= inhibition_threshold`.
527    pub fn validate(&self) -> Result<(), String> {
528        if self.activation_threshold >= self.inhibition_threshold {
529            return Err(format!(
530                "activation_threshold ({}) must be < inhibition_threshold ({})",
531                self.activation_threshold, self.inhibition_threshold
532            ));
533        }
534        Ok(())
535    }
536}
537
538fn default_seed_structural_weight() -> f32 {
539    0.4
540}
541
542fn default_seed_community_cap() -> usize {
543    3
544}
545
546impl Default for SpreadingActivationConfig {
547    fn default() -> Self {
548        Self {
549            enabled: false,
550            decay_lambda: default_spreading_activation_decay_lambda(),
551            max_hops: default_spreading_activation_max_hops(),
552            activation_threshold: default_spreading_activation_activation_threshold(),
553            inhibition_threshold: default_spreading_activation_inhibition_threshold(),
554            max_activated_nodes: default_spreading_activation_max_activated_nodes(),
555            seed_structural_weight: default_seed_structural_weight(),
556            seed_community_cap: default_seed_community_cap(),
557            recall_timeout_ms: default_spreading_activation_recall_timeout_ms(),
558        }
559    }
560}
561
562/// Kumiho belief revision configuration.
563#[derive(Debug, Clone, Deserialize, Serialize)]
564#[serde(default)]
565pub struct BeliefRevisionConfig {
566    /// Enable semantic contradiction detection for graph edges. Default: `false`.
567    pub enabled: bool,
568    /// Cosine similarity threshold for considering two facts as contradictory.
569    /// Only edges with similarity >= this value are candidates for revision. Default: `0.85`.
570    #[serde(deserialize_with = "validate_similarity_threshold")]
571    pub similarity_threshold: f32,
572}
573
574fn default_belief_revision_similarity_threshold() -> f32 {
575    0.85
576}
577
578impl Default for BeliefRevisionConfig {
579    fn default() -> Self {
580        Self {
581            enabled: false,
582            similarity_threshold: default_belief_revision_similarity_threshold(),
583        }
584    }
585}
586
587/// D-MEM RPE-based tiered graph extraction routing configuration.
588#[derive(Debug, Clone, Deserialize, Serialize)]
589#[serde(default)]
590pub struct RpeConfig {
591    /// Enable RPE-based routing to skip extraction on low-surprise turns. Default: `false`.
592    pub enabled: bool,
593    /// RPE threshold. Turns with RPE < this value skip graph extraction. Range: `[0.0, 1.0]`.
594    /// Default: `0.3`.
595    #[serde(deserialize_with = "validate_similarity_threshold")]
596    pub threshold: f32,
597    /// Maximum consecutive turns to skip before forcing extraction (safety valve). Default: `5`.
598    pub max_skip_turns: u32,
599}
600
601fn default_rpe_threshold() -> f32 {
602    0.3
603}
604
605fn default_rpe_max_skip_turns() -> u32 {
606    5
607}
608
609impl Default for RpeConfig {
610    fn default() -> Self {
611        Self {
612            enabled: false,
613            threshold: default_rpe_threshold(),
614            max_skip_turns: default_rpe_max_skip_turns(),
615        }
616    }
617}
618
619/// Configuration for A-MEM dynamic note linking.
620///
621/// When enabled, after each graph extraction pass, entities extracted from the message are
622/// compared against the entity embedding collection. Pairs with cosine similarity above
623/// `similarity_threshold` receive a `similar_to` edge in the graph.
624#[derive(Debug, Clone, Deserialize, Serialize)]
625#[serde(default)]
626pub struct NoteLinkingConfig {
627    /// Enable A-MEM note linking after graph extraction. Default: `false`.
628    pub enabled: bool,
629    /// Minimum cosine similarity score to create a `similar_to` edge. Default: `0.85`.
630    #[serde(deserialize_with = "validate_similarity_threshold")]
631    pub similarity_threshold: f32,
632    /// Maximum number of similar entities to link per extracted entity. Default: `10`.
633    pub top_k: usize,
634    /// Timeout for the entire linking pass in seconds. Default: `5`.
635    pub timeout_secs: u64,
636}
637
638impl Default for NoteLinkingConfig {
639    fn default() -> Self {
640        Self {
641            enabled: false,
642            similarity_threshold: default_note_linking_similarity_threshold(),
643            top_k: default_note_linking_top_k(),
644            timeout_secs: default_note_linking_timeout_secs(),
645        }
646    }
647}
648
649/// Vector backend selector for embedding storage.
650#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
651#[serde(rename_all = "lowercase")]
652pub enum VectorBackend {
653    Qdrant,
654    #[default]
655    Sqlite,
656}
657
658impl VectorBackend {
659    #[must_use]
660    pub fn as_str(&self) -> &'static str {
661        match self {
662            Self::Qdrant => "qdrant",
663            Self::Sqlite => "sqlite",
664        }
665    }
666}
667
668#[derive(Debug, Deserialize, Serialize)]
669#[allow(clippy::struct_excessive_bools)]
670pub struct MemoryConfig {
671    #[serde(default)]
672    pub compression_guidelines: zeph_memory::CompressionGuidelinesConfig,
673    #[serde(default = "default_sqlite_path_field")]
674    pub sqlite_path: String,
675    pub history_limit: u32,
676    #[serde(default = "default_qdrant_url")]
677    pub qdrant_url: String,
678    #[serde(default)]
679    pub semantic: SemanticConfig,
680    #[serde(default = "default_summarization_threshold")]
681    pub summarization_threshold: usize,
682    #[serde(default = "default_context_budget_tokens")]
683    pub context_budget_tokens: usize,
684    #[serde(default = "default_soft_compaction_threshold")]
685    pub soft_compaction_threshold: f32,
686    #[serde(
687        default = "default_hard_compaction_threshold",
688        alias = "compaction_threshold"
689    )]
690    pub hard_compaction_threshold: f32,
691    #[serde(default = "default_compaction_preserve_tail")]
692    pub compaction_preserve_tail: usize,
693    #[serde(default = "default_compaction_cooldown_turns")]
694    pub compaction_cooldown_turns: u8,
695    #[serde(default = "default_auto_budget")]
696    pub auto_budget: bool,
697    #[serde(default = "default_prune_protect_tokens")]
698    pub prune_protect_tokens: usize,
699    #[serde(default = "default_cross_session_score_threshold")]
700    pub cross_session_score_threshold: f32,
701    #[serde(default)]
702    pub vector_backend: VectorBackend,
703    #[serde(default = "default_token_safety_margin")]
704    pub token_safety_margin: f32,
705    #[serde(default = "default_redact_credentials")]
706    pub redact_credentials: bool,
707    #[serde(default = "default_true")]
708    pub autosave_assistant: bool,
709    #[serde(default = "default_autosave_min_length")]
710    pub autosave_min_length: usize,
711    #[serde(default = "default_tool_call_cutoff")]
712    pub tool_call_cutoff: usize,
713    #[serde(default = "default_sqlite_pool_size")]
714    pub sqlite_pool_size: u32,
715    #[serde(default)]
716    pub sessions: SessionsConfig,
717    #[serde(default)]
718    pub documents: DocumentConfig,
719    #[serde(default)]
720    pub eviction: zeph_memory::EvictionConfig,
721    #[serde(default)]
722    pub compression: CompressionConfig,
723    #[serde(default)]
724    pub sidequest: SidequestConfig,
725    #[serde(default)]
726    pub graph: GraphConfig,
727    /// Store a lightweight session summary to the vector store on shutdown when no session
728    /// summary exists yet for this conversation. Enables cross-session recall for short or
729    /// interrupted sessions that never triggered hard compaction. Default: `true`.
730    #[serde(default = "default_shutdown_summary")]
731    pub shutdown_summary: bool,
732    /// Minimum number of user-turn messages required before a shutdown summary is generated.
733    /// Sessions below this threshold are considered trivial and skipped. Default: `4`.
734    #[serde(default = "default_shutdown_summary_min_messages")]
735    pub shutdown_summary_min_messages: usize,
736    /// Maximum number of recent messages (user + assistant) sent to the LLM for shutdown
737    /// summarization. Caps token cost for long sessions that never triggered hard compaction.
738    /// Default: `20`.
739    #[serde(default = "default_shutdown_summary_max_messages")]
740    pub shutdown_summary_max_messages: usize,
741    /// Per-attempt timeout in seconds for each LLM call during shutdown summarization.
742    /// Applies independently to the structured call and to the plain-text fallback.
743    /// Default: `10`.
744    #[serde(default = "default_shutdown_summary_timeout_secs")]
745    pub shutdown_summary_timeout_secs: u64,
746    /// Use structured anchored summaries for context compaction.
747    ///
748    /// When enabled, hard compaction requests a JSON schema from the LLM
749    /// instead of free-form prose. Falls back to prose if the LLM fails
750    /// to produce valid JSON. Default: `false`.
751    #[serde(default)]
752    pub structured_summaries: bool,
753    /// AOI three-layer memory tier promotion system.
754    ///
755    /// When `tiers.enabled = true`, a background sweep promotes frequently-accessed episodic
756    /// messages to a semantic tier by clustering near-duplicates and distilling via LLM.
757    #[serde(default)]
758    pub tiers: TierConfig,
759    /// A-MAC adaptive memory admission control.
760    ///
761    /// When `admission.enabled = true`, each message is evaluated before saving and rejected
762    /// if its composite admission score falls below the configured threshold.
763    #[serde(default)]
764    pub admission: AdmissionConfig,
765    /// Session digest generation at session end. Default: disabled.
766    #[serde(default)]
767    pub digest: DigestConfig,
768    /// Context assembly strategy. Default: `full_history` (current behavior).
769    #[serde(default)]
770    pub context_strategy: ContextStrategy,
771    /// Number of turns at which `Adaptive` strategy switches to `MemoryFirst`. Default: `20`.
772    #[serde(default = "default_crossover_turn_threshold")]
773    pub crossover_turn_threshold: u32,
774    /// All-Mem lifelong memory consolidation sweep.
775    ///
776    /// When `consolidation.enabled = true`, a background loop clusters semantically similar
777    /// messages and merges them into consolidated entries via LLM.
778    #[serde(default)]
779    pub consolidation: ConsolidationConfig,
780    /// `SleepGate` forgetting sweep (#2397).
781    ///
782    /// When `forgetting.enabled = true`, a background loop periodically decays importance
783    /// scores and prunes memories below the forgetting floor.
784    #[serde(default)]
785    pub forgetting: ForgettingConfig,
786    /// `PostgreSQL` connection URL.
787    ///
788    /// Used when the binary is compiled with `--features postgres`.
789    /// Can be overridden by the vault key `ZEPH_DATABASE_URL`.
790    /// Example: `postgres://user:pass@localhost:5432/zeph`
791    /// Default: `None` (uses `sqlite_path` instead).
792    #[serde(default)]
793    pub database_url: Option<String>,
794    /// Cost-sensitive store routing (#2444).
795    ///
796    /// When `store_routing.enabled = true`, query intent is classified and routed to
797    /// the cheapest sufficient backend instead of querying all stores on every turn.
798    #[serde(default)]
799    pub store_routing: StoreRoutingConfig,
800}
801
802fn default_crossover_turn_threshold() -> u32 {
803    20
804}
805
806/// Session digest configuration (#2289).
807#[derive(Debug, Clone, Deserialize, Serialize)]
808#[serde(default)]
809pub struct DigestConfig {
810    /// Enable session digest generation at session end. Default: `false`.
811    pub enabled: bool,
812    /// Provider name from `[[llm.providers]]` for digest generation.
813    /// Falls back to the primary provider when empty. Default: `""`.
814    pub provider: String,
815    /// Maximum tokens for the digest text. Default: `500`.
816    pub max_tokens: usize,
817    /// Maximum messages to feed into the digest prompt. Default: `50`.
818    pub max_input_messages: usize,
819}
820
821impl Default for DigestConfig {
822    fn default() -> Self {
823        Self {
824            enabled: false,
825            provider: String::new(),
826            max_tokens: 500,
827            max_input_messages: 50,
828        }
829    }
830}
831
832/// Context assembly strategy (#2288).
833#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
834#[serde(rename_all = "snake_case")]
835pub enum ContextStrategy {
836    /// Full conversation history trimmed to budget, with memory augmentation.
837    /// This is the default and existing behavior.
838    #[default]
839    FullHistory,
840    /// Drop conversation history; assemble context from summaries, semantic recall,
841    /// cross-session memory, and session digest only.
842    MemoryFirst,
843    /// Start as `FullHistory`; switch to `MemoryFirst` when turn count exceeds
844    /// `crossover_turn_threshold`.
845    Adaptive,
846}
847
848#[derive(Debug, Clone, Deserialize, Serialize)]
849#[serde(default)]
850pub struct SessionsConfig {
851    /// Maximum number of sessions returned by list operations (0 = unlimited).
852    #[serde(default = "default_max_history")]
853    pub max_history: usize,
854    /// Maximum characters for auto-generated session titles.
855    #[serde(default = "default_title_max_chars")]
856    pub title_max_chars: usize,
857}
858
859impl Default for SessionsConfig {
860    fn default() -> Self {
861        Self {
862            max_history: default_max_history(),
863            title_max_chars: default_title_max_chars(),
864        }
865    }
866}
867
868/// Configuration for the document ingestion and RAG retrieval pipeline.
869#[derive(Debug, Clone, Deserialize, Serialize)]
870pub struct DocumentConfig {
871    #[serde(default = "default_document_collection")]
872    pub collection: String,
873    #[serde(default = "default_document_chunk_size")]
874    pub chunk_size: usize,
875    #[serde(default = "default_document_chunk_overlap")]
876    pub chunk_overlap: usize,
877    /// Number of document chunks to inject into agent context per turn.
878    #[serde(default = "default_document_top_k")]
879    pub top_k: usize,
880    /// Enable document RAG injection into agent context.
881    #[serde(default)]
882    pub rag_enabled: bool,
883}
884
885impl Default for DocumentConfig {
886    fn default() -> Self {
887        Self {
888            collection: default_document_collection(),
889            chunk_size: default_document_chunk_size(),
890            chunk_overlap: default_document_chunk_overlap(),
891            top_k: default_document_top_k(),
892            rag_enabled: false,
893        }
894    }
895}
896
897#[derive(Debug, Deserialize, Serialize)]
898#[allow(clippy::struct_excessive_bools)]
899pub struct SemanticConfig {
900    #[serde(default = "default_semantic_enabled")]
901    pub enabled: bool,
902    #[serde(default = "default_recall_limit")]
903    pub recall_limit: usize,
904    #[serde(default = "default_vector_weight")]
905    pub vector_weight: f64,
906    #[serde(default = "default_keyword_weight")]
907    pub keyword_weight: f64,
908    #[serde(default = "default_true")]
909    pub temporal_decay_enabled: bool,
910    #[serde(default = "default_temporal_decay_half_life_days")]
911    pub temporal_decay_half_life_days: u32,
912    #[serde(default = "default_true")]
913    pub mmr_enabled: bool,
914    #[serde(default = "default_mmr_lambda")]
915    pub mmr_lambda: f32,
916    #[serde(default = "default_true")]
917    pub importance_enabled: bool,
918    #[serde(
919        default = "default_importance_weight",
920        deserialize_with = "validate_importance_weight"
921    )]
922    pub importance_weight: f64,
923}
924
925impl Default for SemanticConfig {
926    fn default() -> Self {
927        Self {
928            enabled: default_semantic_enabled(),
929            recall_limit: default_recall_limit(),
930            vector_weight: default_vector_weight(),
931            keyword_weight: default_keyword_weight(),
932            temporal_decay_enabled: true,
933            temporal_decay_half_life_days: default_temporal_decay_half_life_days(),
934            mmr_enabled: true,
935            mmr_lambda: default_mmr_lambda(),
936            importance_enabled: true,
937            importance_weight: default_importance_weight(),
938        }
939    }
940}
941
942/// Compression strategy for active context compression (#1161).
943#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
944#[serde(tag = "strategy", rename_all = "snake_case")]
945pub enum CompressionStrategy {
946    /// Compress only when reactive compaction fires (current behavior).
947    #[default]
948    Reactive,
949    /// Compress proactively when context exceeds `threshold_tokens`.
950    Proactive {
951        /// Token count that triggers proactive compression.
952        threshold_tokens: usize,
953        /// Maximum tokens for the compressed summary (passed to LLM as `max_tokens`).
954        max_summary_tokens: usize,
955    },
956    /// Agent calls `compress_context` tool explicitly. Reactive compaction still fires as a
957    /// safety net. The `compress_context` tool is also available in all other strategies.
958    Autonomous,
959    /// Knowledge-block-aware compression strategy (#2510).
960    ///
961    /// Low-relevance context segments are automatically consolidated into `AutoConsolidated`
962    /// knowledge blocks. LLM-curated blocks are never evicted before auto-consolidated ones.
963    Focus,
964}
965
966/// Pruning strategy for tool-output eviction inside the compaction pipeline (#1851, #2022).
967///
968/// When `context-compression` feature is enabled, this replaces the default oldest-first
969/// heuristic with scored eviction.
970#[derive(Debug, Clone, Copy, Default, Serialize, PartialEq, Eq)]
971#[serde(rename_all = "snake_case")]
972pub enum PruningStrategy {
973    /// Oldest-first eviction — current default behavior.
974    #[default]
975    Reactive,
976    /// Short LLM call extracts a task goal; blocks are scored by keyword overlap and pruned
977    /// lowest-first. Requires `context-compression` feature.
978    TaskAware,
979    /// Coarse-to-fine MIG scoring: relevance − redundancy with temporal partitioning.
980    /// Requires `context-compression` feature.
981    Mig,
982    /// Subgoal-aware pruning: tracks the agent's current subgoal via fire-and-forget LLM
983    /// extraction and partitions tool outputs into Active/Completed/Outdated tiers (#2022).
984    /// Requires `context-compression` feature.
985    Subgoal,
986    /// Subgoal-aware pruning combined with MIG redundancy scoring (#2022).
987    /// Requires `context-compression` feature.
988    SubgoalMig,
989}
990
991impl PruningStrategy {
992    /// Returns `true` when the strategy is subgoal-aware (`Subgoal` or `SubgoalMig`).
993    #[must_use]
994    pub fn is_subgoal(self) -> bool {
995        matches!(self, Self::Subgoal | Self::SubgoalMig)
996    }
997}
998
999// Route serde deserialization through FromStr so that removed variants (e.g. task_aware_mig)
1000// emit a warning and fall back to Reactive instead of hard-erroring when found in TOML configs.
1001impl<'de> serde::Deserialize<'de> for PruningStrategy {
1002    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1003        let s = String::deserialize(deserializer)?;
1004        s.parse().map_err(serde::de::Error::custom)
1005    }
1006}
1007
1008impl std::str::FromStr for PruningStrategy {
1009    type Err = String;
1010
1011    fn from_str(s: &str) -> Result<Self, Self::Err> {
1012        match s {
1013            "reactive" => Ok(Self::Reactive),
1014            "task_aware" | "task-aware" => Ok(Self::TaskAware),
1015            "mig" => Ok(Self::Mig),
1016            // task_aware_mig was removed (dead code — was routed to scored path only).
1017            // Fall back to Reactive so existing TOML configs do not hard-error on startup.
1018            "task_aware_mig" | "task-aware-mig" => {
1019                tracing::warn!(
1020                    "pruning strategy `task_aware_mig` has been removed; \
1021                     falling back to `reactive`. Use `task_aware` or `mig` instead."
1022                );
1023                Ok(Self::Reactive)
1024            }
1025            "subgoal" => Ok(Self::Subgoal),
1026            "subgoal_mig" | "subgoal-mig" => Ok(Self::SubgoalMig),
1027            other => Err(format!(
1028                "unknown pruning strategy `{other}`, expected \
1029                 reactive|task_aware|mig|subgoal|subgoal_mig"
1030            )),
1031        }
1032    }
1033}
1034
1035fn default_high_density_budget() -> f32 {
1036    0.7
1037}
1038
1039fn default_low_density_budget() -> f32 {
1040    0.3
1041}
1042
1043/// Configuration for the performance-floor compression ratio predictor (#2460).
1044///
1045/// When `enabled = true`, before hard compaction the predictor selects the most aggressive
1046/// compression ratio that keeps the predicted probe score above `probe.hard_fail_threshold`.
1047/// Requires enough training data (`min_samples`) before activating — during cold start the
1048/// predictor returns `None` and default behavior applies.
1049#[derive(Debug, Clone, Deserialize, Serialize)]
1050#[serde(default)]
1051pub struct CompressionPredictorConfig {
1052    /// Enable the adaptive compression ratio predictor. Default: `false`.
1053    pub enabled: bool,
1054    /// Minimum training samples before the predictor activates. Default: `10`.
1055    pub min_samples: u64,
1056    /// Candidate compression ratios evaluated from most to least aggressive.
1057    /// Default: `[0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]`.
1058    pub candidate_ratios: Vec<f32>,
1059    /// Retrain the model after this many new samples. Default: `5`.
1060    pub retrain_interval: u64,
1061    /// Maximum training samples to retain (sliding window). Default: `200`.
1062    pub max_training_samples: usize,
1063}
1064
1065impl Default for CompressionPredictorConfig {
1066    fn default() -> Self {
1067        Self {
1068            enabled: false,
1069            min_samples: 10,
1070            candidate_ratios: vec![0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9],
1071            retrain_interval: 5,
1072            max_training_samples: 200,
1073        }
1074    }
1075}
1076
1077/// Configuration for the `SleepGate` forgetting sweep (#2397).
1078///
1079/// When `enabled = true`, a background loop periodically decays importance scores
1080/// (synaptic downscaling), restores recently-accessed memories (selective replay),
1081/// and prunes memories below `forgetting_floor` (targeted forgetting).
1082#[derive(Debug, Clone, Deserialize, Serialize)]
1083#[serde(default)]
1084pub struct ForgettingConfig {
1085    /// Enable the `SleepGate` forgetting sweep. Default: `false`.
1086    pub enabled: bool,
1087    /// Per-sweep decay rate applied to importance scores. Range: (0.0, 1.0). Default: `0.1`.
1088    pub decay_rate: f32,
1089    /// Importance floor below which memories are pruned. Range: [0.0, 1.0]. Default: `0.05`.
1090    pub forgetting_floor: f32,
1091    /// How often the forgetting sweep runs, in seconds. Default: `7200`.
1092    pub sweep_interval_secs: u64,
1093    /// Maximum messages to process per sweep. Default: `500`.
1094    pub sweep_batch_size: usize,
1095    /// Hours: messages accessed within this window get replay protection. Default: `24`.
1096    pub replay_window_hours: u32,
1097    /// Messages with `access_count` >= this get replay protection. Default: `3`.
1098    pub replay_min_access_count: u32,
1099    /// Hours: never prune messages accessed within this window. Default: `24`.
1100    pub protect_recent_hours: u32,
1101    /// Never prune messages with `access_count` >= this. Default: `3`.
1102    pub protect_min_access_count: u32,
1103}
1104
1105impl Default for ForgettingConfig {
1106    fn default() -> Self {
1107        Self {
1108            enabled: false,
1109            decay_rate: 0.1,
1110            forgetting_floor: 0.05,
1111            sweep_interval_secs: 7200,
1112            sweep_batch_size: 500,
1113            replay_window_hours: 24,
1114            replay_min_access_count: 3,
1115            protect_recent_hours: 24,
1116            protect_min_access_count: 3,
1117        }
1118    }
1119}
1120
1121/// Configuration for active context compression (#1161).
1122#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1123#[serde(default)]
1124pub struct CompressionConfig {
1125    /// Compression strategy.
1126    #[serde(flatten)]
1127    pub strategy: CompressionStrategy,
1128    /// Tool-output pruning strategy (requires `context-compression` feature).
1129    pub pruning_strategy: PruningStrategy,
1130    /// Model to use for compression summaries.
1131    ///
1132    /// Currently unused — the primary summary provider is used regardless of this value.
1133    /// Reserved for future per-compression model selection. Setting this field has no effect.
1134    pub model: String,
1135    /// Provider name from `[[llm.providers]]` for `compress_context` summaries.
1136    /// Falls back to the primary provider when empty. Default: `""`.
1137    pub compress_provider: ProviderName,
1138    /// Compaction probe: validates summary quality before committing it (#1609).
1139    #[serde(default)]
1140    pub probe: zeph_memory::CompactionProbeConfig,
1141    /// Archive tool output bodies to `SQLite` before compaction (Memex #2432).
1142    ///
1143    /// When enabled, tool output bodies in the compaction range are saved to
1144    /// `tool_overflow` with `archive_type = 'archive'` before summarization.
1145    /// The LLM summarizes placeholder messages; archived content is appended as
1146    /// a postfix after summarization so references survive compaction.
1147    /// Default: `false`.
1148    #[serde(default)]
1149    pub archive_tool_outputs: bool,
1150    /// Provider for Focus strategy segment scoring (#2510).
1151    /// Falls back to the primary provider when empty. Default: `""`.
1152    pub focus_scorer_provider: ProviderName,
1153    /// Token-budget fraction for high-density content in density-aware compression (#2481).
1154    /// Must sum to 1.0 with `low_density_budget`. Default: `0.7`.
1155    #[serde(default = "default_high_density_budget")]
1156    pub high_density_budget: f32,
1157    /// Token-budget fraction for low-density content in density-aware compression (#2481).
1158    /// Must sum to 1.0 with `high_density_budget`. Default: `0.3`.
1159    #[serde(default = "default_low_density_budget")]
1160    pub low_density_budget: f32,
1161    /// Performance-floor compression ratio predictor (#2460).
1162    #[serde(default)]
1163    pub predictor: CompressionPredictorConfig,
1164}
1165
1166fn default_sidequest_interval_turns() -> u32 {
1167    4
1168}
1169
1170fn default_sidequest_max_eviction_ratio() -> f32 {
1171    0.5
1172}
1173
1174fn default_sidequest_max_cursors() -> usize {
1175    30
1176}
1177
1178fn default_sidequest_min_cursor_tokens() -> usize {
1179    100
1180}
1181
1182/// Configuration for LLM-driven side-thread tool output eviction (#1885).
1183#[derive(Debug, Clone, Deserialize, Serialize)]
1184#[serde(default)]
1185pub struct SidequestConfig {
1186    /// Enable `SideQuest` eviction. Default: `false`.
1187    pub enabled: bool,
1188    /// Run eviction every N user turns. Default: `4`.
1189    #[serde(default = "default_sidequest_interval_turns")]
1190    pub interval_turns: u32,
1191    /// Maximum fraction of tool outputs to evict per pass. Default: `0.5`.
1192    #[serde(default = "default_sidequest_max_eviction_ratio")]
1193    pub max_eviction_ratio: f32,
1194    /// Maximum cursor entries in eviction prompt (largest outputs first). Default: `30`.
1195    #[serde(default = "default_sidequest_max_cursors")]
1196    pub max_cursors: usize,
1197    /// Exclude tool outputs smaller than this token count from eviction candidates.
1198    /// Default: `100`.
1199    #[serde(default = "default_sidequest_min_cursor_tokens")]
1200    pub min_cursor_tokens: usize,
1201}
1202
1203impl Default for SidequestConfig {
1204    fn default() -> Self {
1205        Self {
1206            enabled: false,
1207            interval_turns: default_sidequest_interval_turns(),
1208            max_eviction_ratio: default_sidequest_max_eviction_ratio(),
1209            max_cursors: default_sidequest_max_cursors(),
1210            min_cursor_tokens: default_sidequest_min_cursor_tokens(),
1211        }
1212    }
1213}
1214
1215/// Configuration for the knowledge graph memory subsystem (`[memory.graph]` TOML section).
1216///
1217/// # Security
1218///
1219/// Entity names, relation labels, and fact strings extracted by the LLM are stored verbatim
1220/// without PII redaction. This is a known pre-1.0 MVP limitation. Do not enable graph memory
1221/// when processing conversations that may contain personal, medical, or sensitive data until
1222/// a redaction pass is implemented on the write path.
1223#[derive(Debug, Clone, Deserialize, Serialize)]
1224#[serde(default)]
1225pub struct GraphConfig {
1226    pub enabled: bool,
1227    pub extract_model: String,
1228    #[serde(default = "default_graph_max_entities_per_message")]
1229    pub max_entities_per_message: usize,
1230    #[serde(default = "default_graph_max_edges_per_message")]
1231    pub max_edges_per_message: usize,
1232    #[serde(default = "default_graph_community_refresh_interval")]
1233    pub community_refresh_interval: usize,
1234    #[serde(default = "default_graph_entity_similarity_threshold")]
1235    pub entity_similarity_threshold: f32,
1236    #[serde(default = "default_graph_extraction_timeout_secs")]
1237    pub extraction_timeout_secs: u64,
1238    #[serde(default)]
1239    pub use_embedding_resolution: bool,
1240    #[serde(default = "default_graph_entity_ambiguous_threshold")]
1241    pub entity_ambiguous_threshold: f32,
1242    #[serde(default = "default_graph_max_hops")]
1243    pub max_hops: u32,
1244    #[serde(default = "default_graph_recall_limit")]
1245    pub recall_limit: usize,
1246    /// Days to retain expired (superseded) edges before deletion. Default: 90.
1247    #[serde(default = "default_graph_expired_edge_retention_days")]
1248    pub expired_edge_retention_days: u32,
1249    /// Maximum entities to retain in the graph. 0 = unlimited.
1250    #[serde(default)]
1251    pub max_entities: usize,
1252    /// Maximum prompt size in bytes for community summary generation. Default: 8192.
1253    #[serde(default = "default_graph_community_summary_max_prompt_bytes")]
1254    pub community_summary_max_prompt_bytes: usize,
1255    /// Maximum concurrent LLM calls during community summarization. Default: 4.
1256    #[serde(default = "default_graph_community_summary_concurrency")]
1257    pub community_summary_concurrency: usize,
1258    /// Number of edges fetched per chunk during community detection. Default: 10000.
1259    /// Set to 0 to disable chunking and load all edges at once (legacy behavior).
1260    #[serde(default = "default_lpa_edge_chunk_size")]
1261    pub lpa_edge_chunk_size: usize,
1262    /// Temporal recency decay rate for graph recall scoring (units: 1/day).
1263    ///
1264    /// When > 0, recent edges receive a small additive score boost over older edges.
1265    /// The boost formula is `1 / (1 + age_days * rate)`, blended additively with the base
1266    /// composite score. Default 0.0 preserves existing scoring behavior exactly.
1267    #[serde(
1268        default = "default_graph_temporal_decay_rate",
1269        deserialize_with = "validate_temporal_decay_rate"
1270    )]
1271    pub temporal_decay_rate: f64,
1272    /// Maximum number of historical edge versions returned by `edge_history()`. Default: 100.
1273    ///
1274    /// Caps the result set returned for a given source entity + predicate pair. Prevents
1275    /// unbounded memory usage for high-churn predicates when this method is exposed via TUI
1276    /// or API endpoints.
1277    #[serde(default = "default_graph_edge_history_limit")]
1278    pub edge_history_limit: usize,
1279    /// A-MEM dynamic note linking configuration.
1280    ///
1281    /// When `note_linking.enabled = true`, entities extracted from each message are linked to
1282    /// semantically similar entities via `similar_to` edges. Requires an embedding store
1283    /// (`qdrant` or `sqlite` vector backend) to be configured.
1284    #[serde(default)]
1285    pub note_linking: NoteLinkingConfig,
1286    /// SYNAPSE spreading activation retrieval configuration.
1287    ///
1288    /// When `spreading_activation.enabled = true`, graph recall uses spreading activation
1289    /// with lateral inhibition and temporal decay instead of BFS.
1290    #[serde(default)]
1291    pub spreading_activation: SpreadingActivationConfig,
1292    /// A-MEM link weight decay: multiplicative factor applied to `retrieval_count`
1293    /// for un-retrieved edges each decay pass. Range: `(0.0, 1.0]`. Default: `0.95`.
1294    #[serde(
1295        default = "default_link_weight_decay_lambda",
1296        deserialize_with = "validate_link_weight_decay_lambda"
1297    )]
1298    pub link_weight_decay_lambda: f64,
1299    /// Seconds between link weight decay passes. Default: `86400` (24 hours).
1300    #[serde(default = "default_link_weight_decay_interval_secs")]
1301    pub link_weight_decay_interval_secs: u64,
1302    /// Kumiho AGM-inspired belief revision configuration.
1303    ///
1304    /// When `belief_revision.enabled = true`, new edges that semantically contradict existing
1305    /// edges for the same entity pair trigger revision: the old edge is invalidated with a
1306    /// `superseded_by` pointer and the new edge becomes the current belief.
1307    #[serde(default)]
1308    pub belief_revision: BeliefRevisionConfig,
1309    /// D-MEM RPE-based tiered graph extraction routing.
1310    ///
1311    /// When `rpe.enabled = true`, low-surprise turns skip the expensive MAGMA LLM extraction
1312    /// pipeline. A consecutive-skip safety valve ensures no turn is silently skipped indefinitely.
1313    #[serde(default)]
1314    pub rpe: RpeConfig,
1315    /// `SQLite` connection pool size dedicated to graph operations.
1316    ///
1317    /// Graph tables share the same database file as messages/embeddings but use a
1318    /// separate pool to prevent pool starvation when community detection or spreading
1319    /// activation runs concurrently with regular memory operations. Default: `3`.
1320    #[serde(default = "default_graph_pool_size")]
1321    pub pool_size: u32,
1322}
1323
1324fn default_graph_pool_size() -> u32 {
1325    3
1326}
1327
1328impl Default for GraphConfig {
1329    fn default() -> Self {
1330        Self {
1331            enabled: false,
1332            extract_model: String::new(),
1333            max_entities_per_message: default_graph_max_entities_per_message(),
1334            max_edges_per_message: default_graph_max_edges_per_message(),
1335            community_refresh_interval: default_graph_community_refresh_interval(),
1336            entity_similarity_threshold: default_graph_entity_similarity_threshold(),
1337            extraction_timeout_secs: default_graph_extraction_timeout_secs(),
1338            use_embedding_resolution: false,
1339            entity_ambiguous_threshold: default_graph_entity_ambiguous_threshold(),
1340            max_hops: default_graph_max_hops(),
1341            recall_limit: default_graph_recall_limit(),
1342            expired_edge_retention_days: default_graph_expired_edge_retention_days(),
1343            max_entities: 0,
1344            community_summary_max_prompt_bytes: default_graph_community_summary_max_prompt_bytes(),
1345            community_summary_concurrency: default_graph_community_summary_concurrency(),
1346            lpa_edge_chunk_size: default_lpa_edge_chunk_size(),
1347            temporal_decay_rate: default_graph_temporal_decay_rate(),
1348            edge_history_limit: default_graph_edge_history_limit(),
1349            note_linking: NoteLinkingConfig::default(),
1350            spreading_activation: SpreadingActivationConfig::default(),
1351            link_weight_decay_lambda: default_link_weight_decay_lambda(),
1352            link_weight_decay_interval_secs: default_link_weight_decay_interval_secs(),
1353            belief_revision: BeliefRevisionConfig::default(),
1354            rpe: RpeConfig::default(),
1355            pool_size: default_graph_pool_size(),
1356        }
1357    }
1358}
1359
1360fn default_consolidation_confidence_threshold() -> f32 {
1361    0.7
1362}
1363
1364fn default_consolidation_sweep_interval_secs() -> u64 {
1365    3600
1366}
1367
1368fn default_consolidation_sweep_batch_size() -> usize {
1369    50
1370}
1371
1372fn default_consolidation_similarity_threshold() -> f32 {
1373    0.85
1374}
1375
1376/// Configuration for the All-Mem lifelong memory consolidation sweep (`[memory.consolidation]`).
1377///
1378/// When `enabled = true`, a background loop periodically clusters semantically similar messages
1379/// and merges them into consolidated entries via an LLM call. Originals are never deleted —
1380/// they are marked as consolidated and deprioritized in recall via temporal decay.
1381#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1382#[serde(default)]
1383pub struct ConsolidationConfig {
1384    /// Enable the consolidation background loop. Default: `false`.
1385    pub enabled: bool,
1386    /// Provider name from `[[llm.providers]]` for consolidation LLM calls.
1387    /// Falls back to the primary provider when empty. Default: `""`.
1388    #[serde(default)]
1389    pub consolidation_provider: ProviderName,
1390    /// Minimum LLM-assigned confidence for a topology op to be applied. Default: `0.7`.
1391    #[serde(default = "default_consolidation_confidence_threshold")]
1392    pub confidence_threshold: f32,
1393    /// How often the background consolidation sweep runs, in seconds. Default: `3600`.
1394    #[serde(default = "default_consolidation_sweep_interval_secs")]
1395    pub sweep_interval_secs: u64,
1396    /// Maximum number of messages to evaluate per sweep cycle. Default: `50`.
1397    #[serde(default = "default_consolidation_sweep_batch_size")]
1398    pub sweep_batch_size: usize,
1399    /// Minimum cosine similarity for two messages to be considered consolidation candidates.
1400    /// Default: `0.85`.
1401    #[serde(default = "default_consolidation_similarity_threshold")]
1402    pub similarity_threshold: f32,
1403}
1404
1405impl Default for ConsolidationConfig {
1406    fn default() -> Self {
1407        Self {
1408            enabled: false,
1409            consolidation_provider: ProviderName::default(),
1410            confidence_threshold: default_consolidation_confidence_threshold(),
1411            sweep_interval_secs: default_consolidation_sweep_interval_secs(),
1412            sweep_batch_size: default_consolidation_sweep_batch_size(),
1413            similarity_threshold: default_consolidation_similarity_threshold(),
1414        }
1415    }
1416}
1417
1418fn default_link_weight_decay_lambda() -> f64 {
1419    0.95
1420}
1421
1422fn default_link_weight_decay_interval_secs() -> u64 {
1423    86400
1424}
1425
1426fn validate_link_weight_decay_lambda<'de, D>(deserializer: D) -> Result<f64, D::Error>
1427where
1428    D: serde::Deserializer<'de>,
1429{
1430    let value = <f64 as serde::Deserialize>::deserialize(deserializer)?;
1431    if value.is_nan() || value.is_infinite() {
1432        return Err(serde::de::Error::custom(
1433            "link_weight_decay_lambda must be a finite number",
1434        ));
1435    }
1436    if !(value > 0.0 && value <= 1.0) {
1437        return Err(serde::de::Error::custom(
1438            "link_weight_decay_lambda must be in (0.0, 1.0]",
1439        ));
1440    }
1441    Ok(value)
1442}
1443
1444fn validate_admission_threshold<'de, D>(deserializer: D) -> Result<f32, D::Error>
1445where
1446    D: serde::Deserializer<'de>,
1447{
1448    let value = <f32 as serde::Deserialize>::deserialize(deserializer)?;
1449    if value.is_nan() || value.is_infinite() {
1450        return Err(serde::de::Error::custom(
1451            "threshold must be a finite number",
1452        ));
1453    }
1454    if !(0.0..=1.0).contains(&value) {
1455        return Err(serde::de::Error::custom("threshold must be in [0.0, 1.0]"));
1456    }
1457    Ok(value)
1458}
1459
1460fn validate_admission_fast_path_margin<'de, D>(deserializer: D) -> Result<f32, D::Error>
1461where
1462    D: serde::Deserializer<'de>,
1463{
1464    let value = <f32 as serde::Deserialize>::deserialize(deserializer)?;
1465    if value.is_nan() || value.is_infinite() {
1466        return Err(serde::de::Error::custom(
1467            "fast_path_margin must be a finite number",
1468        ));
1469    }
1470    if !(0.0..=1.0).contains(&value) {
1471        return Err(serde::de::Error::custom(
1472            "fast_path_margin must be in [0.0, 1.0]",
1473        ));
1474    }
1475    Ok(value)
1476}
1477
1478fn default_admission_threshold() -> f32 {
1479    0.40
1480}
1481
1482fn default_admission_fast_path_margin() -> f32 {
1483    0.15
1484}
1485
1486fn default_rl_min_samples() -> u32 {
1487    500
1488}
1489
1490fn default_rl_retrain_interval_secs() -> u64 {
1491    3600
1492}
1493
1494/// Admission decision strategy.
1495///
1496/// `Heuristic` uses the existing multi-factor weighted score with an optional LLM call.
1497/// `Rl` replaces the LLM-based `future_utility` factor with a trained logistic regression model.
1498#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
1499#[serde(rename_all = "snake_case")]
1500pub enum AdmissionStrategy {
1501    /// Current A-MAC behavior: weighted heuristics + optional LLM call. Default.
1502    #[default]
1503    Heuristic,
1504    /// Learned model: logistic regression trained on recall feedback.
1505    /// Falls back to `Heuristic` when training data is below `rl_min_samples`.
1506    Rl,
1507}
1508
1509fn validate_admission_weight<'de, D>(deserializer: D) -> Result<f32, D::Error>
1510where
1511    D: serde::Deserializer<'de>,
1512{
1513    let value = <f32 as serde::Deserialize>::deserialize(deserializer)?;
1514    if value < 0.0 {
1515        return Err(serde::de::Error::custom(
1516            "admission weight must be non-negative (>= 0.0)",
1517        ));
1518    }
1519    Ok(value)
1520}
1521
1522/// Per-factor weights for the A-MAC admission score (`[memory.admission.weights]`).
1523///
1524/// Weights are normalized at runtime (divided by their sum), so they do not need to sum to 1.0.
1525/// All values must be non-negative.
1526#[derive(Debug, Clone, Deserialize, Serialize)]
1527#[serde(default)]
1528pub struct AdmissionWeights {
1529    /// LLM-estimated future reuse probability. Default: `0.30`.
1530    #[serde(deserialize_with = "validate_admission_weight")]
1531    pub future_utility: f32,
1532    /// Factual confidence heuristic (inverse of hedging markers). Default: `0.15`.
1533    #[serde(deserialize_with = "validate_admission_weight")]
1534    pub factual_confidence: f32,
1535    /// Semantic novelty: 1 - max similarity to existing memories. Default: `0.30`.
1536    #[serde(deserialize_with = "validate_admission_weight")]
1537    pub semantic_novelty: f32,
1538    /// Temporal recency: always 1.0 at write time. Default: `0.10`.
1539    #[serde(deserialize_with = "validate_admission_weight")]
1540    pub temporal_recency: f32,
1541    /// Content type prior based on role. Default: `0.15`.
1542    #[serde(deserialize_with = "validate_admission_weight")]
1543    pub content_type_prior: f32,
1544    /// Goal-conditioned utility (#2408). `0.0` when `goal_conditioned_write = false`.
1545    /// When enabled, set this alongside reducing `future_utility` so total sums remain stable.
1546    /// Normalized automatically at runtime. Default: `0.0`.
1547    #[serde(deserialize_with = "validate_admission_weight")]
1548    pub goal_utility: f32,
1549}
1550
1551impl Default for AdmissionWeights {
1552    fn default() -> Self {
1553        Self {
1554            future_utility: 0.30,
1555            factual_confidence: 0.15,
1556            semantic_novelty: 0.30,
1557            temporal_recency: 0.10,
1558            content_type_prior: 0.15,
1559            goal_utility: 0.0,
1560        }
1561    }
1562}
1563
1564impl AdmissionWeights {
1565    /// Return weights normalized so they sum to 1.0.
1566    ///
1567    /// All weights are non-negative; the sum is always > 0 when defaults are used.
1568    #[must_use]
1569    pub fn normalized(&self) -> Self {
1570        let sum = self.future_utility
1571            + self.factual_confidence
1572            + self.semantic_novelty
1573            + self.temporal_recency
1574            + self.content_type_prior
1575            + self.goal_utility;
1576        if sum <= f32::EPSILON {
1577            return Self::default();
1578        }
1579        Self {
1580            future_utility: self.future_utility / sum,
1581            factual_confidence: self.factual_confidence / sum,
1582            semantic_novelty: self.semantic_novelty / sum,
1583            temporal_recency: self.temporal_recency / sum,
1584            content_type_prior: self.content_type_prior / sum,
1585            goal_utility: self.goal_utility / sum,
1586        }
1587    }
1588}
1589
1590/// Configuration for A-MAC adaptive memory admission control (`[memory.admission]` TOML section).
1591///
1592/// When `enabled = true`, a write-time gate evaluates each message before saving to memory.
1593/// Messages below the composite admission threshold are rejected and not persisted.
1594#[derive(Debug, Clone, Deserialize, Serialize)]
1595#[serde(default)]
1596pub struct AdmissionConfig {
1597    /// Enable A-MAC admission control. Default: `false`.
1598    pub enabled: bool,
1599    /// Composite score threshold below which messages are rejected. Range: `[0.0, 1.0]`.
1600    /// Default: `0.40`.
1601    #[serde(deserialize_with = "validate_admission_threshold")]
1602    pub threshold: f32,
1603    /// Margin above threshold at which the fast path admits without an LLM call. Range: `[0.0, 1.0]`.
1604    /// When heuristic score >= threshold + margin, LLM call is skipped. Default: `0.15`.
1605    #[serde(deserialize_with = "validate_admission_fast_path_margin")]
1606    pub fast_path_margin: f32,
1607    /// Provider name from `[[llm.providers]]` for `future_utility` LLM evaluation.
1608    /// Falls back to the primary provider when empty. Default: `""`.
1609    pub admission_provider: ProviderName,
1610    /// Per-factor weights. Normalized at runtime. Default: `{0.30, 0.15, 0.30, 0.10, 0.15}`.
1611    pub weights: AdmissionWeights,
1612    /// Admission decision strategy. Default: `heuristic`.
1613    #[serde(default)]
1614    pub admission_strategy: AdmissionStrategy,
1615    /// Minimum training samples before the RL model is activated.
1616    /// Below this count the system falls back to `Heuristic`. Default: `500`.
1617    #[serde(default = "default_rl_min_samples")]
1618    pub rl_min_samples: u32,
1619    /// Background RL model retraining interval in seconds. Default: `3600`.
1620    #[serde(default = "default_rl_retrain_interval_secs")]
1621    pub rl_retrain_interval_secs: u64,
1622    /// Enable goal-conditioned write gate (#2408). When `true`, memories are scored
1623    /// against the current task goal and rejected if relevance is below `goal_utility_threshold`.
1624    /// Zero regression when `false`. Default: `false`.
1625    #[serde(default)]
1626    pub goal_conditioned_write: bool,
1627    /// Provider name from `[[llm.providers]]` for goal-utility LLM refinement.
1628    /// Used only for borderline cases (similarity within 0.1 of threshold).
1629    /// Falls back to the primary provider when empty. Default: `""`.
1630    #[serde(default)]
1631    pub goal_utility_provider: ProviderName,
1632    /// Minimum cosine similarity between goal embedding and candidate memory
1633    /// to consider it goal-relevant. Below this, `goal_utility = 0.0`. Default: `0.4`.
1634    #[serde(default = "default_goal_utility_threshold")]
1635    pub goal_utility_threshold: f32,
1636    /// Weight of the `goal_utility` factor in the composite admission score.
1637    /// Set to `0.0` to disable (equivalent to `goal_conditioned_write = false`). Default: `0.25`.
1638    #[serde(default = "default_goal_utility_weight")]
1639    pub goal_utility_weight: f32,
1640}
1641
1642fn default_goal_utility_threshold() -> f32 {
1643    0.4
1644}
1645
1646fn default_goal_utility_weight() -> f32 {
1647    0.25
1648}
1649
1650impl Default for AdmissionConfig {
1651    fn default() -> Self {
1652        Self {
1653            enabled: false,
1654            threshold: default_admission_threshold(),
1655            fast_path_margin: default_admission_fast_path_margin(),
1656            admission_provider: ProviderName::default(),
1657            weights: AdmissionWeights::default(),
1658            admission_strategy: AdmissionStrategy::default(),
1659            rl_min_samples: default_rl_min_samples(),
1660            rl_retrain_interval_secs: default_rl_retrain_interval_secs(),
1661            goal_conditioned_write: false,
1662            goal_utility_provider: ProviderName::default(),
1663            goal_utility_threshold: default_goal_utility_threshold(),
1664            goal_utility_weight: default_goal_utility_weight(),
1665        }
1666    }
1667}
1668
1669/// Routing strategy for `[memory.store_routing]`.
1670#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
1671#[serde(rename_all = "snake_case")]
1672pub enum StoreRoutingStrategy {
1673    /// Pure heuristic pattern matching. Zero LLM calls. Default.
1674    #[default]
1675    Heuristic,
1676    /// LLM-based classification via `routing_classifier_provider`.
1677    Llm,
1678    /// Heuristic first; escalates to LLM only when confidence is low.
1679    Hybrid,
1680}
1681
1682/// Configuration for cost-sensitive store routing (`[memory.store_routing]`).
1683///
1684/// Controls how each query is classified and routed to the appropriate memory
1685/// backend(s), avoiding unnecessary store queries for simple lookups.
1686#[derive(Debug, Clone, Deserialize, Serialize)]
1687#[serde(default)]
1688pub struct StoreRoutingConfig {
1689    /// Enable configurable store routing. When `false`, `HeuristicRouter` is used
1690    /// directly (existing behavior). Default: `false`.
1691    pub enabled: bool,
1692    /// Routing strategy. Default: `heuristic`.
1693    pub strategy: StoreRoutingStrategy,
1694    /// Provider name from `[[llm.providers]]` for LLM-based classification.
1695    /// Falls back to the primary provider when empty. Default: `""`.
1696    pub routing_classifier_provider: ProviderName,
1697    /// Route to use when the classifier is uncertain (confidence < threshold).
1698    /// Default: `"hybrid"`.
1699    pub fallback_route: String,
1700    /// Confidence threshold below which `HybridRouter` escalates to LLM.
1701    /// Range: `[0.0, 1.0]`. Default: `0.7`.
1702    pub confidence_threshold: f32,
1703}
1704
1705impl Default for StoreRoutingConfig {
1706    fn default() -> Self {
1707        Self {
1708            enabled: false,
1709            strategy: StoreRoutingStrategy::Heuristic,
1710            routing_classifier_provider: ProviderName::default(),
1711            fallback_route: "hybrid".into(),
1712            confidence_threshold: 0.7,
1713        }
1714    }
1715}
1716
1717#[cfg(test)]
1718mod tests {
1719    use super::*;
1720
1721    // Verify that serde deserialization routes through FromStr so that removed variants
1722    // (task_aware_mig) fall back to Reactive instead of hard-erroring when found in TOML.
1723    #[test]
1724    fn pruning_strategy_toml_task_aware_mig_falls_back_to_reactive() {
1725        #[derive(serde::Deserialize)]
1726        struct Wrapper {
1727            #[allow(dead_code)]
1728            pruning_strategy: PruningStrategy,
1729        }
1730        let toml = r#"pruning_strategy = "task_aware_mig""#;
1731        let w: Wrapper = toml::from_str(toml).expect("should deserialize without error");
1732        assert_eq!(
1733            w.pruning_strategy,
1734            PruningStrategy::Reactive,
1735            "task_aware_mig must fall back to Reactive"
1736        );
1737    }
1738
1739    #[test]
1740    fn pruning_strategy_toml_round_trip() {
1741        #[derive(serde::Deserialize)]
1742        struct Wrapper {
1743            #[allow(dead_code)]
1744            pruning_strategy: PruningStrategy,
1745        }
1746        for (input, expected) in [
1747            ("reactive", PruningStrategy::Reactive),
1748            ("task_aware", PruningStrategy::TaskAware),
1749            ("mig", PruningStrategy::Mig),
1750        ] {
1751            let toml = format!(r#"pruning_strategy = "{input}""#);
1752            let w: Wrapper = toml::from_str(&toml)
1753                .unwrap_or_else(|e| panic!("failed to deserialize `{input}`: {e}"));
1754            assert_eq!(w.pruning_strategy, expected, "mismatch for `{input}`");
1755        }
1756    }
1757
1758    #[test]
1759    fn pruning_strategy_toml_unknown_value_errors() {
1760        #[derive(serde::Deserialize)]
1761        #[allow(dead_code)]
1762        struct Wrapper {
1763            pruning_strategy: PruningStrategy,
1764        }
1765        let toml = r#"pruning_strategy = "nonexistent_strategy""#;
1766        assert!(
1767            toml::from_str::<Wrapper>(toml).is_err(),
1768            "unknown strategy must produce an error"
1769        );
1770    }
1771
1772    #[test]
1773    fn tier_config_defaults_are_correct() {
1774        let cfg = TierConfig::default();
1775        assert!(!cfg.enabled);
1776        assert_eq!(cfg.promotion_min_sessions, 3);
1777        assert!((cfg.similarity_threshold - 0.92).abs() < f32::EPSILON);
1778        assert_eq!(cfg.sweep_interval_secs, 3600);
1779        assert_eq!(cfg.sweep_batch_size, 100);
1780    }
1781
1782    #[test]
1783    fn tier_config_rejects_min_sessions_below_2() {
1784        let toml = "promotion_min_sessions = 1";
1785        assert!(toml::from_str::<TierConfig>(toml).is_err());
1786    }
1787
1788    #[test]
1789    fn tier_config_rejects_similarity_threshold_below_0_5() {
1790        let toml = "similarity_threshold = 0.4";
1791        assert!(toml::from_str::<TierConfig>(toml).is_err());
1792    }
1793
1794    #[test]
1795    fn tier_config_rejects_zero_sweep_batch_size() {
1796        let toml = "sweep_batch_size = 0";
1797        assert!(toml::from_str::<TierConfig>(toml).is_err());
1798    }
1799
1800    fn deserialize_importance_weight(toml_val: &str) -> Result<SemanticConfig, toml::de::Error> {
1801        let input = format!("importance_weight = {toml_val}");
1802        toml::from_str::<SemanticConfig>(&input)
1803    }
1804
1805    #[test]
1806    fn importance_weight_default_is_0_15() {
1807        let cfg = SemanticConfig::default();
1808        assert!((cfg.importance_weight - 0.15).abs() < f64::EPSILON);
1809    }
1810
1811    #[test]
1812    fn importance_weight_valid_zero() {
1813        let cfg = deserialize_importance_weight("0.0").unwrap();
1814        assert!((cfg.importance_weight - 0.0_f64).abs() < f64::EPSILON);
1815    }
1816
1817    #[test]
1818    fn importance_weight_valid_one() {
1819        let cfg = deserialize_importance_weight("1.0").unwrap();
1820        assert!((cfg.importance_weight - 1.0_f64).abs() < f64::EPSILON);
1821    }
1822
1823    #[test]
1824    fn importance_weight_rejects_near_zero_negative() {
1825        // TOML does not have a NaN literal, but we can test via a f64 that
1826        // the validator rejects out-of-range values. Test with negative here
1827        // and rely on validate_importance_weight rejecting non-finite via
1828        // a constructed deserializer call.
1829        let result = deserialize_importance_weight("-0.01");
1830        assert!(
1831            result.is_err(),
1832            "negative importance_weight must be rejected"
1833        );
1834    }
1835
1836    #[test]
1837    fn importance_weight_rejects_negative() {
1838        let result = deserialize_importance_weight("-1.0");
1839        assert!(result.is_err(), "negative value must be rejected");
1840    }
1841
1842    #[test]
1843    fn importance_weight_rejects_greater_than_one() {
1844        let result = deserialize_importance_weight("1.01");
1845        assert!(result.is_err(), "value > 1.0 must be rejected");
1846    }
1847
1848    // ── AdmissionWeights::normalized() tests (#2317) ────────────────────────
1849
1850    // Test: weights that don't sum to 1.0 are normalized to sum to 1.0.
1851    #[test]
1852    fn admission_weights_normalized_sums_to_one() {
1853        let w = AdmissionWeights {
1854            future_utility: 2.0,
1855            factual_confidence: 1.0,
1856            semantic_novelty: 3.0,
1857            temporal_recency: 1.0,
1858            content_type_prior: 3.0,
1859            goal_utility: 0.0,
1860        };
1861        let n = w.normalized();
1862        let sum = n.future_utility
1863            + n.factual_confidence
1864            + n.semantic_novelty
1865            + n.temporal_recency
1866            + n.content_type_prior;
1867        assert!(
1868            (sum - 1.0).abs() < 0.001,
1869            "normalized weights must sum to 1.0, got {sum}"
1870        );
1871    }
1872
1873    // Test: already-normalized weights are preserved.
1874    #[test]
1875    fn admission_weights_normalized_preserves_already_unit_sum() {
1876        let w = AdmissionWeights::default();
1877        let n = w.normalized();
1878        let sum = n.future_utility
1879            + n.factual_confidence
1880            + n.semantic_novelty
1881            + n.temporal_recency
1882            + n.content_type_prior;
1883        assert!(
1884            (sum - 1.0).abs() < 0.001,
1885            "default weights sum to ~1.0 after normalization"
1886        );
1887    }
1888
1889    // Test: zero weights fall back to default (no divide-by-zero panic).
1890    #[test]
1891    fn admission_weights_normalized_zero_sum_falls_back_to_default() {
1892        let w = AdmissionWeights {
1893            future_utility: 0.0,
1894            factual_confidence: 0.0,
1895            semantic_novelty: 0.0,
1896            temporal_recency: 0.0,
1897            content_type_prior: 0.0,
1898            goal_utility: 0.0,
1899        };
1900        let n = w.normalized();
1901        let default = AdmissionWeights::default();
1902        assert!(
1903            (n.future_utility - default.future_utility).abs() < 0.001,
1904            "zero-sum weights must fall back to defaults"
1905        );
1906    }
1907
1908    // Test: AdmissionConfig default values match documented defaults.
1909    #[test]
1910    fn admission_config_defaults() {
1911        let cfg = AdmissionConfig::default();
1912        assert!(!cfg.enabled);
1913        assert!((cfg.threshold - 0.40).abs() < 0.001);
1914        assert!((cfg.fast_path_margin - 0.15).abs() < 0.001);
1915        assert!(cfg.admission_provider.is_empty());
1916    }
1917
1918    // ── SpreadingActivationConfig tests (#2514) ──────────────────────────────
1919
1920    #[test]
1921    fn spreading_activation_default_recall_timeout_ms_is_1000() {
1922        let cfg = SpreadingActivationConfig::default();
1923        assert_eq!(
1924            cfg.recall_timeout_ms, 1000,
1925            "default recall_timeout_ms must be 1000ms"
1926        );
1927    }
1928
1929    #[test]
1930    fn spreading_activation_toml_recall_timeout_ms_round_trip() {
1931        #[derive(serde::Deserialize)]
1932        struct Wrapper {
1933            recall_timeout_ms: u64,
1934        }
1935        let toml = "recall_timeout_ms = 500";
1936        let w: Wrapper = toml::from_str(toml).unwrap();
1937        assert_eq!(w.recall_timeout_ms, 500);
1938    }
1939
1940    #[test]
1941    fn spreading_activation_validate_cross_field_constraints() {
1942        let mut cfg = SpreadingActivationConfig::default();
1943        // Default activation_threshold (0.1) < inhibition_threshold (0.8) → must be Ok.
1944        assert!(cfg.validate().is_ok());
1945
1946        // Equal thresholds must be rejected.
1947        cfg.activation_threshold = 0.5;
1948        cfg.inhibition_threshold = 0.5;
1949        assert!(cfg.validate().is_err());
1950    }
1951
1952    // ─── CompressionConfig: new Focus fields deserialization (#2510, #2481) ──
1953
1954    #[test]
1955    fn compression_config_focus_strategy_deserializes() {
1956        let toml = r#"strategy = "focus""#;
1957        let cfg: CompressionConfig = toml::from_str(toml).unwrap();
1958        assert_eq!(cfg.strategy, CompressionStrategy::Focus);
1959    }
1960
1961    #[test]
1962    fn compression_config_density_budget_defaults_on_deserialize() {
1963        // `#[serde(default = "...")]` applies during deserialization, not via Default::default().
1964        // Verify that omitting both fields yields the serde defaults (0.7 / 0.3).
1965        let toml = r#"strategy = "reactive""#;
1966        let cfg: CompressionConfig = toml::from_str(toml).unwrap();
1967        assert!((cfg.high_density_budget - 0.7).abs() < 1e-6);
1968        assert!((cfg.low_density_budget - 0.3).abs() < 1e-6);
1969    }
1970
1971    #[test]
1972    fn compression_config_density_budget_round_trip() {
1973        let toml = "strategy = \"reactive\"\nhigh_density_budget = 0.6\nlow_density_budget = 0.4";
1974        let cfg: CompressionConfig = toml::from_str(toml).unwrap();
1975        assert!((cfg.high_density_budget - 0.6).abs() < f32::EPSILON);
1976        assert!((cfg.low_density_budget - 0.4).abs() < f32::EPSILON);
1977    }
1978
1979    #[test]
1980    fn compression_config_focus_scorer_provider_default_empty() {
1981        let cfg = CompressionConfig::default();
1982        assert!(cfg.focus_scorer_provider.is_empty());
1983    }
1984
1985    #[test]
1986    fn compression_config_focus_scorer_provider_round_trip() {
1987        let toml = "strategy = \"focus\"\nfocus_scorer_provider = \"fast\"";
1988        let cfg: CompressionConfig = toml::from_str(toml).unwrap();
1989        assert_eq!(cfg.focus_scorer_provider.as_str(), "fast");
1990    }
1991}