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