Skip to main content

zeph_config/memory/
consolidation.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Episodic and five-signal SYNAPSE consolidation configuration.
5//!
6//! Background daemons that promote/demote and consolidate episodic memories
7//! using the five-signal salience model.
8
9use crate::providers::ProviderName;
10use serde::{Deserialize, Serialize};
11
12// ── Episodic consolidation daemon config (issue #3799) ────────────────────────
13
14fn default_episodic_consolidation_interval_secs() -> u64 {
15    1800
16}
17
18fn default_episodic_consolidation_batch_size() -> usize {
19    30
20}
21
22fn default_episodic_consolidation_min_age_secs() -> u64 {
23    300
24}
25
26fn default_episodic_consolidation_dedup_jaccard_threshold() -> f32 {
27    0.6
28}
29
30// ── Five-signal SYNAPSE retrieval config (issue #4374) ────────────────────────
31
32fn default_five_signal_w_recency() -> f64 {
33    0.35
34}
35
36fn default_five_signal_w_relevance() -> f64 {
37    0.35
38}
39
40fn default_causal_bfs_max_depth() -> u32 {
41    10
42}
43
44fn default_neutral_causal_distance() -> u32 {
45    5
46}
47
48fn default_novelty_decay_rate() -> f64 {
49    0.1
50}
51
52fn default_five_signal_interval_seconds() -> u64 {
53    7200
54}
55
56fn default_five_signal_batch_size() -> usize {
57    500
58}
59
60fn default_five_signal_daemon_max_runtime_ms() -> u64 {
61    30_000
62}
63
64fn default_five_signal_promotion_score_threshold() -> f64 {
65    0.70
66}
67
68fn default_five_signal_demotion_score_threshold() -> f64 {
69    0.20
70}
71
72fn default_five_signal_top_k_per_run() -> usize {
73    500
74}
75
76/// Five-signal SYNAPSE retrieval configuration (issue #4374).
77///
78/// Extends SYNAPSE recall with three additional signals — access frequency, causal
79/// distance, and novelty — beyond the two-signal baseline (recency + relevance).
80/// All new signal weights default to `0.0`, preserving exact backward compatibility.
81///
82/// # Example (TOML)
83///
84/// ```toml
85/// [memory.five_signal]
86/// enabled = true
87/// w_recency   = 0.35
88/// w_relevance = 0.35
89/// w_frequency = 0.15
90/// w_causal    = 0.10
91/// w_novelty   = 0.05
92///
93/// [memory.five_signal.consolidation_daemon]
94/// enabled = true
95/// interval_seconds = 7200
96/// ```
97#[derive(Debug, Clone, Deserialize, Serialize)]
98pub struct FiveSignalConfig {
99    /// Master switch. When `false`, the five-signal code path contributes zero overhead.
100    #[serde(default)]
101    pub enabled: bool,
102    /// Weight for the recency signal. Default: `0.35`.
103    #[serde(default = "default_five_signal_w_recency")]
104    pub w_recency: f64,
105    /// Weight for the semantic relevance signal. Default: `0.35`.
106    #[serde(default = "default_five_signal_w_relevance")]
107    pub w_relevance: f64,
108    /// Weight for the access frequency signal. Default: `0.0` (baseline-compatible).
109    #[serde(default)]
110    pub w_frequency: f64,
111    /// Weight for the causal distance signal. Default: `0.0` (baseline-compatible).
112    #[serde(default)]
113    pub w_causal: f64,
114    /// Weight for the novelty signal. Default: `0.0` (baseline-compatible).
115    #[serde(default)]
116    pub w_novelty: f64,
117    /// Maximum BFS depth for causal distance computation. Default: `10`.
118    #[serde(default = "default_causal_bfs_max_depth")]
119    pub causal_bfs_max_depth: u32,
120    /// Causal distance assigned when no goal entity is set or a fact lies beyond
121    /// `causal_bfs_max_depth`. Default: `5`.
122    #[serde(default = "default_neutral_causal_distance")]
123    pub neutral_causal_distance: u32,
124    /// Decay rate λ in `exp(-λ × days)` for the novelty signal. Default: `0.1`.
125    #[serde(default = "default_novelty_decay_rate")]
126    pub novelty_decay_rate: f64,
127    /// Async consolidation daemon that promotes hot episodic facts to Qdrant.
128    #[serde(default)]
129    pub consolidation_daemon: FiveSignalConsolidationConfig,
130}
131
132impl Default for FiveSignalConfig {
133    fn default() -> Self {
134        Self {
135            enabled: false,
136            w_recency: default_five_signal_w_recency(),
137            w_relevance: default_five_signal_w_relevance(),
138            w_frequency: 0.0,
139            w_causal: 0.0,
140            w_novelty: 0.0,
141            causal_bfs_max_depth: default_causal_bfs_max_depth(),
142            neutral_causal_distance: default_neutral_causal_distance(),
143            novelty_decay_rate: default_novelty_decay_rate(),
144            consolidation_daemon: FiveSignalConsolidationConfig::default(),
145        }
146    }
147}
148
149/// Async consolidation daemon configuration for five-signal retrieval (issue #4374).
150///
151/// When `enabled = true`, a background task runs at `interval_seconds` intervals,
152/// evaluates the top `top_k_per_run` episodic facts by five-signal score, promotes
153/// facts above `promotion_score_threshold` to Qdrant, and demotes facts below
154/// `demotion_score_threshold` to `episodic_only` tier.
155///
156/// # Example (TOML)
157///
158/// ```toml
159/// [memory.five_signal.consolidation_daemon]
160/// enabled = true
161/// interval_seconds = 7200
162/// batch_size = 500
163/// promotion_score_threshold = 0.70
164/// demotion_score_threshold = 0.20
165/// ```
166#[derive(Debug, Clone, Deserialize, Serialize)]
167pub struct FiveSignalConsolidationConfig {
168    /// Enable the daemon. Requires the `scheduler` feature. Default: `false`.
169    #[serde(default)]
170    pub enabled: bool,
171    /// Interval between daemon runs in seconds. Default: `7200` (2 hours).
172    #[serde(default = "default_five_signal_interval_seconds")]
173    pub interval_seconds: u64,
174    /// Maximum facts processed (embed + upsert) per run. Default: `500`.
175    #[serde(default = "default_five_signal_batch_size")]
176    pub batch_size: usize,
177    /// Hard timeout per run in milliseconds. Default: `30000`.
178    #[serde(default = "default_five_signal_daemon_max_runtime_ms")]
179    pub daemon_max_runtime_ms: u64,
180    /// Five-signal score above which a fact is promoted to Qdrant. Default: `0.70`.
181    #[serde(default = "default_five_signal_promotion_score_threshold")]
182    pub promotion_score_threshold: f64,
183    /// Five-signal score below which a promoted fact is demoted. Default: `0.20`.
184    #[serde(default = "default_five_signal_demotion_score_threshold")]
185    pub demotion_score_threshold: f64,
186    /// Number of episodic facts queried per run (SQL LIMIT). Must be >= `batch_size`.
187    /// Default: `500`.
188    #[serde(default = "default_five_signal_top_k_per_run")]
189    pub top_k_per_run: usize,
190}
191
192impl Default for FiveSignalConsolidationConfig {
193    fn default() -> Self {
194        Self {
195            enabled: false,
196            interval_seconds: default_five_signal_interval_seconds(),
197            batch_size: default_five_signal_batch_size(),
198            daemon_max_runtime_ms: default_five_signal_daemon_max_runtime_ms(),
199            promotion_score_threshold: default_five_signal_promotion_score_threshold(),
200            demotion_score_threshold: default_five_signal_demotion_score_threshold(),
201            top_k_per_run: default_five_signal_top_k_per_run(),
202        }
203    }
204}
205
206/// Episodic-to-semantic consolidation daemon configuration (issue #3799).
207///
208/// When `enabled = true`, a background loop periodically sweeps mature `episodic_events`,
209/// extracts durable factual statements via LLM, deduplicates them against existing
210/// key facts using Jaccard similarity, and promotes accepted facts to the semantic tier
211/// in both `consolidated_facts` (`SQLite` persistence) and `zeph_key_facts` (Qdrant, if available).
212///
213/// # Example (TOML)
214///
215/// ```toml
216/// [memory.episodic_consolidation]
217/// enabled = false
218/// consolidation_provider = ""
219/// interval_secs = 1800
220/// batch_size = 30
221/// min_age_secs = 300
222/// dedup_jaccard_threshold = 0.6
223/// ```
224#[derive(Debug, Clone, Deserialize, Serialize)]
225#[serde(default)]
226pub struct EpisodicConsolidationConfig {
227    /// Enable the episodic consolidation daemon. Default: `false`.
228    pub enabled: bool,
229    /// Provider name from `[[llm.providers]]` for fact extraction LLM calls.
230    /// Falls back to the primary provider when empty.
231    pub consolidation_provider: ProviderName,
232    /// How often the consolidation sweep runs, in seconds. Default: `1800` (30 min).
233    #[serde(default = "default_episodic_consolidation_interval_secs")]
234    pub interval_secs: u64,
235    /// Maximum number of episodic events to process per sweep. Default: `30`.
236    #[serde(default = "default_episodic_consolidation_batch_size")]
237    pub batch_size: usize,
238    /// Minimum age in seconds before an episodic event is eligible. Default: `300` (5 min).
239    /// Prevents consolidating events from the active conversation.
240    #[serde(default = "default_episodic_consolidation_min_age_secs")]
241    pub min_age_secs: u64,
242    /// Jaccard similarity threshold for deduplication against existing key facts.
243    /// Facts with token-set Jaccard >= this value are considered duplicates. Default: `0.6`.
244    #[serde(default = "default_episodic_consolidation_dedup_jaccard_threshold")]
245    pub dedup_jaccard_threshold: f32,
246}
247
248impl Default for EpisodicConsolidationConfig {
249    fn default() -> Self {
250        Self {
251            enabled: false,
252            consolidation_provider: ProviderName::default(),
253            interval_secs: default_episodic_consolidation_interval_secs(),
254            batch_size: default_episodic_consolidation_batch_size(),
255            min_age_secs: default_episodic_consolidation_min_age_secs(),
256            dedup_jaccard_threshold: default_episodic_consolidation_dedup_jaccard_threshold(),
257        }
258    }
259}