Skip to main content

zeph_experiments/
snapshot.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Config snapshot for a single experiment arm.
5
6use ordered_float::OrderedFloat;
7use serde::{Deserialize, Serialize};
8pub use zeph_llm::provider::GenerationOverrides;
9
10use super::types::{ParameterKind, Variation, VariationValue};
11
12/// Snapshot of all tunable parameters for a single experiment arm.
13///
14/// `ConfigSnapshot` is the bridge between Zeph's runtime `Config` and the
15/// variation engine. Each experiment arm is defined by a snapshot derived from
16/// the baseline config with exactly one parameter changed.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ConfigSnapshot {
19    pub temperature: f64,
20    pub top_p: f64,
21    pub top_k: f64,
22    pub frequency_penalty: f64,
23    pub presence_penalty: f64,
24    pub retrieval_top_k: f64,
25    pub similarity_threshold: f64,
26    pub temporal_decay: f64,
27}
28
29impl Default for ConfigSnapshot {
30    fn default() -> Self {
31        Self {
32            temperature: 0.7,
33            top_p: 0.9,
34            top_k: 40.0,
35            frequency_penalty: 0.0,
36            presence_penalty: 0.0,
37            retrieval_top_k: 5.0,
38            similarity_threshold: 0.35,
39            temporal_decay: 30.0,
40        }
41    }
42}
43
44impl ConfigSnapshot {
45    /// Create a snapshot from the current runtime config.
46    ///
47    /// LLM generation parameters come from `config.llm.candle.generation` when
48    /// a Candle provider is configured. All other providers do not expose
49    /// generation params in config — defaults are used for the experiment baseline.
50    /// Memory parameters are read from `config.memory.semantic`.
51    #[must_use]
52    pub fn from_config(config: &zeph_config::Config) -> Self {
53        let (temperature, top_p, top_k) = config.llm.candle.as_ref().map_or_else(
54            || {
55                tracing::debug!(
56                    provider = ?config.llm.effective_provider(),
57                    "LLM generation params not available for this provider; \
58                    using defaults for experiment baseline (temperature=0.7, top_p=0.9, top_k=40)"
59                );
60                (0.7, 0.9, 40.0)
61            },
62            |c| {
63                (
64                    c.generation.temperature,
65                    c.generation.top_p.unwrap_or(0.9),
66                    #[allow(clippy::cast_precision_loss)]
67                    c.generation.top_k.map_or(40.0, |k| k as f64),
68                )
69            },
70        );
71
72        Self {
73            temperature,
74            top_p,
75            top_k,
76            frequency_penalty: 0.0,
77            presence_penalty: 0.0,
78            #[allow(clippy::cast_precision_loss)]
79            retrieval_top_k: config.memory.semantic.recall_limit as f64,
80            similarity_threshold: f64::from(config.memory.cross_session_score_threshold),
81            temporal_decay: f64::from(config.memory.semantic.temporal_decay_half_life_days),
82        }
83    }
84
85    /// Apply a single variation and return a new snapshot with that parameter changed.
86    #[must_use]
87    pub fn apply(&self, variation: &Variation) -> Self {
88        let mut snapshot = self.clone();
89        snapshot.set(variation.parameter, variation.value.as_f64());
90        snapshot
91    }
92
93    /// Return the single `Variation` that differs between `self` and `other`, or `None`
94    /// if zero or more than one parameter differs.
95    ///
96    /// Integer parameters (`TopK`, `RetrievalTopK`) produce a [`VariationValue::Int`] variant.
97    #[must_use]
98    pub fn diff(&self, other: &ConfigSnapshot) -> Option<Variation> {
99        let kinds = [
100            ParameterKind::Temperature,
101            ParameterKind::TopP,
102            ParameterKind::TopK,
103            ParameterKind::FrequencyPenalty,
104            ParameterKind::PresencePenalty,
105            ParameterKind::RetrievalTopK,
106            ParameterKind::SimilarityThreshold,
107            ParameterKind::TemporalDecay,
108        ];
109        let mut result = None;
110        for kind in kinds {
111            let a = self.get(kind);
112            let b = other.get(kind);
113            if (a - b).abs() > f64::EPSILON {
114                if result.is_some() {
115                    return None; // more than one diff
116                }
117                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
118                let value = if kind.is_integer() {
119                    VariationValue::Int(b.round() as i64)
120                } else {
121                    VariationValue::Float(OrderedFloat(b))
122                };
123                result = Some(Variation {
124                    parameter: kind,
125                    value,
126                });
127            }
128        }
129        result
130    }
131
132    /// Get the value of a parameter by kind.
133    #[must_use]
134    pub fn get(&self, kind: ParameterKind) -> f64 {
135        #[allow(unreachable_patterns)]
136        match kind {
137            ParameterKind::Temperature => self.temperature,
138            ParameterKind::TopP => self.top_p,
139            ParameterKind::TopK => self.top_k,
140            ParameterKind::FrequencyPenalty => self.frequency_penalty,
141            ParameterKind::PresencePenalty => self.presence_penalty,
142            ParameterKind::RetrievalTopK => self.retrieval_top_k,
143            ParameterKind::SimilarityThreshold => self.similarity_threshold,
144            ParameterKind::TemporalDecay => self.temporal_decay,
145            _ => 0.0,
146        }
147    }
148
149    /// Set the value of a parameter by kind.
150    pub fn set(&mut self, kind: ParameterKind, value: f64) {
151        #[allow(unreachable_patterns)]
152        match kind {
153            ParameterKind::Temperature => self.temperature = value,
154            ParameterKind::TopP => self.top_p = value,
155            ParameterKind::TopK => self.top_k = value,
156            ParameterKind::FrequencyPenalty => self.frequency_penalty = value,
157            ParameterKind::PresencePenalty => self.presence_penalty = value,
158            ParameterKind::RetrievalTopK => self.retrieval_top_k = value,
159            ParameterKind::SimilarityThreshold => self.similarity_threshold = value,
160            ParameterKind::TemporalDecay => self.temporal_decay = value,
161            _ => {}
162        }
163    }
164
165    /// Extract LLM-relevant parameter overrides for use by the experiment engine.
166    ///
167    /// Uses `.round() as usize` for `top_k` to avoid truncation from floating-point noise.
168    #[must_use]
169    pub fn to_generation_overrides(&self) -> GenerationOverrides {
170        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
171        GenerationOverrides {
172            temperature: Some(self.temperature),
173            top_p: Some(self.top_p),
174            top_k: Some(self.top_k.round() as usize),
175            frequency_penalty: Some(self.frequency_penalty),
176            presence_penalty: Some(self.presence_penalty),
177        }
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    #![allow(
184        clippy::field_reassign_with_default,
185        clippy::semicolon_if_nothing_returned,
186        clippy::type_complexity
187    )]
188
189    use super::*;
190    use ordered_float::OrderedFloat;
191
192    #[test]
193    fn default_snapshot_fields() {
194        let s = ConfigSnapshot::default();
195        assert!((s.temperature - 0.7).abs() < f64::EPSILON);
196        assert!((s.top_p - 0.9).abs() < f64::EPSILON);
197        assert!((s.top_k - 40.0).abs() < f64::EPSILON);
198        assert!((s.frequency_penalty - 0.0).abs() < f64::EPSILON);
199        assert!((s.presence_penalty - 0.0).abs() < f64::EPSILON);
200        assert!((s.retrieval_top_k - 5.0).abs() < f64::EPSILON);
201        assert!((s.similarity_threshold - 0.35).abs() < 1e-6);
202        assert!((s.temporal_decay - 30.0).abs() < f64::EPSILON);
203    }
204
205    #[test]
206    fn apply_changes_single_param() {
207        let baseline = ConfigSnapshot::default();
208        let variation = Variation {
209            parameter: ParameterKind::Temperature,
210            value: VariationValue::Float(OrderedFloat(1.0)),
211        };
212        let applied = baseline.apply(&variation);
213        assert!((applied.temperature - 1.0).abs() < f64::EPSILON);
214        assert!((applied.top_p - 0.9).abs() < f64::EPSILON); // unchanged
215    }
216
217    #[test]
218    fn apply_with_int_value() {
219        let baseline = ConfigSnapshot::default();
220        let variation = Variation {
221            parameter: ParameterKind::TopK,
222            value: VariationValue::Int(50),
223        };
224        let applied = baseline.apply(&variation);
225        assert!((applied.top_k - 50.0).abs() < f64::EPSILON);
226    }
227
228    #[test]
229    fn diff_returns_single_changed_param() {
230        let a = ConfigSnapshot::default();
231        let mut b = ConfigSnapshot::default();
232        b.temperature = 1.0;
233        let variation = a.diff(&b);
234        assert!(variation.is_some());
235        let v = variation.unwrap();
236        assert_eq!(v.parameter, ParameterKind::Temperature);
237        assert!((v.value.as_f64() - 1.0).abs() < f64::EPSILON);
238    }
239
240    #[test]
241    fn diff_returns_none_for_identical_snapshots() {
242        let a = ConfigSnapshot::default();
243        let b = ConfigSnapshot::default();
244        assert!(a.diff(&b).is_none());
245    }
246
247    #[test]
248    fn diff_returns_none_for_multiple_changes() {
249        let a = ConfigSnapshot::default();
250        let mut b = ConfigSnapshot::default();
251        b.temperature = 1.0;
252        b.top_p = 0.5;
253        assert!(a.diff(&b).is_none());
254    }
255
256    #[test]
257    fn get_all_kinds() {
258        let s = ConfigSnapshot {
259            temperature: 0.1,
260            top_p: 0.2,
261            top_k: 3.0,
262            frequency_penalty: 0.4,
263            presence_penalty: 0.5,
264            retrieval_top_k: 6.0,
265            similarity_threshold: 0.7,
266            temporal_decay: 8.0,
267        };
268        assert!((s.get(ParameterKind::Temperature) - 0.1).abs() < f64::EPSILON);
269        assert!((s.get(ParameterKind::TopP) - 0.2).abs() < f64::EPSILON);
270        assert!((s.get(ParameterKind::TopK) - 3.0).abs() < f64::EPSILON);
271        assert!((s.get(ParameterKind::FrequencyPenalty) - 0.4).abs() < f64::EPSILON);
272        assert!((s.get(ParameterKind::PresencePenalty) - 0.5).abs() < f64::EPSILON);
273        assert!((s.get(ParameterKind::RetrievalTopK) - 6.0).abs() < f64::EPSILON);
274        assert!((s.get(ParameterKind::SimilarityThreshold) - 0.7).abs() < f64::EPSILON);
275        assert!((s.get(ParameterKind::TemporalDecay) - 8.0).abs() < f64::EPSILON);
276    }
277
278    #[test]
279    fn set_all_kinds() {
280        let mut s = ConfigSnapshot::default();
281        s.set(ParameterKind::Temperature, 1.1);
282        s.set(ParameterKind::TopP, 0.8);
283        s.set(ParameterKind::TopK, 20.0);
284        s.set(ParameterKind::FrequencyPenalty, -0.5);
285        s.set(ParameterKind::PresencePenalty, 0.3);
286        s.set(ParameterKind::RetrievalTopK, 10.0);
287        s.set(ParameterKind::SimilarityThreshold, 0.5);
288        s.set(ParameterKind::TemporalDecay, 60.0);
289        assert!((s.temperature - 1.1).abs() < f64::EPSILON);
290        assert!((s.top_p - 0.8).abs() < f64::EPSILON);
291        assert!((s.top_k - 20.0).abs() < f64::EPSILON);
292        assert!((s.frequency_penalty + 0.5).abs() < f64::EPSILON);
293        assert!((s.presence_penalty - 0.3).abs() < f64::EPSILON);
294        assert!((s.retrieval_top_k - 10.0).abs() < f64::EPSILON);
295        assert!((s.similarity_threshold - 0.5).abs() < f64::EPSILON);
296        assert!((s.temporal_decay - 60.0).abs() < f64::EPSILON);
297    }
298
299    #[test]
300    fn to_generation_overrides_rounds_top_k() {
301        let mut s = ConfigSnapshot::default();
302        // top_k = 39.9 must round to 40, not truncate to 39
303        s.top_k = 39.9;
304        let overrides = s.to_generation_overrides();
305        assert_eq!(overrides.top_k, Some(40));
306    }
307
308    #[test]
309    fn to_generation_overrides_contains_all_llm_fields() {
310        let s = ConfigSnapshot::default();
311        let overrides = s.to_generation_overrides();
312        assert!(overrides.temperature.is_some());
313        assert!(overrides.top_p.is_some());
314        assert!(overrides.top_k.is_some());
315        assert!(overrides.frequency_penalty.is_some());
316        assert!(overrides.presence_penalty.is_some());
317    }
318
319    #[test]
320    fn diff_integer_param_produces_int_value() {
321        let a = ConfigSnapshot::default();
322        let mut b = ConfigSnapshot::default();
323        b.top_k = 50.0;
324        let variation = a.diff(&b).expect("should have one diff");
325        assert_eq!(variation.parameter, ParameterKind::TopK);
326        assert!(
327            matches!(variation.value, VariationValue::Int(50)),
328            "expected Int(50), got {:?}",
329            variation.value
330        );
331    }
332
333    #[test]
334    fn diff_retrieval_top_k_produces_int_value() {
335        let a = ConfigSnapshot::default();
336        let mut b = ConfigSnapshot::default();
337        b.retrieval_top_k = 10.0;
338        let variation = a.diff(&b).expect("should have one diff");
339        assert_eq!(variation.parameter, ParameterKind::RetrievalTopK);
340        assert!(matches!(variation.value, VariationValue::Int(10)));
341    }
342
343    #[test]
344    fn diff_all_eight_kinds() {
345        let fields: &[(ParameterKind, fn(&mut ConfigSnapshot))] = &[
346            (ParameterKind::Temperature, |s| s.temperature = 1.5),
347            (ParameterKind::TopP, |s| s.top_p = 0.5),
348            (ParameterKind::TopK, |s| s.top_k = 20.0),
349            (ParameterKind::FrequencyPenalty, |s| {
350                s.frequency_penalty = 0.5;
351            }),
352            (ParameterKind::PresencePenalty, |s| s.presence_penalty = 0.5),
353            (ParameterKind::RetrievalTopK, |s| s.retrieval_top_k = 10.0),
354            (ParameterKind::SimilarityThreshold, |s| {
355                s.similarity_threshold = 0.8;
356            }),
357            (ParameterKind::TemporalDecay, |s| s.temporal_decay = 60.0),
358        ];
359        for (kind, mutate) in fields {
360            let a = ConfigSnapshot::default();
361            let mut b = ConfigSnapshot::default();
362            mutate(&mut b);
363            let v = a
364                .diff(&b)
365                .unwrap_or_else(|| panic!("expected diff for {kind:?}"));
366            assert_eq!(v.parameter, *kind);
367        }
368    }
369
370    #[test]
371    fn snapshot_serde_roundtrip() {
372        let s = ConfigSnapshot {
373            temperature: 1.2,
374            top_p: 0.85,
375            top_k: 50.0,
376            frequency_penalty: -0.1,
377            presence_penalty: 0.2,
378            retrieval_top_k: 7.0,
379            similarity_threshold: 0.4,
380            temporal_decay: 45.0,
381        };
382        let json = serde_json::to_string(&s).unwrap();
383        let s2: ConfigSnapshot = serde_json::from_str(&json).unwrap();
384        assert!((s2.temperature - s.temperature).abs() < f64::EPSILON);
385        assert!((s2.top_k - s.top_k).abs() < f64::EPSILON);
386    }
387}