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    /// Persona memory layer (#2461).
801    ///
802    /// When `persona.enabled = true`, user preferences and domain knowledge are extracted
803    /// from conversation history and injected into context after the system prompt.
804    #[serde(default)]
805    pub persona: PersonaConfig,
806}
807
808fn default_crossover_turn_threshold() -> u32 {
809    20
810}
811
812/// Session digest configuration (#2289).
813#[derive(Debug, Clone, Deserialize, Serialize)]
814#[serde(default)]
815pub struct DigestConfig {
816    /// Enable session digest generation at session end. Default: `false`.
817    pub enabled: bool,
818    /// Provider name from `[[llm.providers]]` for digest generation.
819    /// Falls back to the primary provider when empty. Default: `""`.
820    pub provider: String,
821    /// Maximum tokens for the digest text. Default: `500`.
822    pub max_tokens: usize,
823    /// Maximum messages to feed into the digest prompt. Default: `50`.
824    pub max_input_messages: usize,
825}
826
827impl Default for DigestConfig {
828    fn default() -> Self {
829        Self {
830            enabled: false,
831            provider: String::new(),
832            max_tokens: 500,
833            max_input_messages: 50,
834        }
835    }
836}
837
838/// Context assembly strategy (#2288).
839#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
840#[serde(rename_all = "snake_case")]
841pub enum ContextStrategy {
842    /// Full conversation history trimmed to budget, with memory augmentation.
843    /// This is the default and existing behavior.
844    #[default]
845    FullHistory,
846    /// Drop conversation history; assemble context from summaries, semantic recall,
847    /// cross-session memory, and session digest only.
848    MemoryFirst,
849    /// Start as `FullHistory`; switch to `MemoryFirst` when turn count exceeds
850    /// `crossover_turn_threshold`.
851    Adaptive,
852}
853
854#[derive(Debug, Clone, Deserialize, Serialize)]
855#[serde(default)]
856pub struct SessionsConfig {
857    /// Maximum number of sessions returned by list operations (0 = unlimited).
858    #[serde(default = "default_max_history")]
859    pub max_history: usize,
860    /// Maximum characters for auto-generated session titles.
861    #[serde(default = "default_title_max_chars")]
862    pub title_max_chars: usize,
863}
864
865impl Default for SessionsConfig {
866    fn default() -> Self {
867        Self {
868            max_history: default_max_history(),
869            title_max_chars: default_title_max_chars(),
870        }
871    }
872}
873
874/// Configuration for the document ingestion and RAG retrieval pipeline.
875#[derive(Debug, Clone, Deserialize, Serialize)]
876pub struct DocumentConfig {
877    #[serde(default = "default_document_collection")]
878    pub collection: String,
879    #[serde(default = "default_document_chunk_size")]
880    pub chunk_size: usize,
881    #[serde(default = "default_document_chunk_overlap")]
882    pub chunk_overlap: usize,
883    /// Number of document chunks to inject into agent context per turn.
884    #[serde(default = "default_document_top_k")]
885    pub top_k: usize,
886    /// Enable document RAG injection into agent context.
887    #[serde(default)]
888    pub rag_enabled: bool,
889}
890
891impl Default for DocumentConfig {
892    fn default() -> Self {
893        Self {
894            collection: default_document_collection(),
895            chunk_size: default_document_chunk_size(),
896            chunk_overlap: default_document_chunk_overlap(),
897            top_k: default_document_top_k(),
898            rag_enabled: false,
899        }
900    }
901}
902
903#[derive(Debug, Deserialize, Serialize)]
904#[allow(clippy::struct_excessive_bools)]
905pub struct SemanticConfig {
906    #[serde(default = "default_semantic_enabled")]
907    pub enabled: bool,
908    #[serde(default = "default_recall_limit")]
909    pub recall_limit: usize,
910    #[serde(default = "default_vector_weight")]
911    pub vector_weight: f64,
912    #[serde(default = "default_keyword_weight")]
913    pub keyword_weight: f64,
914    #[serde(default = "default_true")]
915    pub temporal_decay_enabled: bool,
916    #[serde(default = "default_temporal_decay_half_life_days")]
917    pub temporal_decay_half_life_days: u32,
918    #[serde(default = "default_true")]
919    pub mmr_enabled: bool,
920    #[serde(default = "default_mmr_lambda")]
921    pub mmr_lambda: f32,
922    #[serde(default = "default_true")]
923    pub importance_enabled: bool,
924    #[serde(
925        default = "default_importance_weight",
926        deserialize_with = "validate_importance_weight"
927    )]
928    pub importance_weight: f64,
929    /// Name of a `[[llm.providers]]` entry to use exclusively for embedding calls during
930    /// memory write and backfill operations. A dedicated provider prevents `embed_backfill`
931    /// from contending with the guardrail at the API server level (rate limits, Ollama
932    /// single-model lock). When unset or empty, falls back to the main agent provider.
933    #[serde(default)]
934    pub embed_provider: Option<String>,
935}
936
937impl Default for SemanticConfig {
938    fn default() -> Self {
939        Self {
940            enabled: default_semantic_enabled(),
941            recall_limit: default_recall_limit(),
942            vector_weight: default_vector_weight(),
943            keyword_weight: default_keyword_weight(),
944            temporal_decay_enabled: true,
945            temporal_decay_half_life_days: default_temporal_decay_half_life_days(),
946            mmr_enabled: true,
947            mmr_lambda: default_mmr_lambda(),
948            importance_enabled: true,
949            importance_weight: default_importance_weight(),
950            embed_provider: None,
951        }
952    }
953}
954
955/// Compression strategy for active context compression (#1161).
956#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)]
957#[serde(tag = "strategy", rename_all = "snake_case")]
958pub enum CompressionStrategy {
959    /// Compress only when reactive compaction fires (current behavior).
960    #[default]
961    Reactive,
962    /// Compress proactively when context exceeds `threshold_tokens`.
963    Proactive {
964        /// Token count that triggers proactive compression.
965        threshold_tokens: usize,
966        /// Maximum tokens for the compressed summary (passed to LLM as `max_tokens`).
967        max_summary_tokens: usize,
968    },
969    /// Agent calls `compress_context` tool explicitly. Reactive compaction still fires as a
970    /// safety net. The `compress_context` tool is also available in all other strategies.
971    Autonomous,
972    /// Knowledge-block-aware compression strategy (#2510).
973    ///
974    /// Low-relevance context segments are automatically consolidated into `AutoConsolidated`
975    /// knowledge blocks. LLM-curated blocks are never evicted before auto-consolidated ones.
976    Focus,
977}
978
979/// Pruning strategy for tool-output eviction inside the compaction pipeline (#1851, #2022).
980///
981/// When `context-compression` feature is enabled, this replaces the default oldest-first
982/// heuristic with scored eviction.
983#[derive(Debug, Clone, Copy, Default, Serialize, PartialEq, Eq)]
984#[serde(rename_all = "snake_case")]
985pub enum PruningStrategy {
986    /// Oldest-first eviction — current default behavior.
987    #[default]
988    Reactive,
989    /// Short LLM call extracts a task goal; blocks are scored by keyword overlap and pruned
990    /// lowest-first. Requires `context-compression` feature.
991    TaskAware,
992    /// Coarse-to-fine MIG scoring: relevance − redundancy with temporal partitioning.
993    /// Requires `context-compression` feature.
994    Mig,
995    /// Subgoal-aware pruning: tracks the agent's current subgoal via fire-and-forget LLM
996    /// extraction and partitions tool outputs into Active/Completed/Outdated tiers (#2022).
997    /// Requires `context-compression` feature.
998    Subgoal,
999    /// Subgoal-aware pruning combined with MIG redundancy scoring (#2022).
1000    /// Requires `context-compression` feature.
1001    SubgoalMig,
1002}
1003
1004impl PruningStrategy {
1005    /// Returns `true` when the strategy is subgoal-aware (`Subgoal` or `SubgoalMig`).
1006    #[must_use]
1007    pub fn is_subgoal(self) -> bool {
1008        matches!(self, Self::Subgoal | Self::SubgoalMig)
1009    }
1010}
1011
1012// Route serde deserialization through FromStr so that removed variants (e.g. task_aware_mig)
1013// emit a warning and fall back to Reactive instead of hard-erroring when found in TOML configs.
1014impl<'de> serde::Deserialize<'de> for PruningStrategy {
1015    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1016        let s = String::deserialize(deserializer)?;
1017        s.parse().map_err(serde::de::Error::custom)
1018    }
1019}
1020
1021impl std::str::FromStr for PruningStrategy {
1022    type Err = String;
1023
1024    fn from_str(s: &str) -> Result<Self, Self::Err> {
1025        match s {
1026            "reactive" => Ok(Self::Reactive),
1027            "task_aware" | "task-aware" => Ok(Self::TaskAware),
1028            "mig" => Ok(Self::Mig),
1029            // task_aware_mig was removed (dead code — was routed to scored path only).
1030            // Fall back to Reactive so existing TOML configs do not hard-error on startup.
1031            "task_aware_mig" | "task-aware-mig" => {
1032                tracing::warn!(
1033                    "pruning strategy `task_aware_mig` has been removed; \
1034                     falling back to `reactive`. Use `task_aware` or `mig` instead."
1035                );
1036                Ok(Self::Reactive)
1037            }
1038            "subgoal" => Ok(Self::Subgoal),
1039            "subgoal_mig" | "subgoal-mig" => Ok(Self::SubgoalMig),
1040            other => Err(format!(
1041                "unknown pruning strategy `{other}`, expected \
1042                 reactive|task_aware|mig|subgoal|subgoal_mig"
1043            )),
1044        }
1045    }
1046}
1047
1048fn default_high_density_budget() -> f32 {
1049    0.7
1050}
1051
1052fn default_low_density_budget() -> f32 {
1053    0.3
1054}
1055
1056/// Configuration for the performance-floor compression ratio predictor (#2460).
1057///
1058/// When `enabled = true`, before hard compaction the predictor selects the most aggressive
1059/// compression ratio that keeps the predicted probe score above `probe.hard_fail_threshold`.
1060/// Requires enough training data (`min_samples`) before activating — during cold start the
1061/// predictor returns `None` and default behavior applies.
1062#[derive(Debug, Clone, Deserialize, Serialize)]
1063#[serde(default)]
1064pub struct CompressionPredictorConfig {
1065    /// Enable the adaptive compression ratio predictor. Default: `false`.
1066    pub enabled: bool,
1067    /// Minimum training samples before the predictor activates. Default: `10`.
1068    pub min_samples: u64,
1069    /// Candidate compression ratios evaluated from most to least aggressive.
1070    /// Default: `[0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]`.
1071    pub candidate_ratios: Vec<f32>,
1072    /// Retrain the model after this many new samples. Default: `5`.
1073    pub retrain_interval: u64,
1074    /// Maximum training samples to retain (sliding window). Default: `200`.
1075    pub max_training_samples: usize,
1076}
1077
1078impl Default for CompressionPredictorConfig {
1079    fn default() -> Self {
1080        Self {
1081            enabled: false,
1082            min_samples: 10,
1083            candidate_ratios: vec![0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9],
1084            retrain_interval: 5,
1085            max_training_samples: 200,
1086        }
1087    }
1088}
1089
1090/// Configuration for the `SleepGate` forgetting sweep (#2397).
1091///
1092/// When `enabled = true`, a background loop periodically decays importance scores
1093/// (synaptic downscaling), restores recently-accessed memories (selective replay),
1094/// and prunes memories below `forgetting_floor` (targeted forgetting).
1095#[derive(Debug, Clone, Deserialize, Serialize)]
1096#[serde(default)]
1097pub struct ForgettingConfig {
1098    /// Enable the `SleepGate` forgetting sweep. Default: `false`.
1099    pub enabled: bool,
1100    /// Per-sweep decay rate applied to importance scores. Range: (0.0, 1.0). Default: `0.1`.
1101    pub decay_rate: f32,
1102    /// Importance floor below which memories are pruned. Range: [0.0, 1.0]. Default: `0.05`.
1103    pub forgetting_floor: f32,
1104    /// How often the forgetting sweep runs, in seconds. Default: `7200`.
1105    pub sweep_interval_secs: u64,
1106    /// Maximum messages to process per sweep. Default: `500`.
1107    pub sweep_batch_size: usize,
1108    /// Hours: messages accessed within this window get replay protection. Default: `24`.
1109    pub replay_window_hours: u32,
1110    /// Messages with `access_count` >= this get replay protection. Default: `3`.
1111    pub replay_min_access_count: u32,
1112    /// Hours: never prune messages accessed within this window. Default: `24`.
1113    pub protect_recent_hours: u32,
1114    /// Never prune messages with `access_count` >= this. Default: `3`.
1115    pub protect_min_access_count: u32,
1116}
1117
1118impl Default for ForgettingConfig {
1119    fn default() -> Self {
1120        Self {
1121            enabled: false,
1122            decay_rate: 0.1,
1123            forgetting_floor: 0.05,
1124            sweep_interval_secs: 7200,
1125            sweep_batch_size: 500,
1126            replay_window_hours: 24,
1127            replay_min_access_count: 3,
1128            protect_recent_hours: 24,
1129            protect_min_access_count: 3,
1130        }
1131    }
1132}
1133
1134/// Configuration for active context compression (#1161).
1135#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1136#[serde(default)]
1137pub struct CompressionConfig {
1138    /// Compression strategy.
1139    #[serde(flatten)]
1140    pub strategy: CompressionStrategy,
1141    /// Tool-output pruning strategy (requires `context-compression` feature).
1142    pub pruning_strategy: PruningStrategy,
1143    /// Model to use for compression summaries.
1144    ///
1145    /// Currently unused — the primary summary provider is used regardless of this value.
1146    /// Reserved for future per-compression model selection. Setting this field has no effect.
1147    pub model: String,
1148    /// Provider name from `[[llm.providers]]` for `compress_context` summaries.
1149    /// Falls back to the primary provider when empty. Default: `""`.
1150    pub compress_provider: ProviderName,
1151    /// Compaction probe: validates summary quality before committing it (#1609).
1152    #[serde(default)]
1153    pub probe: zeph_memory::CompactionProbeConfig,
1154    /// Archive tool output bodies to `SQLite` before compaction (Memex #2432).
1155    ///
1156    /// When enabled, tool output bodies in the compaction range are saved to
1157    /// `tool_overflow` with `archive_type = 'archive'` before summarization.
1158    /// The LLM summarizes placeholder messages; archived content is appended as
1159    /// a postfix after summarization so references survive compaction.
1160    /// Default: `false`.
1161    #[serde(default)]
1162    pub archive_tool_outputs: bool,
1163    /// Provider for Focus strategy segment scoring (#2510).
1164    /// Falls back to the primary provider when empty. Default: `""`.
1165    pub focus_scorer_provider: ProviderName,
1166    /// Token-budget fraction for high-density content in density-aware compression (#2481).
1167    /// Must sum to 1.0 with `low_density_budget`. Default: `0.7`.
1168    #[serde(default = "default_high_density_budget")]
1169    pub high_density_budget: f32,
1170    /// Token-budget fraction for low-density content in density-aware compression (#2481).
1171    /// Must sum to 1.0 with `high_density_budget`. Default: `0.3`.
1172    #[serde(default = "default_low_density_budget")]
1173    pub low_density_budget: f32,
1174    /// Performance-floor compression ratio predictor (#2460).
1175    #[serde(default)]
1176    pub predictor: CompressionPredictorConfig,
1177}
1178
1179fn default_sidequest_interval_turns() -> u32 {
1180    4
1181}
1182
1183fn default_sidequest_max_eviction_ratio() -> f32 {
1184    0.5
1185}
1186
1187fn default_sidequest_max_cursors() -> usize {
1188    30
1189}
1190
1191fn default_sidequest_min_cursor_tokens() -> usize {
1192    100
1193}
1194
1195/// Configuration for LLM-driven side-thread tool output eviction (#1885).
1196#[derive(Debug, Clone, Deserialize, Serialize)]
1197#[serde(default)]
1198pub struct SidequestConfig {
1199    /// Enable `SideQuest` eviction. Default: `false`.
1200    pub enabled: bool,
1201    /// Run eviction every N user turns. Default: `4`.
1202    #[serde(default = "default_sidequest_interval_turns")]
1203    pub interval_turns: u32,
1204    /// Maximum fraction of tool outputs to evict per pass. Default: `0.5`.
1205    #[serde(default = "default_sidequest_max_eviction_ratio")]
1206    pub max_eviction_ratio: f32,
1207    /// Maximum cursor entries in eviction prompt (largest outputs first). Default: `30`.
1208    #[serde(default = "default_sidequest_max_cursors")]
1209    pub max_cursors: usize,
1210    /// Exclude tool outputs smaller than this token count from eviction candidates.
1211    /// Default: `100`.
1212    #[serde(default = "default_sidequest_min_cursor_tokens")]
1213    pub min_cursor_tokens: usize,
1214}
1215
1216impl Default for SidequestConfig {
1217    fn default() -> Self {
1218        Self {
1219            enabled: false,
1220            interval_turns: default_sidequest_interval_turns(),
1221            max_eviction_ratio: default_sidequest_max_eviction_ratio(),
1222            max_cursors: default_sidequest_max_cursors(),
1223            min_cursor_tokens: default_sidequest_min_cursor_tokens(),
1224        }
1225    }
1226}
1227
1228/// Configuration for the knowledge graph memory subsystem (`[memory.graph]` TOML section).
1229///
1230/// # Security
1231///
1232/// Entity names, relation labels, and fact strings extracted by the LLM are stored verbatim
1233/// without PII redaction. This is a known pre-1.0 MVP limitation. Do not enable graph memory
1234/// when processing conversations that may contain personal, medical, or sensitive data until
1235/// a redaction pass is implemented on the write path.
1236#[derive(Debug, Clone, Deserialize, Serialize)]
1237#[serde(default)]
1238pub struct GraphConfig {
1239    pub enabled: bool,
1240    pub extract_model: String,
1241    #[serde(default = "default_graph_max_entities_per_message")]
1242    pub max_entities_per_message: usize,
1243    #[serde(default = "default_graph_max_edges_per_message")]
1244    pub max_edges_per_message: usize,
1245    #[serde(default = "default_graph_community_refresh_interval")]
1246    pub community_refresh_interval: usize,
1247    #[serde(default = "default_graph_entity_similarity_threshold")]
1248    pub entity_similarity_threshold: f32,
1249    #[serde(default = "default_graph_extraction_timeout_secs")]
1250    pub extraction_timeout_secs: u64,
1251    #[serde(default)]
1252    pub use_embedding_resolution: bool,
1253    #[serde(default = "default_graph_entity_ambiguous_threshold")]
1254    pub entity_ambiguous_threshold: f32,
1255    #[serde(default = "default_graph_max_hops")]
1256    pub max_hops: u32,
1257    #[serde(default = "default_graph_recall_limit")]
1258    pub recall_limit: usize,
1259    /// Days to retain expired (superseded) edges before deletion. Default: 90.
1260    #[serde(default = "default_graph_expired_edge_retention_days")]
1261    pub expired_edge_retention_days: u32,
1262    /// Maximum entities to retain in the graph. 0 = unlimited.
1263    #[serde(default)]
1264    pub max_entities: usize,
1265    /// Maximum prompt size in bytes for community summary generation. Default: 8192.
1266    #[serde(default = "default_graph_community_summary_max_prompt_bytes")]
1267    pub community_summary_max_prompt_bytes: usize,
1268    /// Maximum concurrent LLM calls during community summarization. Default: 4.
1269    #[serde(default = "default_graph_community_summary_concurrency")]
1270    pub community_summary_concurrency: usize,
1271    /// Number of edges fetched per chunk during community detection. Default: 10000.
1272    /// Set to 0 to disable chunking and load all edges at once (legacy behavior).
1273    #[serde(default = "default_lpa_edge_chunk_size")]
1274    pub lpa_edge_chunk_size: usize,
1275    /// Temporal recency decay rate for graph recall scoring (units: 1/day).
1276    ///
1277    /// When > 0, recent edges receive a small additive score boost over older edges.
1278    /// The boost formula is `1 / (1 + age_days * rate)`, blended additively with the base
1279    /// composite score. Default 0.0 preserves existing scoring behavior exactly.
1280    #[serde(
1281        default = "default_graph_temporal_decay_rate",
1282        deserialize_with = "validate_temporal_decay_rate"
1283    )]
1284    pub temporal_decay_rate: f64,
1285    /// Maximum number of historical edge versions returned by `edge_history()`. Default: 100.
1286    ///
1287    /// Caps the result set returned for a given source entity + predicate pair. Prevents
1288    /// unbounded memory usage for high-churn predicates when this method is exposed via TUI
1289    /// or API endpoints.
1290    #[serde(default = "default_graph_edge_history_limit")]
1291    pub edge_history_limit: usize,
1292    /// A-MEM dynamic note linking configuration.
1293    ///
1294    /// When `note_linking.enabled = true`, entities extracted from each message are linked to
1295    /// semantically similar entities via `similar_to` edges. Requires an embedding store
1296    /// (`qdrant` or `sqlite` vector backend) to be configured.
1297    #[serde(default)]
1298    pub note_linking: NoteLinkingConfig,
1299    /// SYNAPSE spreading activation retrieval configuration.
1300    ///
1301    /// When `spreading_activation.enabled = true`, graph recall uses spreading activation
1302    /// with lateral inhibition and temporal decay instead of BFS.
1303    #[serde(default)]
1304    pub spreading_activation: SpreadingActivationConfig,
1305    /// A-MEM link weight decay: multiplicative factor applied to `retrieval_count`
1306    /// for un-retrieved edges each decay pass. Range: `(0.0, 1.0]`. Default: `0.95`.
1307    #[serde(
1308        default = "default_link_weight_decay_lambda",
1309        deserialize_with = "validate_link_weight_decay_lambda"
1310    )]
1311    pub link_weight_decay_lambda: f64,
1312    /// Seconds between link weight decay passes. Default: `86400` (24 hours).
1313    #[serde(default = "default_link_weight_decay_interval_secs")]
1314    pub link_weight_decay_interval_secs: u64,
1315    /// Kumiho AGM-inspired belief revision configuration.
1316    ///
1317    /// When `belief_revision.enabled = true`, new edges that semantically contradict existing
1318    /// edges for the same entity pair trigger revision: the old edge is invalidated with a
1319    /// `superseded_by` pointer and the new edge becomes the current belief.
1320    #[serde(default)]
1321    pub belief_revision: BeliefRevisionConfig,
1322    /// D-MEM RPE-based tiered graph extraction routing.
1323    ///
1324    /// When `rpe.enabled = true`, low-surprise turns skip the expensive MAGMA LLM extraction
1325    /// pipeline. A consecutive-skip safety valve ensures no turn is silently skipped indefinitely.
1326    #[serde(default)]
1327    pub rpe: RpeConfig,
1328    /// `SQLite` connection pool size dedicated to graph operations.
1329    ///
1330    /// Graph tables share the same database file as messages/embeddings but use a
1331    /// separate pool to prevent pool starvation when community detection or spreading
1332    /// activation runs concurrently with regular memory operations. Default: `3`.
1333    #[serde(default = "default_graph_pool_size")]
1334    pub pool_size: u32,
1335}
1336
1337fn default_graph_pool_size() -> u32 {
1338    3
1339}
1340
1341impl Default for GraphConfig {
1342    fn default() -> Self {
1343        Self {
1344            enabled: false,
1345            extract_model: String::new(),
1346            max_entities_per_message: default_graph_max_entities_per_message(),
1347            max_edges_per_message: default_graph_max_edges_per_message(),
1348            community_refresh_interval: default_graph_community_refresh_interval(),
1349            entity_similarity_threshold: default_graph_entity_similarity_threshold(),
1350            extraction_timeout_secs: default_graph_extraction_timeout_secs(),
1351            use_embedding_resolution: false,
1352            entity_ambiguous_threshold: default_graph_entity_ambiguous_threshold(),
1353            max_hops: default_graph_max_hops(),
1354            recall_limit: default_graph_recall_limit(),
1355            expired_edge_retention_days: default_graph_expired_edge_retention_days(),
1356            max_entities: 0,
1357            community_summary_max_prompt_bytes: default_graph_community_summary_max_prompt_bytes(),
1358            community_summary_concurrency: default_graph_community_summary_concurrency(),
1359            lpa_edge_chunk_size: default_lpa_edge_chunk_size(),
1360            temporal_decay_rate: default_graph_temporal_decay_rate(),
1361            edge_history_limit: default_graph_edge_history_limit(),
1362            note_linking: NoteLinkingConfig::default(),
1363            spreading_activation: SpreadingActivationConfig::default(),
1364            link_weight_decay_lambda: default_link_weight_decay_lambda(),
1365            link_weight_decay_interval_secs: default_link_weight_decay_interval_secs(),
1366            belief_revision: BeliefRevisionConfig::default(),
1367            rpe: RpeConfig::default(),
1368            pool_size: default_graph_pool_size(),
1369        }
1370    }
1371}
1372
1373fn default_consolidation_confidence_threshold() -> f32 {
1374    0.7
1375}
1376
1377fn default_consolidation_sweep_interval_secs() -> u64 {
1378    3600
1379}
1380
1381fn default_consolidation_sweep_batch_size() -> usize {
1382    50
1383}
1384
1385fn default_consolidation_similarity_threshold() -> f32 {
1386    0.85
1387}
1388
1389/// Configuration for the All-Mem lifelong memory consolidation sweep (`[memory.consolidation]`).
1390///
1391/// When `enabled = true`, a background loop periodically clusters semantically similar messages
1392/// and merges them into consolidated entries via an LLM call. Originals are never deleted —
1393/// they are marked as consolidated and deprioritized in recall via temporal decay.
1394#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1395#[serde(default)]
1396pub struct ConsolidationConfig {
1397    /// Enable the consolidation background loop. Default: `false`.
1398    pub enabled: bool,
1399    /// Provider name from `[[llm.providers]]` for consolidation LLM calls.
1400    /// Falls back to the primary provider when empty. Default: `""`.
1401    #[serde(default)]
1402    pub consolidation_provider: ProviderName,
1403    /// Minimum LLM-assigned confidence for a topology op to be applied. Default: `0.7`.
1404    #[serde(default = "default_consolidation_confidence_threshold")]
1405    pub confidence_threshold: f32,
1406    /// How often the background consolidation sweep runs, in seconds. Default: `3600`.
1407    #[serde(default = "default_consolidation_sweep_interval_secs")]
1408    pub sweep_interval_secs: u64,
1409    /// Maximum number of messages to evaluate per sweep cycle. Default: `50`.
1410    #[serde(default = "default_consolidation_sweep_batch_size")]
1411    pub sweep_batch_size: usize,
1412    /// Minimum cosine similarity for two messages to be considered consolidation candidates.
1413    /// Default: `0.85`.
1414    #[serde(default = "default_consolidation_similarity_threshold")]
1415    pub similarity_threshold: f32,
1416}
1417
1418impl Default for ConsolidationConfig {
1419    fn default() -> Self {
1420        Self {
1421            enabled: false,
1422            consolidation_provider: ProviderName::default(),
1423            confidence_threshold: default_consolidation_confidence_threshold(),
1424            sweep_interval_secs: default_consolidation_sweep_interval_secs(),
1425            sweep_batch_size: default_consolidation_sweep_batch_size(),
1426            similarity_threshold: default_consolidation_similarity_threshold(),
1427        }
1428    }
1429}
1430
1431fn default_link_weight_decay_lambda() -> f64 {
1432    0.95
1433}
1434
1435fn default_link_weight_decay_interval_secs() -> u64 {
1436    86400
1437}
1438
1439fn validate_link_weight_decay_lambda<'de, D>(deserializer: D) -> Result<f64, D::Error>
1440where
1441    D: serde::Deserializer<'de>,
1442{
1443    let value = <f64 as serde::Deserialize>::deserialize(deserializer)?;
1444    if value.is_nan() || value.is_infinite() {
1445        return Err(serde::de::Error::custom(
1446            "link_weight_decay_lambda must be a finite number",
1447        ));
1448    }
1449    if !(value > 0.0 && value <= 1.0) {
1450        return Err(serde::de::Error::custom(
1451            "link_weight_decay_lambda must be in (0.0, 1.0]",
1452        ));
1453    }
1454    Ok(value)
1455}
1456
1457fn validate_admission_threshold<'de, D>(deserializer: D) -> Result<f32, D::Error>
1458where
1459    D: serde::Deserializer<'de>,
1460{
1461    let value = <f32 as serde::Deserialize>::deserialize(deserializer)?;
1462    if value.is_nan() || value.is_infinite() {
1463        return Err(serde::de::Error::custom(
1464            "threshold must be a finite number",
1465        ));
1466    }
1467    if !(0.0..=1.0).contains(&value) {
1468        return Err(serde::de::Error::custom("threshold must be in [0.0, 1.0]"));
1469    }
1470    Ok(value)
1471}
1472
1473fn validate_admission_fast_path_margin<'de, D>(deserializer: D) -> Result<f32, D::Error>
1474where
1475    D: serde::Deserializer<'de>,
1476{
1477    let value = <f32 as serde::Deserialize>::deserialize(deserializer)?;
1478    if value.is_nan() || value.is_infinite() {
1479        return Err(serde::de::Error::custom(
1480            "fast_path_margin must be a finite number",
1481        ));
1482    }
1483    if !(0.0..=1.0).contains(&value) {
1484        return Err(serde::de::Error::custom(
1485            "fast_path_margin must be in [0.0, 1.0]",
1486        ));
1487    }
1488    Ok(value)
1489}
1490
1491fn default_admission_threshold() -> f32 {
1492    0.40
1493}
1494
1495fn default_admission_fast_path_margin() -> f32 {
1496    0.15
1497}
1498
1499fn default_rl_min_samples() -> u32 {
1500    500
1501}
1502
1503fn default_rl_retrain_interval_secs() -> u64 {
1504    3600
1505}
1506
1507/// Admission decision strategy.
1508///
1509/// `Heuristic` uses the existing multi-factor weighted score with an optional LLM call.
1510/// `Rl` replaces the LLM-based `future_utility` factor with a trained logistic regression model.
1511#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
1512#[serde(rename_all = "snake_case")]
1513pub enum AdmissionStrategy {
1514    /// Current A-MAC behavior: weighted heuristics + optional LLM call. Default.
1515    #[default]
1516    Heuristic,
1517    /// Learned model: logistic regression trained on recall feedback.
1518    /// Falls back to `Heuristic` when training data is below `rl_min_samples`.
1519    Rl,
1520}
1521
1522fn validate_admission_weight<'de, D>(deserializer: D) -> Result<f32, D::Error>
1523where
1524    D: serde::Deserializer<'de>,
1525{
1526    let value = <f32 as serde::Deserialize>::deserialize(deserializer)?;
1527    if value < 0.0 {
1528        return Err(serde::de::Error::custom(
1529            "admission weight must be non-negative (>= 0.0)",
1530        ));
1531    }
1532    Ok(value)
1533}
1534
1535/// Per-factor weights for the A-MAC admission score (`[memory.admission.weights]`).
1536///
1537/// Weights are normalized at runtime (divided by their sum), so they do not need to sum to 1.0.
1538/// All values must be non-negative.
1539#[derive(Debug, Clone, Deserialize, Serialize)]
1540#[serde(default)]
1541pub struct AdmissionWeights {
1542    /// LLM-estimated future reuse probability. Default: `0.30`.
1543    #[serde(deserialize_with = "validate_admission_weight")]
1544    pub future_utility: f32,
1545    /// Factual confidence heuristic (inverse of hedging markers). Default: `0.15`.
1546    #[serde(deserialize_with = "validate_admission_weight")]
1547    pub factual_confidence: f32,
1548    /// Semantic novelty: 1 - max similarity to existing memories. Default: `0.30`.
1549    #[serde(deserialize_with = "validate_admission_weight")]
1550    pub semantic_novelty: f32,
1551    /// Temporal recency: always 1.0 at write time. Default: `0.10`.
1552    #[serde(deserialize_with = "validate_admission_weight")]
1553    pub temporal_recency: f32,
1554    /// Content type prior based on role. Default: `0.15`.
1555    #[serde(deserialize_with = "validate_admission_weight")]
1556    pub content_type_prior: f32,
1557    /// Goal-conditioned utility (#2408). `0.0` when `goal_conditioned_write = false`.
1558    /// When enabled, set this alongside reducing `future_utility` so total sums remain stable.
1559    /// Normalized automatically at runtime. Default: `0.0`.
1560    #[serde(deserialize_with = "validate_admission_weight")]
1561    pub goal_utility: f32,
1562}
1563
1564impl Default for AdmissionWeights {
1565    fn default() -> Self {
1566        Self {
1567            future_utility: 0.30,
1568            factual_confidence: 0.15,
1569            semantic_novelty: 0.30,
1570            temporal_recency: 0.10,
1571            content_type_prior: 0.15,
1572            goal_utility: 0.0,
1573        }
1574    }
1575}
1576
1577impl AdmissionWeights {
1578    /// Return weights normalized so they sum to 1.0.
1579    ///
1580    /// All weights are non-negative; the sum is always > 0 when defaults are used.
1581    #[must_use]
1582    pub fn normalized(&self) -> Self {
1583        let sum = self.future_utility
1584            + self.factual_confidence
1585            + self.semantic_novelty
1586            + self.temporal_recency
1587            + self.content_type_prior
1588            + self.goal_utility;
1589        if sum <= f32::EPSILON {
1590            return Self::default();
1591        }
1592        Self {
1593            future_utility: self.future_utility / sum,
1594            factual_confidence: self.factual_confidence / sum,
1595            semantic_novelty: self.semantic_novelty / sum,
1596            temporal_recency: self.temporal_recency / sum,
1597            content_type_prior: self.content_type_prior / sum,
1598            goal_utility: self.goal_utility / sum,
1599        }
1600    }
1601}
1602
1603/// Configuration for A-MAC adaptive memory admission control (`[memory.admission]` TOML section).
1604///
1605/// When `enabled = true`, a write-time gate evaluates each message before saving to memory.
1606/// Messages below the composite admission threshold are rejected and not persisted.
1607#[derive(Debug, Clone, Deserialize, Serialize)]
1608#[serde(default)]
1609pub struct AdmissionConfig {
1610    /// Enable A-MAC admission control. Default: `false`.
1611    pub enabled: bool,
1612    /// Composite score threshold below which messages are rejected. Range: `[0.0, 1.0]`.
1613    /// Default: `0.40`.
1614    #[serde(deserialize_with = "validate_admission_threshold")]
1615    pub threshold: f32,
1616    /// Margin above threshold at which the fast path admits without an LLM call. Range: `[0.0, 1.0]`.
1617    /// When heuristic score >= threshold + margin, LLM call is skipped. Default: `0.15`.
1618    #[serde(deserialize_with = "validate_admission_fast_path_margin")]
1619    pub fast_path_margin: f32,
1620    /// Provider name from `[[llm.providers]]` for `future_utility` LLM evaluation.
1621    /// Falls back to the primary provider when empty. Default: `""`.
1622    pub admission_provider: ProviderName,
1623    /// Per-factor weights. Normalized at runtime. Default: `{0.30, 0.15, 0.30, 0.10, 0.15}`.
1624    pub weights: AdmissionWeights,
1625    /// Admission decision strategy. Default: `heuristic`.
1626    #[serde(default)]
1627    pub admission_strategy: AdmissionStrategy,
1628    /// Minimum training samples before the RL model is activated.
1629    /// Below this count the system falls back to `Heuristic`. Default: `500`.
1630    #[serde(default = "default_rl_min_samples")]
1631    pub rl_min_samples: u32,
1632    /// Background RL model retraining interval in seconds. Default: `3600`.
1633    #[serde(default = "default_rl_retrain_interval_secs")]
1634    pub rl_retrain_interval_secs: u64,
1635    /// Enable goal-conditioned write gate (#2408). When `true`, memories are scored
1636    /// against the current task goal and rejected if relevance is below `goal_utility_threshold`.
1637    /// Zero regression when `false`. Default: `false`.
1638    #[serde(default)]
1639    pub goal_conditioned_write: bool,
1640    /// Provider name from `[[llm.providers]]` for goal-utility LLM refinement.
1641    /// Used only for borderline cases (similarity within 0.1 of threshold).
1642    /// Falls back to the primary provider when empty. Default: `""`.
1643    #[serde(default)]
1644    pub goal_utility_provider: ProviderName,
1645    /// Minimum cosine similarity between goal embedding and candidate memory
1646    /// to consider it goal-relevant. Below this, `goal_utility = 0.0`. Default: `0.4`.
1647    #[serde(default = "default_goal_utility_threshold")]
1648    pub goal_utility_threshold: f32,
1649    /// Weight of the `goal_utility` factor in the composite admission score.
1650    /// Set to `0.0` to disable (equivalent to `goal_conditioned_write = false`). Default: `0.25`.
1651    #[serde(default = "default_goal_utility_weight")]
1652    pub goal_utility_weight: f32,
1653}
1654
1655fn default_goal_utility_threshold() -> f32 {
1656    0.4
1657}
1658
1659fn default_goal_utility_weight() -> f32 {
1660    0.25
1661}
1662
1663impl Default for AdmissionConfig {
1664    fn default() -> Self {
1665        Self {
1666            enabled: false,
1667            threshold: default_admission_threshold(),
1668            fast_path_margin: default_admission_fast_path_margin(),
1669            admission_provider: ProviderName::default(),
1670            weights: AdmissionWeights::default(),
1671            admission_strategy: AdmissionStrategy::default(),
1672            rl_min_samples: default_rl_min_samples(),
1673            rl_retrain_interval_secs: default_rl_retrain_interval_secs(),
1674            goal_conditioned_write: false,
1675            goal_utility_provider: ProviderName::default(),
1676            goal_utility_threshold: default_goal_utility_threshold(),
1677            goal_utility_weight: default_goal_utility_weight(),
1678        }
1679    }
1680}
1681
1682/// Routing strategy for `[memory.store_routing]`.
1683#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
1684#[serde(rename_all = "snake_case")]
1685pub enum StoreRoutingStrategy {
1686    /// Pure heuristic pattern matching. Zero LLM calls. Default.
1687    #[default]
1688    Heuristic,
1689    /// LLM-based classification via `routing_classifier_provider`.
1690    Llm,
1691    /// Heuristic first; escalates to LLM only when confidence is low.
1692    Hybrid,
1693}
1694
1695/// Configuration for cost-sensitive store routing (`[memory.store_routing]`).
1696///
1697/// Controls how each query is classified and routed to the appropriate memory
1698/// backend(s), avoiding unnecessary store queries for simple lookups.
1699#[derive(Debug, Clone, Deserialize, Serialize)]
1700#[serde(default)]
1701pub struct StoreRoutingConfig {
1702    /// Enable configurable store routing. When `false`, `HeuristicRouter` is used
1703    /// directly (existing behavior). Default: `false`.
1704    pub enabled: bool,
1705    /// Routing strategy. Default: `heuristic`.
1706    pub strategy: StoreRoutingStrategy,
1707    /// Provider name from `[[llm.providers]]` for LLM-based classification.
1708    /// Falls back to the primary provider when empty. Default: `""`.
1709    pub routing_classifier_provider: ProviderName,
1710    /// Route to use when the classifier is uncertain (confidence < threshold).
1711    /// Default: `"hybrid"`.
1712    pub fallback_route: String,
1713    /// Confidence threshold below which `HybridRouter` escalates to LLM.
1714    /// Range: `[0.0, 1.0]`. Default: `0.7`.
1715    pub confidence_threshold: f32,
1716}
1717
1718impl Default for StoreRoutingConfig {
1719    fn default() -> Self {
1720        Self {
1721            enabled: false,
1722            strategy: StoreRoutingStrategy::Heuristic,
1723            routing_classifier_provider: ProviderName::default(),
1724            fallback_route: "hybrid".into(),
1725            confidence_threshold: 0.7,
1726        }
1727    }
1728}
1729
1730/// Persona memory layer configuration (#2461).
1731///
1732/// When `enabled = true`, user preferences and domain knowledge are extracted from
1733/// conversation history via a cheap LLM provider and injected after the system prompt.
1734#[derive(Debug, Clone, Deserialize, Serialize)]
1735#[serde(default)]
1736pub struct PersonaConfig {
1737    /// Enable persona memory extraction and injection. Default: `false`.
1738    pub enabled: bool,
1739    /// Provider name from `[[llm.providers]]` for persona extraction.
1740    /// Should be a cheap/fast model. Falls back to the primary provider when empty.
1741    pub persona_provider: ProviderName,
1742    /// Minimum confidence threshold for facts included in context. Default: `0.6`.
1743    pub min_confidence: f64,
1744    /// Minimum user messages before extraction runs in a session. Default: `3`.
1745    pub min_messages: usize,
1746    /// Maximum messages sent to the LLM per extraction pass. Default: `10`.
1747    pub max_messages: usize,
1748    /// LLM timeout for the extraction call in seconds. Default: `10`.
1749    pub extraction_timeout_secs: u64,
1750    /// Token budget allocated to persona context in assembly. Default: `500`.
1751    pub context_budget_tokens: usize,
1752}
1753
1754impl Default for PersonaConfig {
1755    fn default() -> Self {
1756        Self {
1757            enabled: false,
1758            persona_provider: ProviderName::default(),
1759            min_confidence: 0.6,
1760            min_messages: 3,
1761            max_messages: 10,
1762            extraction_timeout_secs: 10,
1763            context_budget_tokens: 500,
1764        }
1765    }
1766}
1767
1768#[cfg(test)]
1769mod tests {
1770    use super::*;
1771
1772    // Verify that serde deserialization routes through FromStr so that removed variants
1773    // (task_aware_mig) fall back to Reactive instead of hard-erroring when found in TOML.
1774    #[test]
1775    fn pruning_strategy_toml_task_aware_mig_falls_back_to_reactive() {
1776        #[derive(serde::Deserialize)]
1777        struct Wrapper {
1778            #[allow(dead_code)]
1779            pruning_strategy: PruningStrategy,
1780        }
1781        let toml = r#"pruning_strategy = "task_aware_mig""#;
1782        let w: Wrapper = toml::from_str(toml).expect("should deserialize without error");
1783        assert_eq!(
1784            w.pruning_strategy,
1785            PruningStrategy::Reactive,
1786            "task_aware_mig must fall back to Reactive"
1787        );
1788    }
1789
1790    #[test]
1791    fn pruning_strategy_toml_round_trip() {
1792        #[derive(serde::Deserialize)]
1793        struct Wrapper {
1794            #[allow(dead_code)]
1795            pruning_strategy: PruningStrategy,
1796        }
1797        for (input, expected) in [
1798            ("reactive", PruningStrategy::Reactive),
1799            ("task_aware", PruningStrategy::TaskAware),
1800            ("mig", PruningStrategy::Mig),
1801        ] {
1802            let toml = format!(r#"pruning_strategy = "{input}""#);
1803            let w: Wrapper = toml::from_str(&toml)
1804                .unwrap_or_else(|e| panic!("failed to deserialize `{input}`: {e}"));
1805            assert_eq!(w.pruning_strategy, expected, "mismatch for `{input}`");
1806        }
1807    }
1808
1809    #[test]
1810    fn pruning_strategy_toml_unknown_value_errors() {
1811        #[derive(serde::Deserialize)]
1812        #[allow(dead_code)]
1813        struct Wrapper {
1814            pruning_strategy: PruningStrategy,
1815        }
1816        let toml = r#"pruning_strategy = "nonexistent_strategy""#;
1817        assert!(
1818            toml::from_str::<Wrapper>(toml).is_err(),
1819            "unknown strategy must produce an error"
1820        );
1821    }
1822
1823    #[test]
1824    fn tier_config_defaults_are_correct() {
1825        let cfg = TierConfig::default();
1826        assert!(!cfg.enabled);
1827        assert_eq!(cfg.promotion_min_sessions, 3);
1828        assert!((cfg.similarity_threshold - 0.92).abs() < f32::EPSILON);
1829        assert_eq!(cfg.sweep_interval_secs, 3600);
1830        assert_eq!(cfg.sweep_batch_size, 100);
1831    }
1832
1833    #[test]
1834    fn tier_config_rejects_min_sessions_below_2() {
1835        let toml = "promotion_min_sessions = 1";
1836        assert!(toml::from_str::<TierConfig>(toml).is_err());
1837    }
1838
1839    #[test]
1840    fn tier_config_rejects_similarity_threshold_below_0_5() {
1841        let toml = "similarity_threshold = 0.4";
1842        assert!(toml::from_str::<TierConfig>(toml).is_err());
1843    }
1844
1845    #[test]
1846    fn tier_config_rejects_zero_sweep_batch_size() {
1847        let toml = "sweep_batch_size = 0";
1848        assert!(toml::from_str::<TierConfig>(toml).is_err());
1849    }
1850
1851    fn deserialize_importance_weight(toml_val: &str) -> Result<SemanticConfig, toml::de::Error> {
1852        let input = format!("importance_weight = {toml_val}");
1853        toml::from_str::<SemanticConfig>(&input)
1854    }
1855
1856    #[test]
1857    fn importance_weight_default_is_0_15() {
1858        let cfg = SemanticConfig::default();
1859        assert!((cfg.importance_weight - 0.15).abs() < f64::EPSILON);
1860    }
1861
1862    #[test]
1863    fn importance_weight_valid_zero() {
1864        let cfg = deserialize_importance_weight("0.0").unwrap();
1865        assert!((cfg.importance_weight - 0.0_f64).abs() < f64::EPSILON);
1866    }
1867
1868    #[test]
1869    fn importance_weight_valid_one() {
1870        let cfg = deserialize_importance_weight("1.0").unwrap();
1871        assert!((cfg.importance_weight - 1.0_f64).abs() < f64::EPSILON);
1872    }
1873
1874    #[test]
1875    fn importance_weight_rejects_near_zero_negative() {
1876        // TOML does not have a NaN literal, but we can test via a f64 that
1877        // the validator rejects out-of-range values. Test with negative here
1878        // and rely on validate_importance_weight rejecting non-finite via
1879        // a constructed deserializer call.
1880        let result = deserialize_importance_weight("-0.01");
1881        assert!(
1882            result.is_err(),
1883            "negative importance_weight must be rejected"
1884        );
1885    }
1886
1887    #[test]
1888    fn importance_weight_rejects_negative() {
1889        let result = deserialize_importance_weight("-1.0");
1890        assert!(result.is_err(), "negative value must be rejected");
1891    }
1892
1893    #[test]
1894    fn importance_weight_rejects_greater_than_one() {
1895        let result = deserialize_importance_weight("1.01");
1896        assert!(result.is_err(), "value > 1.0 must be rejected");
1897    }
1898
1899    // ── AdmissionWeights::normalized() tests (#2317) ────────────────────────
1900
1901    // Test: weights that don't sum to 1.0 are normalized to sum to 1.0.
1902    #[test]
1903    fn admission_weights_normalized_sums_to_one() {
1904        let w = AdmissionWeights {
1905            future_utility: 2.0,
1906            factual_confidence: 1.0,
1907            semantic_novelty: 3.0,
1908            temporal_recency: 1.0,
1909            content_type_prior: 3.0,
1910            goal_utility: 0.0,
1911        };
1912        let n = w.normalized();
1913        let sum = n.future_utility
1914            + n.factual_confidence
1915            + n.semantic_novelty
1916            + n.temporal_recency
1917            + n.content_type_prior;
1918        assert!(
1919            (sum - 1.0).abs() < 0.001,
1920            "normalized weights must sum to 1.0, got {sum}"
1921        );
1922    }
1923
1924    // Test: already-normalized weights are preserved.
1925    #[test]
1926    fn admission_weights_normalized_preserves_already_unit_sum() {
1927        let w = AdmissionWeights::default();
1928        let n = w.normalized();
1929        let sum = n.future_utility
1930            + n.factual_confidence
1931            + n.semantic_novelty
1932            + n.temporal_recency
1933            + n.content_type_prior;
1934        assert!(
1935            (sum - 1.0).abs() < 0.001,
1936            "default weights sum to ~1.0 after normalization"
1937        );
1938    }
1939
1940    // Test: zero weights fall back to default (no divide-by-zero panic).
1941    #[test]
1942    fn admission_weights_normalized_zero_sum_falls_back_to_default() {
1943        let w = AdmissionWeights {
1944            future_utility: 0.0,
1945            factual_confidence: 0.0,
1946            semantic_novelty: 0.0,
1947            temporal_recency: 0.0,
1948            content_type_prior: 0.0,
1949            goal_utility: 0.0,
1950        };
1951        let n = w.normalized();
1952        let default = AdmissionWeights::default();
1953        assert!(
1954            (n.future_utility - default.future_utility).abs() < 0.001,
1955            "zero-sum weights must fall back to defaults"
1956        );
1957    }
1958
1959    // Test: AdmissionConfig default values match documented defaults.
1960    #[test]
1961    fn admission_config_defaults() {
1962        let cfg = AdmissionConfig::default();
1963        assert!(!cfg.enabled);
1964        assert!((cfg.threshold - 0.40).abs() < 0.001);
1965        assert!((cfg.fast_path_margin - 0.15).abs() < 0.001);
1966        assert!(cfg.admission_provider.is_empty());
1967    }
1968
1969    // ── SpreadingActivationConfig tests (#2514) ──────────────────────────────
1970
1971    #[test]
1972    fn spreading_activation_default_recall_timeout_ms_is_1000() {
1973        let cfg = SpreadingActivationConfig::default();
1974        assert_eq!(
1975            cfg.recall_timeout_ms, 1000,
1976            "default recall_timeout_ms must be 1000ms"
1977        );
1978    }
1979
1980    #[test]
1981    fn spreading_activation_toml_recall_timeout_ms_round_trip() {
1982        #[derive(serde::Deserialize)]
1983        struct Wrapper {
1984            recall_timeout_ms: u64,
1985        }
1986        let toml = "recall_timeout_ms = 500";
1987        let w: Wrapper = toml::from_str(toml).unwrap();
1988        assert_eq!(w.recall_timeout_ms, 500);
1989    }
1990
1991    #[test]
1992    fn spreading_activation_validate_cross_field_constraints() {
1993        let mut cfg = SpreadingActivationConfig::default();
1994        // Default activation_threshold (0.1) < inhibition_threshold (0.8) → must be Ok.
1995        assert!(cfg.validate().is_ok());
1996
1997        // Equal thresholds must be rejected.
1998        cfg.activation_threshold = 0.5;
1999        cfg.inhibition_threshold = 0.5;
2000        assert!(cfg.validate().is_err());
2001    }
2002
2003    // ─── CompressionConfig: new Focus fields deserialization (#2510, #2481) ──
2004
2005    #[test]
2006    fn compression_config_focus_strategy_deserializes() {
2007        let toml = r#"strategy = "focus""#;
2008        let cfg: CompressionConfig = toml::from_str(toml).unwrap();
2009        assert_eq!(cfg.strategy, CompressionStrategy::Focus);
2010    }
2011
2012    #[test]
2013    fn compression_config_density_budget_defaults_on_deserialize() {
2014        // `#[serde(default = "...")]` applies during deserialization, not via Default::default().
2015        // Verify that omitting both fields yields the serde defaults (0.7 / 0.3).
2016        let toml = r#"strategy = "reactive""#;
2017        let cfg: CompressionConfig = toml::from_str(toml).unwrap();
2018        assert!((cfg.high_density_budget - 0.7).abs() < 1e-6);
2019        assert!((cfg.low_density_budget - 0.3).abs() < 1e-6);
2020    }
2021
2022    #[test]
2023    fn compression_config_density_budget_round_trip() {
2024        let toml = "strategy = \"reactive\"\nhigh_density_budget = 0.6\nlow_density_budget = 0.4";
2025        let cfg: CompressionConfig = toml::from_str(toml).unwrap();
2026        assert!((cfg.high_density_budget - 0.6).abs() < f32::EPSILON);
2027        assert!((cfg.low_density_budget - 0.4).abs() < f32::EPSILON);
2028    }
2029
2030    #[test]
2031    fn compression_config_focus_scorer_provider_default_empty() {
2032        let cfg = CompressionConfig::default();
2033        assert!(cfg.focus_scorer_provider.is_empty());
2034    }
2035
2036    #[test]
2037    fn compression_config_focus_scorer_provider_round_trip() {
2038        let toml = "strategy = \"focus\"\nfocus_scorer_provider = \"fast\"";
2039        let cfg: CompressionConfig = toml::from_str(toml).unwrap();
2040        assert_eq!(cfg.focus_scorer_provider.as_str(), "fast");
2041    }
2042}