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}