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 kinds = [
145            ParameterKind::Temperature,
146            ParameterKind::TopP,
147            ParameterKind::TopK,
148            ParameterKind::FrequencyPenalty,
149            ParameterKind::PresencePenalty,
150            ParameterKind::RetrievalTopK,
151            ParameterKind::SimilarityThreshold,
152            ParameterKind::TemporalDecay,
153            ParameterKind::GroupStructured,
154        ];
155        let mut result = None;
156        for kind in kinds {
157            let a = self.get(kind);
158            let b = other.get(kind);
159            if (a - b).abs() > f64::EPSILON {
160                if result.is_some() {
161                    return None; // more than one diff
162                }
163                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
164                let value = if kind.is_integer() {
165                    VariationValue::Int(b.round() as i64)
166                } else {
167                    VariationValue::Float(OrderedFloat(b))
168                };
169                result = Some(Variation {
170                    parameter: kind,
171                    value,
172                });
173            }
174        }
175        result
176    }
177
178    /// Get the current value of a parameter by kind.
179    ///
180    /// Unknown kinds (from `#[non_exhaustive]` additions) return `0.0`.
181    ///
182    /// # Examples
183    ///
184    /// ```rust
185    /// use zeph_experiments::{ConfigSnapshot, ParameterKind};
186    ///
187    /// let s = ConfigSnapshot::default();
188    /// assert!((s.get(ParameterKind::Temperature) - 0.7).abs() < f64::EPSILON);
189    /// assert!((s.get(ParameterKind::TopK) - 40.0).abs() < f64::EPSILON);
190    /// ```
191    #[must_use]
192    pub fn get(&self, kind: ParameterKind) -> f64 {
193        #[allow(unreachable_patterns)]
194        match kind {
195            ParameterKind::Temperature => self.temperature,
196            ParameterKind::TopP => self.top_p,
197            ParameterKind::TopK => self.top_k,
198            ParameterKind::FrequencyPenalty => self.frequency_penalty,
199            ParameterKind::PresencePenalty => self.presence_penalty,
200            ParameterKind::RetrievalTopK => self.retrieval_top_k,
201            ParameterKind::SimilarityThreshold => self.similarity_threshold,
202            ParameterKind::TemporalDecay => self.temporal_decay,
203            ParameterKind::GroupStructured => self.group_structured,
204            _ => 0.0,
205        }
206    }
207
208    /// Set the value of a parameter by kind.
209    ///
210    /// Unknown kinds (from `#[non_exhaustive]` additions) are silently ignored.
211    ///
212    /// # Examples
213    ///
214    /// ```rust
215    /// use zeph_experiments::{ConfigSnapshot, ParameterKind};
216    ///
217    /// let mut s = ConfigSnapshot::default();
218    /// s.set(ParameterKind::Temperature, 1.2);
219    /// assert!((s.temperature - 1.2).abs() < f64::EPSILON);
220    /// ```
221    pub fn set(&mut self, kind: ParameterKind, value: f64) {
222        #[allow(unreachable_patterns)]
223        match kind {
224            ParameterKind::Temperature => self.temperature = value,
225            ParameterKind::TopP => self.top_p = value,
226            ParameterKind::TopK => self.top_k = value,
227            ParameterKind::FrequencyPenalty => self.frequency_penalty = value,
228            ParameterKind::PresencePenalty => self.presence_penalty = value,
229            ParameterKind::RetrievalTopK => self.retrieval_top_k = value,
230            ParameterKind::SimilarityThreshold => self.similarity_threshold = value,
231            ParameterKind::TemporalDecay => self.temporal_decay = value,
232            ParameterKind::GroupStructured => self.group_structured = value,
233            _ => {}
234        }
235    }
236
237    /// Extract LLM-relevant parameter overrides for use by the experiment engine.
238    ///
239    /// Uses `.round() as usize` for `top_k` to avoid truncation from floating-point noise.
240    #[must_use]
241    pub fn to_generation_overrides(&self) -> GenerationOverrides {
242        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
243        GenerationOverrides {
244            temperature: Some(self.temperature),
245            top_p: Some(self.top_p),
246            top_k: Some(self.top_k.round() as usize),
247            frequency_penalty: Some(self.frequency_penalty),
248            presence_penalty: Some(self.presence_penalty),
249        }
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    #![allow(
256        clippy::field_reassign_with_default,
257        clippy::semicolon_if_nothing_returned,
258        clippy::type_complexity
259    )]
260
261    use std::assert_matches;
262
263    use super::*;
264    use ordered_float::OrderedFloat;
265
266    #[test]
267    fn default_snapshot_fields() {
268        let s = ConfigSnapshot::default();
269        assert!((s.temperature - 0.7).abs() < f64::EPSILON);
270        assert!((s.top_p - 0.9).abs() < f64::EPSILON);
271        assert!((s.top_k - 40.0).abs() < f64::EPSILON);
272        assert!((s.frequency_penalty - 0.0).abs() < f64::EPSILON);
273        assert!((s.presence_penalty - 0.0).abs() < f64::EPSILON);
274        assert!((s.retrieval_top_k - 5.0).abs() < f64::EPSILON);
275        assert!((s.similarity_threshold - 0.35).abs() < 1e-6);
276        assert!((s.temporal_decay - 30.0).abs() < f64::EPSILON);
277    }
278
279    #[test]
280    fn apply_changes_single_param() {
281        let baseline = ConfigSnapshot::default();
282        let variation = Variation {
283            parameter: ParameterKind::Temperature,
284            value: VariationValue::Float(OrderedFloat(1.0)),
285        };
286        let applied = baseline.apply(&variation);
287        assert!((applied.temperature - 1.0).abs() < f64::EPSILON);
288        assert!((applied.top_p - 0.9).abs() < f64::EPSILON); // unchanged
289    }
290
291    #[test]
292    fn apply_with_int_value() {
293        let baseline = ConfigSnapshot::default();
294        let variation = Variation {
295            parameter: ParameterKind::TopK,
296            value: VariationValue::Int(50),
297        };
298        let applied = baseline.apply(&variation);
299        assert!((applied.top_k - 50.0).abs() < f64::EPSILON);
300    }
301
302    #[test]
303    fn diff_returns_single_changed_param() {
304        let a = ConfigSnapshot::default();
305        let mut b = ConfigSnapshot::default();
306        b.temperature = 1.0;
307        let variation = a.diff(&b);
308        assert!(variation.is_some());
309        let v = variation.unwrap();
310        assert_eq!(v.parameter, ParameterKind::Temperature);
311        assert!((v.value.as_f64() - 1.0).abs() < f64::EPSILON);
312    }
313
314    #[test]
315    fn diff_returns_none_for_identical_snapshots() {
316        let a = ConfigSnapshot::default();
317        let b = ConfigSnapshot::default();
318        assert!(a.diff(&b).is_none());
319    }
320
321    #[test]
322    fn diff_returns_none_for_multiple_changes() {
323        let a = ConfigSnapshot::default();
324        let mut b = ConfigSnapshot::default();
325        b.temperature = 1.0;
326        b.top_p = 0.5;
327        assert!(a.diff(&b).is_none());
328    }
329
330    #[test]
331    fn get_all_kinds() {
332        let s = ConfigSnapshot {
333            temperature: 0.1,
334            top_p: 0.2,
335            top_k: 3.0,
336            frequency_penalty: 0.4,
337            presence_penalty: 0.5,
338            retrieval_top_k: 6.0,
339            similarity_threshold: 0.7,
340            temporal_decay: 8.0,
341            group_structured: 0.0,
342        };
343        assert!((s.get(ParameterKind::Temperature) - 0.1).abs() < f64::EPSILON);
344        assert!((s.get(ParameterKind::TopP) - 0.2).abs() < f64::EPSILON);
345        assert!((s.get(ParameterKind::TopK) - 3.0).abs() < f64::EPSILON);
346        assert!((s.get(ParameterKind::FrequencyPenalty) - 0.4).abs() < f64::EPSILON);
347        assert!((s.get(ParameterKind::PresencePenalty) - 0.5).abs() < f64::EPSILON);
348        assert!((s.get(ParameterKind::RetrievalTopK) - 6.0).abs() < f64::EPSILON);
349        assert!((s.get(ParameterKind::SimilarityThreshold) - 0.7).abs() < f64::EPSILON);
350        assert!((s.get(ParameterKind::TemporalDecay) - 8.0).abs() < f64::EPSILON);
351        assert!((s.get(ParameterKind::GroupStructured) - 0.0).abs() < f64::EPSILON);
352    }
353
354    #[test]
355    fn set_all_kinds() {
356        let mut s = ConfigSnapshot::default();
357        s.set(ParameterKind::Temperature, 1.1);
358        s.set(ParameterKind::TopP, 0.8);
359        s.set(ParameterKind::TopK, 20.0);
360        s.set(ParameterKind::FrequencyPenalty, -0.5);
361        s.set(ParameterKind::PresencePenalty, 0.3);
362        s.set(ParameterKind::RetrievalTopK, 10.0);
363        s.set(ParameterKind::SimilarityThreshold, 0.5);
364        s.set(ParameterKind::TemporalDecay, 60.0);
365        s.set(ParameterKind::GroupStructured, 1.0);
366        assert!((s.temperature - 1.1).abs() < f64::EPSILON);
367        assert!((s.top_p - 0.8).abs() < f64::EPSILON);
368        assert!((s.top_k - 20.0).abs() < f64::EPSILON);
369        assert!((s.frequency_penalty + 0.5).abs() < f64::EPSILON);
370        assert!((s.presence_penalty - 0.3).abs() < f64::EPSILON);
371        assert!((s.retrieval_top_k - 10.0).abs() < f64::EPSILON);
372        assert!((s.similarity_threshold - 0.5).abs() < f64::EPSILON);
373        assert!((s.temporal_decay - 60.0).abs() < f64::EPSILON);
374        assert!((s.group_structured - 1.0).abs() < f64::EPSILON);
375    }
376
377    #[test]
378    fn to_generation_overrides_rounds_top_k() {
379        let mut s = ConfigSnapshot::default();
380        // top_k = 39.9 must round to 40, not truncate to 39
381        s.top_k = 39.9;
382        let overrides = s.to_generation_overrides();
383        assert_eq!(overrides.top_k, Some(40));
384    }
385
386    #[test]
387    fn to_generation_overrides_contains_all_llm_fields() {
388        let s = ConfigSnapshot::default();
389        let overrides = s.to_generation_overrides();
390        assert!(overrides.temperature.is_some());
391        assert!(overrides.top_p.is_some());
392        assert!(overrides.top_k.is_some());
393        assert!(overrides.frequency_penalty.is_some());
394        assert!(overrides.presence_penalty.is_some());
395    }
396
397    #[test]
398    fn diff_integer_param_produces_int_value() {
399        let a = ConfigSnapshot::default();
400        let mut b = ConfigSnapshot::default();
401        b.top_k = 50.0;
402        let variation = a.diff(&b).expect("should have one diff");
403        assert_eq!(variation.parameter, ParameterKind::TopK);
404        assert!(
405            matches!(variation.value, VariationValue::Int(50)),
406            "expected Int(50), got {:?}",
407            variation.value
408        );
409    }
410
411    #[test]
412    fn diff_retrieval_top_k_produces_int_value() {
413        let a = ConfigSnapshot::default();
414        let mut b = ConfigSnapshot::default();
415        b.retrieval_top_k = 10.0;
416        let variation = a.diff(&b).expect("should have one diff");
417        assert_eq!(variation.parameter, ParameterKind::RetrievalTopK);
418        assert_matches!(variation.value, VariationValue::Int(10));
419    }
420
421    #[test]
422    fn diff_all_nine_kinds() {
423        let fields: &[(ParameterKind, fn(&mut ConfigSnapshot))] = &[
424            (ParameterKind::Temperature, |s| s.temperature = 1.5),
425            (ParameterKind::TopP, |s| s.top_p = 0.5),
426            (ParameterKind::TopK, |s| s.top_k = 20.0),
427            (ParameterKind::FrequencyPenalty, |s| {
428                s.frequency_penalty = 0.5;
429            }),
430            (ParameterKind::PresencePenalty, |s| s.presence_penalty = 0.5),
431            (ParameterKind::RetrievalTopK, |s| s.retrieval_top_k = 10.0),
432            (ParameterKind::SimilarityThreshold, |s| {
433                s.similarity_threshold = 0.8;
434            }),
435            (ParameterKind::TemporalDecay, |s| s.temporal_decay = 60.0),
436            (ParameterKind::GroupStructured, |s| s.group_structured = 1.0),
437        ];
438        for (kind, mutate) in fields {
439            let a = ConfigSnapshot::default();
440            let mut b = ConfigSnapshot::default();
441            mutate(&mut b);
442            let v = a
443                .diff(&b)
444                .unwrap_or_else(|| panic!("expected diff for {kind:?}"));
445            assert_eq!(v.parameter, *kind);
446        }
447    }
448
449    #[test]
450    fn snapshot_serde_roundtrip() {
451        let s = ConfigSnapshot {
452            temperature: 1.2,
453            top_p: 0.85,
454            top_k: 50.0,
455            frequency_penalty: -0.1,
456            presence_penalty: 0.2,
457            retrieval_top_k: 7.0,
458            similarity_threshold: 0.4,
459            temporal_decay: 45.0,
460            group_structured: 0.0,
461        };
462        let json = serde_json::to_string(&s).unwrap();
463        let s2: ConfigSnapshot = serde_json::from_str(&json).unwrap();
464        assert!((s2.temperature - s.temperature).abs() < f64::EPSILON);
465        assert!((s2.top_k - s.top_k).abs() < f64::EPSILON);
466    }
467}