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