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    /// `GoSkills` group-structured injection toggle (0.0 = disabled, 1.0 = enabled).
65    pub group_structured: f64,
66}
67
68impl Default for ConfigSnapshot {
69    fn default() -> Self {
70        Self {
71            temperature: 0.7,
72            top_p: 0.9,
73            top_k: 40.0,
74            frequency_penalty: 0.0,
75            presence_penalty: 0.0,
76            retrieval_top_k: 5.0,
77            similarity_threshold: 0.35,
78            temporal_decay: 30.0,
79            group_structured: 0.0,
80        }
81    }
82}
83
84impl ConfigSnapshot {
85    /// Create a snapshot from the current runtime config.
86    ///
87    /// LLM generation parameters come from `config.llm.candle.generation` when
88    /// a Candle provider is configured. All other providers do not expose
89    /// generation params in config — defaults are used for the experiment baseline.
90    /// Memory parameters are read from `config.memory.semantic`.
91    #[must_use]
92    pub fn from_config(config: &zeph_config::Config) -> Self {
93        let (temperature, top_p, top_k) = config.llm.candle.as_ref().map_or_else(
94            || {
95                tracing::debug!(
96                    provider = ?config.llm.effective_provider(),
97                    "LLM generation params not available for this provider; \
98                    using defaults for experiment baseline (temperature=0.7, top_p=0.9, top_k=40)"
99                );
100                (0.7, 0.9, 40.0)
101            },
102            |c| {
103                (
104                    c.generation.temperature,
105                    c.generation.top_p.unwrap_or(0.9),
106                    #[allow(clippy::cast_precision_loss)]
107                    c.generation.top_k.map_or(40.0, |k| k as f64),
108                )
109            },
110        );
111
112        Self {
113            temperature,
114            top_p,
115            top_k,
116            frequency_penalty: 0.0,
117            presence_penalty: 0.0,
118            #[allow(clippy::cast_precision_loss)]
119            retrieval_top_k: config.memory.semantic.recall_limit as f64,
120            similarity_threshold: f64::from(config.memory.cross_session_score_threshold),
121            temporal_decay: f64::from(config.memory.semantic.temporal_decay_half_life_days),
122            group_structured: if config.skills.group_structured {
123                1.0
124            } else {
125                0.0
126            },
127        }
128    }
129
130    /// Apply a single variation and return a new snapshot with that parameter changed.
131    #[must_use]
132    pub fn apply(&self, variation: &Variation) -> Self {
133        let mut snapshot = self.clone();
134        snapshot.set(variation.parameter, variation.value.as_f64());
135        snapshot
136    }
137
138    /// Return the single `Variation` that differs between `self` and `other`, or `None`
139    /// if zero or more than one parameter differs.
140    ///
141    /// Integer parameters (`TopK`, `RetrievalTopK`) produce a [`VariationValue::Int`] variant.
142    #[must_use]
143    pub fn diff(&self, other: &ConfigSnapshot) -> Option<Variation> {
144        let mut result = None;
145        for kind in ParameterKind::ALL {
146            let a = self.get(kind);
147            let b = other.get(kind);
148            if (a - b).abs() > f64::EPSILON {
149                if result.is_some() {
150                    return None; // more than one diff
151                }
152                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
153                let value = if kind.is_integer() {
154                    VariationValue::Int(b.round() as i64)
155                } else {
156                    VariationValue::Float(OrderedFloat(b))
157                };
158                result = Some(Variation {
159                    parameter: kind,
160                    value,
161                });
162            }
163        }
164        result
165    }
166
167    /// Get the current value of a parameter by kind.
168    ///
169    /// The match is exhaustive over every current [`ParameterKind`] variant — adding a
170    /// new variant to the enum without a matching arm here fails to compile.
171    ///
172    /// # Examples
173    ///
174    /// ```rust
175    /// use zeph_experiments::{ConfigSnapshot, ParameterKind};
176    ///
177    /// let s = ConfigSnapshot::default();
178    /// assert!((s.get(ParameterKind::Temperature) - 0.7).abs() < f64::EPSILON);
179    /// assert!((s.get(ParameterKind::TopK) - 40.0).abs() < f64::EPSILON);
180    /// ```
181    #[must_use]
182    pub fn get(&self, kind: ParameterKind) -> f64 {
183        match kind {
184            ParameterKind::Temperature => self.temperature,
185            ParameterKind::TopP => self.top_p,
186            ParameterKind::TopK => self.top_k,
187            ParameterKind::FrequencyPenalty => self.frequency_penalty,
188            ParameterKind::PresencePenalty => self.presence_penalty,
189            ParameterKind::RetrievalTopK => self.retrieval_top_k,
190            ParameterKind::SimilarityThreshold => self.similarity_threshold,
191            ParameterKind::TemporalDecay => self.temporal_decay,
192            ParameterKind::GroupStructured => self.group_structured,
193        }
194    }
195
196    /// Set the value of a parameter by kind.
197    ///
198    /// The match is exhaustive over every current [`ParameterKind`] variant — adding a
199    /// new variant to the enum without a matching arm here fails to compile.
200    ///
201    /// # Examples
202    ///
203    /// ```rust
204    /// use zeph_experiments::{ConfigSnapshot, ParameterKind};
205    ///
206    /// let mut s = ConfigSnapshot::default();
207    /// s.set(ParameterKind::Temperature, 1.2);
208    /// assert!((s.temperature - 1.2).abs() < f64::EPSILON);
209    /// ```
210    pub fn set(&mut self, kind: ParameterKind, value: f64) {
211        match kind {
212            ParameterKind::Temperature => self.temperature = value,
213            ParameterKind::TopP => self.top_p = value,
214            ParameterKind::TopK => self.top_k = value,
215            ParameterKind::FrequencyPenalty => self.frequency_penalty = value,
216            ParameterKind::PresencePenalty => self.presence_penalty = value,
217            ParameterKind::RetrievalTopK => self.retrieval_top_k = value,
218            ParameterKind::SimilarityThreshold => self.similarity_threshold = value,
219            ParameterKind::TemporalDecay => self.temporal_decay = value,
220            ParameterKind::GroupStructured => self.group_structured = value,
221        }
222    }
223
224    /// Extract LLM-relevant parameter overrides for use by the experiment engine.
225    ///
226    /// Uses `.round() as usize` for `top_k` to avoid truncation from floating-point noise.
227    #[must_use]
228    pub fn to_generation_overrides(&self) -> GenerationOverrides {
229        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
230        GenerationOverrides {
231            temperature: Some(self.temperature),
232            top_p: Some(self.top_p),
233            top_k: Some(self.top_k.round() as usize),
234            frequency_penalty: Some(self.frequency_penalty),
235            presence_penalty: Some(self.presence_penalty),
236        }
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    #![allow(
243        clippy::field_reassign_with_default,
244        clippy::semicolon_if_nothing_returned,
245        clippy::type_complexity
246    )]
247
248    use std::assert_matches;
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            group_structured: 0.0,
329        };
330        assert!((s.get(ParameterKind::Temperature) - 0.1).abs() < f64::EPSILON);
331        assert!((s.get(ParameterKind::TopP) - 0.2).abs() < f64::EPSILON);
332        assert!((s.get(ParameterKind::TopK) - 3.0).abs() < f64::EPSILON);
333        assert!((s.get(ParameterKind::FrequencyPenalty) - 0.4).abs() < f64::EPSILON);
334        assert!((s.get(ParameterKind::PresencePenalty) - 0.5).abs() < f64::EPSILON);
335        assert!((s.get(ParameterKind::RetrievalTopK) - 6.0).abs() < f64::EPSILON);
336        assert!((s.get(ParameterKind::SimilarityThreshold) - 0.7).abs() < f64::EPSILON);
337        assert!((s.get(ParameterKind::TemporalDecay) - 8.0).abs() < f64::EPSILON);
338        assert!((s.get(ParameterKind::GroupStructured) - 0.0).abs() < f64::EPSILON);
339    }
340
341    #[test]
342    fn set_all_kinds() {
343        let mut s = ConfigSnapshot::default();
344        s.set(ParameterKind::Temperature, 1.1);
345        s.set(ParameterKind::TopP, 0.8);
346        s.set(ParameterKind::TopK, 20.0);
347        s.set(ParameterKind::FrequencyPenalty, -0.5);
348        s.set(ParameterKind::PresencePenalty, 0.3);
349        s.set(ParameterKind::RetrievalTopK, 10.0);
350        s.set(ParameterKind::SimilarityThreshold, 0.5);
351        s.set(ParameterKind::TemporalDecay, 60.0);
352        s.set(ParameterKind::GroupStructured, 1.0);
353        assert!((s.temperature - 1.1).abs() < f64::EPSILON);
354        assert!((s.top_p - 0.8).abs() < f64::EPSILON);
355        assert!((s.top_k - 20.0).abs() < f64::EPSILON);
356        assert!((s.frequency_penalty + 0.5).abs() < f64::EPSILON);
357        assert!((s.presence_penalty - 0.3).abs() < f64::EPSILON);
358        assert!((s.retrieval_top_k - 10.0).abs() < f64::EPSILON);
359        assert!((s.similarity_threshold - 0.5).abs() < f64::EPSILON);
360        assert!((s.temporal_decay - 60.0).abs() < f64::EPSILON);
361        assert!((s.group_structured - 1.0).abs() < f64::EPSILON);
362    }
363
364    #[test]
365    fn to_generation_overrides_rounds_top_k() {
366        let mut s = ConfigSnapshot::default();
367        // top_k = 39.9 must round to 40, not truncate to 39
368        s.top_k = 39.9;
369        let overrides = s.to_generation_overrides();
370        assert_eq!(overrides.top_k, Some(40));
371    }
372
373    #[test]
374    fn to_generation_overrides_contains_all_llm_fields() {
375        let s = ConfigSnapshot::default();
376        let overrides = s.to_generation_overrides();
377        assert!(overrides.temperature.is_some());
378        assert!(overrides.top_p.is_some());
379        assert!(overrides.top_k.is_some());
380        assert!(overrides.frequency_penalty.is_some());
381        assert!(overrides.presence_penalty.is_some());
382    }
383
384    #[test]
385    fn diff_integer_param_produces_int_value() {
386        let a = ConfigSnapshot::default();
387        let mut b = ConfigSnapshot::default();
388        b.top_k = 50.0;
389        let variation = a.diff(&b).expect("should have one diff");
390        assert_eq!(variation.parameter, ParameterKind::TopK);
391        assert!(
392            matches!(variation.value, VariationValue::Int(50)),
393            "expected Int(50), got {:?}",
394            variation.value
395        );
396    }
397
398    #[test]
399    fn diff_retrieval_top_k_produces_int_value() {
400        let a = ConfigSnapshot::default();
401        let mut b = ConfigSnapshot::default();
402        b.retrieval_top_k = 10.0;
403        let variation = a.diff(&b).expect("should have one diff");
404        assert_eq!(variation.parameter, ParameterKind::RetrievalTopK);
405        assert_matches!(variation.value, VariationValue::Int(10));
406    }
407
408    #[test]
409    fn diff_all_kinds() {
410        let fields: &[(ParameterKind, fn(&mut ConfigSnapshot))] = &[
411            (ParameterKind::Temperature, |s| s.temperature = 1.5),
412            (ParameterKind::TopP, |s| s.top_p = 0.5),
413            (ParameterKind::TopK, |s| s.top_k = 20.0),
414            (ParameterKind::FrequencyPenalty, |s| {
415                s.frequency_penalty = 0.5;
416            }),
417            (ParameterKind::PresencePenalty, |s| s.presence_penalty = 0.5),
418            (ParameterKind::RetrievalTopK, |s| s.retrieval_top_k = 10.0),
419            (ParameterKind::SimilarityThreshold, |s| {
420                s.similarity_threshold = 0.8;
421            }),
422            (ParameterKind::TemporalDecay, |s| s.temporal_decay = 60.0),
423            (ParameterKind::GroupStructured, |s| s.group_structured = 1.0),
424        ];
425        // If a variant is added to `ParameterKind::ALL` without adding a fixture entry
426        // here, this catches the gap instead of silently under-covering the new kind.
427        assert_eq!(
428            fields.len(),
429            ParameterKind::ALL.len(),
430            "fixture must cover every ParameterKind variant"
431        );
432        for (kind, mutate) in fields {
433            let a = ConfigSnapshot::default();
434            let mut b = ConfigSnapshot::default();
435            mutate(&mut b);
436            let v = a
437                .diff(&b)
438                .unwrap_or_else(|| panic!("expected diff for {kind:?}"));
439            assert_eq!(v.parameter, *kind);
440        }
441    }
442
443    #[test]
444    fn get_set_round_trip_all_kinds() {
445        // Derived from `ParameterKind::ALL` (not hand-enumerated) so a newly added
446        // variant is automatically exercised here.
447        let mut s = ConfigSnapshot::default();
448        for (i, kind) in ParameterKind::ALL.into_iter().enumerate() {
449            #[allow(clippy::cast_precision_loss)]
450            let value = i as f64 + 1.0;
451            s.set(kind, value);
452            assert!(
453                (s.get(kind) - value).abs() < f64::EPSILON,
454                "get/set round-trip failed for {kind:?}"
455            );
456        }
457    }
458
459    #[test]
460    fn snapshot_serde_roundtrip() {
461        let s = ConfigSnapshot {
462            temperature: 1.2,
463            top_p: 0.85,
464            top_k: 50.0,
465            frequency_penalty: -0.1,
466            presence_penalty: 0.2,
467            retrieval_top_k: 7.0,
468            similarity_threshold: 0.4,
469            temporal_decay: 45.0,
470            group_structured: 0.0,
471        };
472        let json = serde_json::to_string(&s).unwrap();
473        let s2: ConfigSnapshot = serde_json::from_str(&json).unwrap();
474        assert!((s2.temperature - s.temperature).abs() < f64::EPSILON);
475        assert!((s2.top_k - s.top_k).abs() < f64::EPSILON);
476    }
477}