Skip to main content

zeph_config/
fidelity.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Configuration for Context-Adaptive Memory (CAM) fidelity scoring.
5//!
6//! [`FidelityConfig`] is serialised from the `[memory.fidelity]` section in `config.toml`.
7//! When `enabled = false` (the default) the fidelity scorer is a complete no-op.
8
9use crate::providers::ProviderName;
10use serde::{Deserialize, Serialize};
11
12fn fidelity_lookahead_depth_default() -> u8 {
13    FidelityConfig::default_lookahead_depth()
14}
15
16/// Configuration for the heuristic fidelity scorer (CAM §8.1).
17///
18/// All weight fields must be positive. Weights are normalised at runtime by
19/// the sum of active weights (INV-05).
20///
21/// # Examples
22///
23/// ```
24/// use zeph_config::fidelity::FidelityConfig;
25///
26/// let cfg = FidelityConfig::default();
27/// assert!(!cfg.enabled, "fidelity scoring is off by default");
28/// assert!((cfg.w_semantic - 0.3).abs() < f32::EPSILON);
29/// ```
30#[derive(Debug, Clone, Deserialize, Serialize)]
31#[serde(default)]
32pub struct FidelityConfig {
33    /// Master switch. When `false`, no fidelity scoring occurs.
34    pub enabled: bool,
35    /// Cosine/keyword semantic relevance weight.
36    ///
37    /// Previously named `w_keyword` in config — that name is still accepted for compatibility.
38    #[serde(alias = "w_keyword")]
39    pub w_semantic: f32,
40    /// Recency weight.
41    pub w_temporal: f32,
42    /// Role-based importance weight.
43    pub w_importance: f32,
44    /// Plan-hint relevance weight (active only when `planned_tools` is non-empty).
45    pub w_plan: f32,
46    /// Score threshold above which a message retains `Full` fidelity.
47    pub full_threshold: f32,
48    /// Score threshold above which a message is `Compressed` (not `Placeholder`).
49    pub compressed_threshold: f32,
50    /// Maximum tokens kept when rendering a `Compressed` message.
51    pub compressed_max_tokens: usize,
52    /// Budget ratio at which `AgeMem` triggers a proactive regrade.
53    pub regrade_threshold: f32,
54    /// Minimum query length for semantic signal to be active.
55    pub min_query_length: usize,
56    /// Maximum number of messages scored per turn (performance cap).
57    pub max_scored_messages: usize,
58    /// Number of the newest messages exempt from scoring when the window exceeds
59    /// `max_scored_messages`. These messages default to `Full` fidelity.
60    ///
61    /// A value of `0` (the default) means no tail exemption beyond the hard
62    /// `max_scored_messages` cap.
63    #[serde(default)]
64    pub exempt_tail_messages: usize,
65    /// LLM provider name (from `[[llm.providers]]`) used to summarize messages during
66    /// `Compressed` rendering. When `None`, truncation is used instead.
67    #[serde(default)]
68    pub compress_provider: Option<ProviderName>,
69    /// Embedding provider name (from `[[llm.providers]]`) used for semantic similarity scoring.
70    /// When `None`, keyword overlap is used instead.
71    #[serde(default)]
72    pub semantic_scoring_provider: Option<ProviderName>,
73    /// Maximum BFS depth for PAACE lookahead hints derived from the orchestration DAG.
74    ///
75    /// Controls how many steps ahead in the active task graph are converted to
76    /// `PlannedToolHint` values and passed to `FidelityScorer`.
77    /// `0` disables lookahead (returns an empty hint slice). Valid range: `0..=5`.
78    #[serde(default = "fidelity_lookahead_depth_default")]
79    pub lookahead_depth: u8,
80    /// Maximum number of concurrent `provider.embed()` calls during the cold-start pre-pass.
81    ///
82    /// Controls the `buffer_unordered(N)` bound. Higher values reduce latency on cold starts
83    /// at the cost of more concurrent API requests. Default is `32`.
84    #[serde(default = "default_embed_concurrency")]
85    pub embed_concurrency: usize,
86    /// Hard cap on message content length (in approximate tokens) fed to `provider.embed()`.
87    ///
88    /// When `Some(n)`, message content is truncated to approximately `n * 4` characters
89    /// (at a valid UTF-8 char boundary) before the embed call. `None` means no cap.
90    #[serde(default)]
91    pub max_embed_input_tokens: Option<usize>,
92    /// Hard cap on message content length (in approximate tokens) fed to the LLM compress call.
93    ///
94    /// When `Some(n)`, the input is truncated to approximately `n * 4` characters before
95    /// the compress call. `None` means no cap. Independent of the existing 2× cost guard.
96    #[serde(default)]
97    pub max_compress_input_tokens: Option<usize>,
98    /// Timeout in seconds for embed calls in fidelity scoring (default: 30).
99    ///
100    /// Applies to both the query embed and each per-message embed in the pre-pass.
101    /// Timed-out calls are skipped with a `warn`-level log; scoring falls back to keyword overlap.
102    #[serde(default = "default_thirty")]
103    pub embed_timeout_secs: u64,
104    /// Timeout in seconds for the LLM compress call in fidelity scoring (default: 30).
105    ///
106    /// When the LLM compress call exceeds this limit it is cancelled and truncation is used
107    /// as a fallback. Set higher if your compress provider has high cold-start latency.
108    #[serde(default = "default_thirty")]
109    pub compress_timeout_secs: u64,
110}
111
112fn default_embed_concurrency() -> usize {
113    32
114}
115
116fn default_thirty() -> u64 {
117    30
118}
119
120impl FidelityConfig {
121    /// Default value for [`lookahead_depth`](FidelityConfig::lookahead_depth): 3 BFS steps.
122    ///
123    /// Used as the `serde` default function and for callers that need the fallback value without
124    /// constructing a full [`FidelityConfig`].
125    #[must_use]
126    pub fn default_lookahead_depth() -> u8 {
127        3
128    }
129
130    /// Validate threshold ordering: `full_threshold >= compressed_threshold >= 0.0`.
131    ///
132    /// Call this at config load time to catch inverted thresholds before they silently
133    /// misclassify messages (score in `compressed_threshold..full_threshold` becomes Full
134    /// instead of Compressed when the invariant is violated).
135    ///
136    /// # Errors
137    ///
138    /// Returns an error string describing the violated constraint.
139    ///
140    /// # Examples
141    ///
142    /// ```
143    /// use zeph_config::fidelity::FidelityConfig;
144    ///
145    /// let valid = FidelityConfig::default();
146    /// assert!(valid.validate().is_ok());
147    ///
148    /// let invalid = FidelityConfig { full_threshold: 0.2, compressed_threshold: 0.5, ..FidelityConfig::default() };
149    /// assert!(invalid.validate().is_err());
150    /// ```
151    pub fn validate(&self) -> Result<(), String> {
152        if self.compressed_threshold < 0.0 {
153            return Err("memory.fidelity: compressed_threshold must be >= 0.0".into());
154        }
155        if self.full_threshold > 1.0 {
156            return Err("memory.fidelity: full_threshold must be <= 1.0".into());
157        }
158        if self.full_threshold < self.compressed_threshold {
159            return Err(format!(
160                "memory.fidelity: full_threshold ({}) must be >= compressed_threshold ({})",
161                self.full_threshold, self.compressed_threshold
162            ));
163        }
164        if self.lookahead_depth > 5 {
165            return Err(format!(
166                "memory.fidelity: lookahead_depth ({}) must be <= 5",
167                self.lookahead_depth
168            ));
169        }
170        if self.embed_timeout_secs == 0 {
171            return Err(
172                "memory.fidelity: embed_timeout_secs must be > 0 (zero causes immediate timeout)"
173                    .into(),
174            );
175        }
176        if self.compress_timeout_secs == 0 {
177            return Err(
178                "memory.fidelity: compress_timeout_secs must be > 0 (zero causes immediate timeout)"
179                    .into(),
180            );
181        }
182        Ok(())
183    }
184}
185
186impl Default for FidelityConfig {
187    fn default() -> Self {
188        Self {
189            enabled: false,
190            w_semantic: 0.3,
191            w_temporal: 0.3,
192            w_importance: 0.2,
193            w_plan: 0.2,
194            full_threshold: 0.7,
195            compressed_threshold: 0.3,
196            compressed_max_tokens: 50,
197            regrade_threshold: 0.6,
198            min_query_length: 8,
199            max_scored_messages: 500,
200            exempt_tail_messages: 0,
201            compress_provider: None,
202            semantic_scoring_provider: None,
203            lookahead_depth: Self::default_lookahead_depth(),
204            embed_concurrency: default_embed_concurrency(),
205            max_embed_input_tokens: None,
206            max_compress_input_tokens: None,
207            embed_timeout_secs: default_thirty(),
208            compress_timeout_secs: default_thirty(),
209        }
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn default_disabled() {
219        let cfg = FidelityConfig::default();
220        assert!(!cfg.enabled);
221    }
222
223    #[test]
224    fn deserialize_enabled() {
225        let toml_str = r"
226            enabled = true
227            w_semantic = 0.4
228            regrade_threshold = 0.7
229        ";
230        let cfg: FidelityConfig = toml::from_str(toml_str).unwrap();
231        assert!(cfg.enabled);
232        assert!((cfg.w_semantic - 0.4).abs() < f32::EPSILON);
233        assert!((cfg.regrade_threshold - 0.7).abs() < f32::EPSILON);
234    }
235
236    #[test]
237    fn deserialize_w_keyword_alias() {
238        let toml_str = r"
239            enabled = true
240            w_keyword = 0.25
241        ";
242        let cfg: FidelityConfig = toml::from_str(toml_str).unwrap();
243        assert!((cfg.w_semantic - 0.25).abs() < f32::EPSILON);
244    }
245
246    #[test]
247    fn deserialize_semantic_scoring_provider() {
248        let toml_str = r#"
249            enabled = true
250            semantic_scoring_provider = "embed-fast"
251        "#;
252        let cfg: FidelityConfig = toml::from_str(toml_str).unwrap();
253        assert_eq!(
254            cfg.semantic_scoring_provider
255                .as_ref()
256                .map(ProviderName::as_str),
257            Some("embed-fast")
258        );
259    }
260
261    #[test]
262    fn deserialize_defaults_for_omitted_fields() {
263        let cfg: FidelityConfig = toml::from_str("enabled = false").unwrap();
264        assert!((cfg.w_temporal - 0.3).abs() < f32::EPSILON);
265        assert_eq!(cfg.compressed_max_tokens, 50);
266        assert_eq!(cfg.max_scored_messages, 500);
267    }
268
269    #[test]
270    fn validate_defaults_ok() {
271        assert!(FidelityConfig::default().validate().is_ok());
272    }
273
274    #[test]
275    fn validate_inverted_thresholds_err() {
276        let cfg = FidelityConfig {
277            full_threshold: 0.2,
278            compressed_threshold: 0.5,
279            ..FidelityConfig::default()
280        };
281        let err = cfg.validate().unwrap_err();
282        assert!(
283            err.contains("full_threshold"),
284            "error should mention full_threshold: {err}"
285        );
286    }
287
288    #[test]
289    fn validate_negative_compressed_threshold_err() {
290        let cfg = FidelityConfig {
291            compressed_threshold: -0.1,
292            ..FidelityConfig::default()
293        };
294        assert!(cfg.validate().is_err());
295    }
296
297    #[test]
298    fn validate_full_threshold_above_one_err() {
299        let cfg = FidelityConfig {
300            full_threshold: 1.1,
301            ..FidelityConfig::default()
302        };
303        assert!(cfg.validate().is_err());
304    }
305
306    #[test]
307    fn default_lookahead_depth_is_three() {
308        assert_eq!(FidelityConfig::default().lookahead_depth, 3);
309    }
310
311    #[test]
312    fn lookahead_depth_zero_is_valid() {
313        let cfg = FidelityConfig {
314            lookahead_depth: 0,
315            ..FidelityConfig::default()
316        };
317        assert!(cfg.validate().is_ok());
318    }
319
320    #[test]
321    fn lookahead_depth_five_is_valid() {
322        let cfg = FidelityConfig {
323            lookahead_depth: 5,
324            ..FidelityConfig::default()
325        };
326        assert!(cfg.validate().is_ok());
327    }
328
329    #[test]
330    fn lookahead_depth_above_five_is_err() {
331        let cfg = FidelityConfig {
332            lookahead_depth: 6,
333            ..FidelityConfig::default()
334        };
335        let err = cfg.validate().unwrap_err();
336        assert!(
337            err.contains("lookahead_depth"),
338            "error should mention lookahead_depth: {err}"
339        );
340    }
341
342    #[test]
343    fn deserialize_lookahead_depth() {
344        let toml_str = "enabled = true\nlookahead_depth = 2";
345        let cfg: FidelityConfig = toml::from_str(toml_str).unwrap();
346        assert_eq!(cfg.lookahead_depth, 2);
347    }
348
349    #[test]
350    fn deserialize_defaults_lookahead_depth_when_omitted() {
351        let cfg: FidelityConfig = toml::from_str("enabled = false").unwrap();
352        assert_eq!(cfg.lookahead_depth, 3);
353    }
354
355    #[test]
356    fn deserialize_new_perf_fields_defaults() {
357        let cfg: FidelityConfig = toml::from_str("enabled = false").unwrap();
358        assert_eq!(cfg.embed_concurrency, 32);
359        assert!(cfg.max_embed_input_tokens.is_none());
360        assert!(cfg.max_compress_input_tokens.is_none());
361    }
362
363    #[test]
364    fn deserialize_new_perf_fields_custom() {
365        let toml_str = r"
366            enabled = true
367            embed_concurrency = 8
368            max_embed_input_tokens = 512
369            max_compress_input_tokens = 1024
370        ";
371        let cfg: FidelityConfig = toml::from_str(toml_str).unwrap();
372        assert_eq!(cfg.embed_concurrency, 8);
373        assert_eq!(cfg.max_embed_input_tokens, Some(512));
374        assert_eq!(cfg.max_compress_input_tokens, Some(1024));
375    }
376
377    #[test]
378    fn default_timeout_fields_are_thirty() {
379        let cfg = FidelityConfig::default();
380        assert_eq!(cfg.embed_timeout_secs, 30);
381        assert_eq!(cfg.compress_timeout_secs, 30);
382    }
383
384    #[test]
385    fn deserialize_timeout_fields_custom() {
386        let toml_str = r"
387            enabled = true
388            embed_timeout_secs = 60
389            compress_timeout_secs = 120
390        ";
391        let cfg: FidelityConfig = toml::from_str(toml_str).unwrap();
392        assert_eq!(cfg.embed_timeout_secs, 60);
393        assert_eq!(cfg.compress_timeout_secs, 120);
394    }
395
396    #[test]
397    fn deserialize_timeout_fields_default_when_omitted() {
398        let cfg: FidelityConfig = toml::from_str("enabled = false").unwrap();
399        assert_eq!(cfg.embed_timeout_secs, 30);
400        assert_eq!(cfg.compress_timeout_secs, 30);
401    }
402
403    #[test]
404    fn validate_embed_timeout_zero_is_err() {
405        let cfg = FidelityConfig {
406            embed_timeout_secs: 0,
407            ..FidelityConfig::default()
408        };
409        let err = cfg.validate().unwrap_err();
410        assert!(
411            err.contains("embed_timeout_secs"),
412            "error should mention embed_timeout_secs: {err}"
413        );
414    }
415
416    #[test]
417    fn validate_compress_timeout_zero_is_err() {
418        let cfg = FidelityConfig {
419            compress_timeout_secs: 0,
420            ..FidelityConfig::default()
421        };
422        let err = cfg.validate().unwrap_err();
423        assert!(
424            err.contains("compress_timeout_secs"),
425            "error should mention compress_timeout_secs: {err}"
426        );
427    }
428
429    #[test]
430    fn validate_timeout_one_is_ok() {
431        let cfg = FidelityConfig {
432            embed_timeout_secs: 1,
433            compress_timeout_secs: 1,
434            ..FidelityConfig::default()
435        };
436        assert!(cfg.validate().is_ok());
437    }
438}