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    #[must_use = "validation result must be checked"]
152    pub fn validate(&self) -> Result<(), String> {
153        if self.compressed_threshold < 0.0 {
154            return Err("memory.fidelity: compressed_threshold must be >= 0.0".into());
155        }
156        if self.full_threshold > 1.0 {
157            return Err("memory.fidelity: full_threshold must be <= 1.0".into());
158        }
159        if self.full_threshold < self.compressed_threshold {
160            return Err(format!(
161                "memory.fidelity: full_threshold ({}) must be >= compressed_threshold ({})",
162                self.full_threshold, self.compressed_threshold
163            ));
164        }
165        if self.lookahead_depth > 5 {
166            return Err(format!(
167                "memory.fidelity: lookahead_depth ({}) must be <= 5",
168                self.lookahead_depth
169            ));
170        }
171        if self.embed_timeout_secs == 0 {
172            return Err(
173                "memory.fidelity: embed_timeout_secs must be > 0 (zero causes immediate timeout)"
174                    .into(),
175            );
176        }
177        if self.compress_timeout_secs == 0 {
178            return Err(
179                "memory.fidelity: compress_timeout_secs must be > 0 (zero causes immediate timeout)"
180                    .into(),
181            );
182        }
183        Ok(())
184    }
185}
186
187impl Default for FidelityConfig {
188    fn default() -> Self {
189        Self {
190            enabled: false,
191            w_semantic: 0.3,
192            w_temporal: 0.3,
193            w_importance: 0.2,
194            w_plan: 0.2,
195            full_threshold: 0.7,
196            compressed_threshold: 0.3,
197            compressed_max_tokens: 50,
198            regrade_threshold: 0.6,
199            min_query_length: 8,
200            max_scored_messages: 500,
201            exempt_tail_messages: 0,
202            compress_provider: None,
203            semantic_scoring_provider: None,
204            lookahead_depth: Self::default_lookahead_depth(),
205            embed_concurrency: default_embed_concurrency(),
206            max_embed_input_tokens: None,
207            max_compress_input_tokens: None,
208            embed_timeout_secs: default_thirty(),
209            compress_timeout_secs: default_thirty(),
210        }
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn default_disabled() {
220        let cfg = FidelityConfig::default();
221        assert!(!cfg.enabled);
222    }
223
224    #[test]
225    fn deserialize_enabled() {
226        let toml_str = r"
227            enabled = true
228            w_semantic = 0.4
229            regrade_threshold = 0.7
230        ";
231        let cfg: FidelityConfig = toml::from_str(toml_str).unwrap();
232        assert!(cfg.enabled);
233        assert!((cfg.w_semantic - 0.4).abs() < f32::EPSILON);
234        assert!((cfg.regrade_threshold - 0.7).abs() < f32::EPSILON);
235    }
236
237    #[test]
238    fn deserialize_w_keyword_alias() {
239        let toml_str = r"
240            enabled = true
241            w_keyword = 0.25
242        ";
243        let cfg: FidelityConfig = toml::from_str(toml_str).unwrap();
244        assert!((cfg.w_semantic - 0.25).abs() < f32::EPSILON);
245    }
246
247    #[test]
248    fn deserialize_semantic_scoring_provider() {
249        let toml_str = r#"
250            enabled = true
251            semantic_scoring_provider = "embed-fast"
252        "#;
253        let cfg: FidelityConfig = toml::from_str(toml_str).unwrap();
254        assert_eq!(
255            cfg.semantic_scoring_provider
256                .as_ref()
257                .map(ProviderName::as_str),
258            Some("embed-fast")
259        );
260    }
261
262    #[test]
263    fn deserialize_defaults_for_omitted_fields() {
264        let cfg: FidelityConfig = toml::from_str("enabled = false").unwrap();
265        assert!((cfg.w_temporal - 0.3).abs() < f32::EPSILON);
266        assert_eq!(cfg.compressed_max_tokens, 50);
267        assert_eq!(cfg.max_scored_messages, 500);
268    }
269
270    #[test]
271    fn validate_defaults_ok() {
272        assert!(FidelityConfig::default().validate().is_ok());
273    }
274
275    #[test]
276    fn validate_inverted_thresholds_err() {
277        let cfg = FidelityConfig {
278            full_threshold: 0.2,
279            compressed_threshold: 0.5,
280            ..FidelityConfig::default()
281        };
282        let err = cfg.validate().unwrap_err();
283        assert!(
284            err.contains("full_threshold"),
285            "error should mention full_threshold: {err}"
286        );
287    }
288
289    #[test]
290    fn validate_negative_compressed_threshold_err() {
291        let cfg = FidelityConfig {
292            compressed_threshold: -0.1,
293            ..FidelityConfig::default()
294        };
295        assert!(cfg.validate().is_err());
296    }
297
298    #[test]
299    fn validate_full_threshold_above_one_err() {
300        let cfg = FidelityConfig {
301            full_threshold: 1.1,
302            ..FidelityConfig::default()
303        };
304        assert!(cfg.validate().is_err());
305    }
306
307    #[test]
308    fn default_lookahead_depth_is_three() {
309        assert_eq!(FidelityConfig::default().lookahead_depth, 3);
310    }
311
312    #[test]
313    fn lookahead_depth_zero_is_valid() {
314        let cfg = FidelityConfig {
315            lookahead_depth: 0,
316            ..FidelityConfig::default()
317        };
318        assert!(cfg.validate().is_ok());
319    }
320
321    #[test]
322    fn lookahead_depth_five_is_valid() {
323        let cfg = FidelityConfig {
324            lookahead_depth: 5,
325            ..FidelityConfig::default()
326        };
327        assert!(cfg.validate().is_ok());
328    }
329
330    #[test]
331    fn lookahead_depth_above_five_is_err() {
332        let cfg = FidelityConfig {
333            lookahead_depth: 6,
334            ..FidelityConfig::default()
335        };
336        let err = cfg.validate().unwrap_err();
337        assert!(
338            err.contains("lookahead_depth"),
339            "error should mention lookahead_depth: {err}"
340        );
341    }
342
343    #[test]
344    fn deserialize_lookahead_depth() {
345        let toml_str = "enabled = true\nlookahead_depth = 2";
346        let cfg: FidelityConfig = toml::from_str(toml_str).unwrap();
347        assert_eq!(cfg.lookahead_depth, 2);
348    }
349
350    #[test]
351    fn deserialize_defaults_lookahead_depth_when_omitted() {
352        let cfg: FidelityConfig = toml::from_str("enabled = false").unwrap();
353        assert_eq!(cfg.lookahead_depth, 3);
354    }
355
356    #[test]
357    fn deserialize_new_perf_fields_defaults() {
358        let cfg: FidelityConfig = toml::from_str("enabled = false").unwrap();
359        assert_eq!(cfg.embed_concurrency, 32);
360        assert!(cfg.max_embed_input_tokens.is_none());
361        assert!(cfg.max_compress_input_tokens.is_none());
362    }
363
364    #[test]
365    fn deserialize_new_perf_fields_custom() {
366        let toml_str = r"
367            enabled = true
368            embed_concurrency = 8
369            max_embed_input_tokens = 512
370            max_compress_input_tokens = 1024
371        ";
372        let cfg: FidelityConfig = toml::from_str(toml_str).unwrap();
373        assert_eq!(cfg.embed_concurrency, 8);
374        assert_eq!(cfg.max_embed_input_tokens, Some(512));
375        assert_eq!(cfg.max_compress_input_tokens, Some(1024));
376    }
377
378    #[test]
379    fn default_timeout_fields_are_thirty() {
380        let cfg = FidelityConfig::default();
381        assert_eq!(cfg.embed_timeout_secs, 30);
382        assert_eq!(cfg.compress_timeout_secs, 30);
383    }
384
385    #[test]
386    fn deserialize_timeout_fields_custom() {
387        let toml_str = r"
388            enabled = true
389            embed_timeout_secs = 60
390            compress_timeout_secs = 120
391        ";
392        let cfg: FidelityConfig = toml::from_str(toml_str).unwrap();
393        assert_eq!(cfg.embed_timeout_secs, 60);
394        assert_eq!(cfg.compress_timeout_secs, 120);
395    }
396
397    #[test]
398    fn deserialize_timeout_fields_default_when_omitted() {
399        let cfg: FidelityConfig = toml::from_str("enabled = false").unwrap();
400        assert_eq!(cfg.embed_timeout_secs, 30);
401        assert_eq!(cfg.compress_timeout_secs, 30);
402    }
403
404    #[test]
405    fn validate_embed_timeout_zero_is_err() {
406        let cfg = FidelityConfig {
407            embed_timeout_secs: 0,
408            ..FidelityConfig::default()
409        };
410        let err = cfg.validate().unwrap_err();
411        assert!(
412            err.contains("embed_timeout_secs"),
413            "error should mention embed_timeout_secs: {err}"
414        );
415    }
416
417    #[test]
418    fn validate_compress_timeout_zero_is_err() {
419        let cfg = FidelityConfig {
420            compress_timeout_secs: 0,
421            ..FidelityConfig::default()
422        };
423        let err = cfg.validate().unwrap_err();
424        assert!(
425            err.contains("compress_timeout_secs"),
426            "error should mention compress_timeout_secs: {err}"
427        );
428    }
429
430    #[test]
431    fn validate_timeout_one_is_ok() {
432        let cfg = FidelityConfig {
433            embed_timeout_secs: 1,
434            compress_timeout_secs: 1,
435            ..FidelityConfig::default()
436        };
437        assert!(cfg.validate().is_ok());
438    }
439}