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