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 super::*;
262    use ordered_float::OrderedFloat;
263
264    #[test]
265    fn default_snapshot_fields() {
266        let s = ConfigSnapshot::default();
267        assert!((s.temperature - 0.7).abs() < f64::EPSILON);
268        assert!((s.top_p - 0.9).abs() < f64::EPSILON);
269        assert!((s.top_k - 40.0).abs() < f64::EPSILON);
270        assert!((s.frequency_penalty - 0.0).abs() < f64::EPSILON);
271        assert!((s.presence_penalty - 0.0).abs() < f64::EPSILON);
272        assert!((s.retrieval_top_k - 5.0).abs() < f64::EPSILON);
273        assert!((s.similarity_threshold - 0.35).abs() < 1e-6);
274        assert!((s.temporal_decay - 30.0).abs() < f64::EPSILON);
275    }
276
277    #[test]
278    fn apply_changes_single_param() {
279        let baseline = ConfigSnapshot::default();
280        let variation = Variation {
281            parameter: ParameterKind::Temperature,
282            value: VariationValue::Float(OrderedFloat(1.0)),
283        };
284        let applied = baseline.apply(&variation);
285        assert!((applied.temperature - 1.0).abs() < f64::EPSILON);
286        assert!((applied.top_p - 0.9).abs() < f64::EPSILON); // unchanged
287    }
288
289    #[test]
290    fn apply_with_int_value() {
291        let baseline = ConfigSnapshot::default();
292        let variation = Variation {
293            parameter: ParameterKind::TopK,
294            value: VariationValue::Int(50),
295        };
296        let applied = baseline.apply(&variation);
297        assert!((applied.top_k - 50.0).abs() < f64::EPSILON);
298    }
299
300    #[test]
301    fn diff_returns_single_changed_param() {
302        let a = ConfigSnapshot::default();
303        let mut b = ConfigSnapshot::default();
304        b.temperature = 1.0;
305        let variation = a.diff(&b);
306        assert!(variation.is_some());
307        let v = variation.unwrap();
308        assert_eq!(v.parameter, ParameterKind::Temperature);
309        assert!((v.value.as_f64() - 1.0).abs() < f64::EPSILON);
310    }
311
312    #[test]
313    fn diff_returns_none_for_identical_snapshots() {
314        let a = ConfigSnapshot::default();
315        let b = ConfigSnapshot::default();
316        assert!(a.diff(&b).is_none());
317    }
318
319    #[test]
320    fn diff_returns_none_for_multiple_changes() {
321        let a = ConfigSnapshot::default();
322        let mut b = ConfigSnapshot::default();
323        b.temperature = 1.0;
324        b.top_p = 0.5;
325        assert!(a.diff(&b).is_none());
326    }
327
328    #[test]
329    fn get_all_kinds() {
330        let s = ConfigSnapshot {
331            temperature: 0.1,
332            top_p: 0.2,
333            top_k: 3.0,
334            frequency_penalty: 0.4,
335            presence_penalty: 0.5,
336            retrieval_top_k: 6.0,
337            similarity_threshold: 0.7,
338            temporal_decay: 8.0,
339            group_structured: 0.0,
340        };
341        assert!((s.get(ParameterKind::Temperature) - 0.1).abs() < f64::EPSILON);
342        assert!((s.get(ParameterKind::TopP) - 0.2).abs() < f64::EPSILON);
343        assert!((s.get(ParameterKind::TopK) - 3.0).abs() < f64::EPSILON);
344        assert!((s.get(ParameterKind::FrequencyPenalty) - 0.4).abs() < f64::EPSILON);
345        assert!((s.get(ParameterKind::PresencePenalty) - 0.5).abs() < f64::EPSILON);
346        assert!((s.get(ParameterKind::RetrievalTopK) - 6.0).abs() < f64::EPSILON);
347        assert!((s.get(ParameterKind::SimilarityThreshold) - 0.7).abs() < f64::EPSILON);
348        assert!((s.get(ParameterKind::TemporalDecay) - 8.0).abs() < f64::EPSILON);
349        assert!((s.get(ParameterKind::GroupStructured) - 0.0).abs() < f64::EPSILON);
350    }
351
352    #[test]
353    fn set_all_kinds() {
354        let mut s = ConfigSnapshot::default();
355        s.set(ParameterKind::Temperature, 1.1);
356        s.set(ParameterKind::TopP, 0.8);
357        s.set(ParameterKind::TopK, 20.0);
358        s.set(ParameterKind::FrequencyPenalty, -0.5);
359        s.set(ParameterKind::PresencePenalty, 0.3);
360        s.set(ParameterKind::RetrievalTopK, 10.0);
361        s.set(ParameterKind::SimilarityThreshold, 0.5);
362        s.set(ParameterKind::TemporalDecay, 60.0);
363        s.set(ParameterKind::GroupStructured, 1.0);
364        assert!((s.temperature - 1.1).abs() < f64::EPSILON);
365        assert!((s.top_p - 0.8).abs() < f64::EPSILON);
366        assert!((s.top_k - 20.0).abs() < f64::EPSILON);
367        assert!((s.frequency_penalty + 0.5).abs() < f64::EPSILON);
368        assert!((s.presence_penalty - 0.3).abs() < f64::EPSILON);
369        assert!((s.retrieval_top_k - 10.0).abs() < f64::EPSILON);
370        assert!((s.similarity_threshold - 0.5).abs() < f64::EPSILON);
371        assert!((s.temporal_decay - 60.0).abs() < f64::EPSILON);
372        assert!((s.group_structured - 1.0).abs() < f64::EPSILON);
373    }
374
375    #[test]
376    fn to_generation_overrides_rounds_top_k() {
377        let mut s = ConfigSnapshot::default();
378        // top_k = 39.9 must round to 40, not truncate to 39
379        s.top_k = 39.9;
380        let overrides = s.to_generation_overrides();
381        assert_eq!(overrides.top_k, Some(40));
382    }
383
384    #[test]
385    fn to_generation_overrides_contains_all_llm_fields() {
386        let s = ConfigSnapshot::default();
387        let overrides = s.to_generation_overrides();
388        assert!(overrides.temperature.is_some());
389        assert!(overrides.top_p.is_some());
390        assert!(overrides.top_k.is_some());
391        assert!(overrides.frequency_penalty.is_some());
392        assert!(overrides.presence_penalty.is_some());
393    }
394
395    #[test]
396    fn diff_integer_param_produces_int_value() {
397        let a = ConfigSnapshot::default();
398        let mut b = ConfigSnapshot::default();
399        b.top_k = 50.0;
400        let variation = a.diff(&b).expect("should have one diff");
401        assert_eq!(variation.parameter, ParameterKind::TopK);
402        assert!(
403            matches!(variation.value, VariationValue::Int(50)),
404            "expected Int(50), got {:?}",
405            variation.value
406        );
407    }
408
409    #[test]
410    fn diff_retrieval_top_k_produces_int_value() {
411        let a = ConfigSnapshot::default();
412        let mut b = ConfigSnapshot::default();
413        b.retrieval_top_k = 10.0;
414        let variation = a.diff(&b).expect("should have one diff");
415        assert_eq!(variation.parameter, ParameterKind::RetrievalTopK);
416        assert!(matches!(variation.value, VariationValue::Int(10)));
417    }
418
419    #[test]
420    fn diff_all_nine_kinds() {
421        let fields: &[(ParameterKind, fn(&mut ConfigSnapshot))] = &[
422            (ParameterKind::Temperature, |s| s.temperature = 1.5),
423            (ParameterKind::TopP, |s| s.top_p = 0.5),
424            (ParameterKind::TopK, |s| s.top_k = 20.0),
425            (ParameterKind::FrequencyPenalty, |s| {
426                s.frequency_penalty = 0.5;
427            }),
428            (ParameterKind::PresencePenalty, |s| s.presence_penalty = 0.5),
429            (ParameterKind::RetrievalTopK, |s| s.retrieval_top_k = 10.0),
430            (ParameterKind::SimilarityThreshold, |s| {
431                s.similarity_threshold = 0.8;
432            }),
433            (ParameterKind::TemporalDecay, |s| s.temporal_decay = 60.0),
434            (ParameterKind::GroupStructured, |s| s.group_structured = 1.0),
435        ];
436        for (kind, mutate) in fields {
437            let a = ConfigSnapshot::default();
438            let mut b = ConfigSnapshot::default();
439            mutate(&mut b);
440            let v = a
441                .diff(&b)
442                .unwrap_or_else(|| panic!("expected diff for {kind:?}"));
443            assert_eq!(v.parameter, *kind);
444        }
445    }
446
447    #[test]
448    fn snapshot_serde_roundtrip() {
449        let s = ConfigSnapshot {
450            temperature: 1.2,
451            top_p: 0.85,
452            top_k: 50.0,
453            frequency_penalty: -0.1,
454            presence_penalty: 0.2,
455            retrieval_top_k: 7.0,
456            similarity_threshold: 0.4,
457            temporal_decay: 45.0,
458            group_structured: 0.0,
459        };
460        let json = serde_json::to_string(&s).unwrap();
461        let s2: ConfigSnapshot = serde_json::from_str(&json).unwrap();
462        assert!((s2.temperature - s.temperature).abs() < f64::EPSILON);
463        assert!((s2.top_k - s.top_k).abs() < f64::EPSILON);
464    }
465}