Skip to main content

zeph_experiments/
types.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use ordered_float::OrderedFloat;
5use serde::{Deserialize, Serialize};
6use zeph_common::SessionId;
7
8/// A single-parameter variation: the parameter to change and its candidate value.
9///
10/// A [`Variation`] represents one experiment arm — it captures exactly which
11/// [`ParameterKind`] is being tested and the candidate [`VariationValue`].
12/// The experiment engine compares scores between the baseline and a snapshot
13/// produced by applying this variation.
14///
15/// # Examples
16///
17/// ```rust
18/// use zeph_experiments::{Variation, ParameterKind, VariationValue};
19///
20/// let v = Variation {
21///     parameter: ParameterKind::Temperature,
22///     value: VariationValue::from(0.8_f64),
23/// };
24/// assert_eq!(v.parameter.as_str(), "temperature");
25/// assert!((v.value.as_f64() - 0.8).abs() < f64::EPSILON);
26/// ```
27#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
28pub struct Variation {
29    /// The parameter being varied.
30    pub parameter: ParameterKind,
31    /// The candidate value for this variation.
32    pub value: VariationValue,
33}
34
35/// Identifies a tunable parameter in the experiment search space.
36///
37/// Each variant corresponds to a field in [`ConfigSnapshot`] and maps to a
38/// named key in [`SearchSpace`] via [`ParameterKind::as_str`].
39///
40/// The enum is `#[non_exhaustive]` — new parameters may be added in future
41/// versions without a breaking change.
42///
43/// # Examples
44///
45/// ```rust
46/// use zeph_experiments::ParameterKind;
47///
48/// assert_eq!(ParameterKind::Temperature.as_str(), "temperature");
49/// assert!(ParameterKind::TopK.is_integer());
50/// assert!(!ParameterKind::TopP.is_integer());
51/// ```
52///
53/// [`ConfigSnapshot`]: crate::ConfigSnapshot
54/// [`SearchSpace`]: crate::SearchSpace
55#[non_exhaustive]
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
57#[serde(rename_all = "snake_case")]
58pub enum ParameterKind {
59    /// LLM sampling temperature (float, typically `[0.0, 2.0]`).
60    Temperature,
61    /// Top-p (nucleus) sampling probability (float, `[0.0, 1.0]`).
62    TopP,
63    /// Top-k sampling cutoff (integer).
64    TopK,
65    /// Frequency penalty applied to already-seen tokens (float, `[-2.0, 2.0]`).
66    FrequencyPenalty,
67    /// Presence penalty applied to already-seen topics (float, `[-2.0, 2.0]`).
68    PresencePenalty,
69    /// Number of memory chunks to retrieve per query (integer).
70    RetrievalTopK,
71    /// Minimum cosine similarity score for cross-session memory recall (float).
72    SimilarityThreshold,
73    /// Half-life in days for temporal memory decay (float).
74    TemporalDecay,
75    /// `GoSkills` group-structured skill injection toggle (boolean: 0.0 = off, 1.0 = on).
76    ///
77    /// When active, this parameter overrides `skills.group_structured` in config,
78    /// bidirectionally (experiment can both enable and disable the feature).
79    GroupStructured,
80}
81
82impl ParameterKind {
83    /// Every variant of this enum, in declaration order.
84    ///
85    /// This is the single source of truth for code that must iterate all parameters,
86    /// e.g. [`ConfigSnapshot::diff`](crate::ConfigSnapshot::diff) and tests asserting
87    /// full-coverage behavior. `as_str`, `is_integer`, and `ConfigSnapshot::get`/`set`
88    /// are exhaustive matches and fail to compile if a new variant is left unhandled —
89    /// but this array is a plain literal, not a match, so adding a variant here is
90    /// **not** compiler-enforced. The `_all_variants_exhaustive` check directly below
91    /// forces a compile error pointing back at this array when a variant is added to
92    /// the enum, which is the practical guard against forgetting to extend `ALL`.
93    pub const ALL: [Self; 9] = [
94        Self::Temperature,
95        Self::TopP,
96        Self::TopK,
97        Self::FrequencyPenalty,
98        Self::PresencePenalty,
99        Self::RetrievalTopK,
100        Self::SimilarityThreshold,
101        Self::TemporalDecay,
102        Self::GroupStructured,
103    ];
104
105    /// Compile-time reminder to extend [`Self::ALL`] when a variant is added.
106    ///
107    /// Evaluated once at compile time via the `_ASSERT_ALL_VARIANTS_HANDLED` const below
108    /// (never called at runtime). Its only purpose is that adding a `ParameterKind`
109    /// variant without a corresponding arm here fails the build with `E0004:
110    /// non-exhaustive patterns`, pointing the author at `ALL` immediately above. It does
111    /// not verify `ALL`'s *length* or *contents* match the enum, only that every variant
112    /// has been acknowledged somewhere in this match.
113    const fn _all_variants_exhaustive(kind: Self) {
114        match kind {
115            Self::Temperature
116            | Self::TopP
117            | Self::TopK
118            | Self::FrequencyPenalty
119            | Self::PresencePenalty
120            | Self::RetrievalTopK
121            | Self::SimilarityThreshold
122            | Self::TemporalDecay
123            | Self::GroupStructured => {}
124        }
125    }
126
127    /// Forces [`Self::_all_variants_exhaustive`] to be checked at compile time.
128    const _ASSERT_ALL_VARIANTS_HANDLED: () = Self::_all_variants_exhaustive(Self::Temperature);
129
130    /// Return the canonical `snake_case` name of this parameter.
131    ///
132    /// The returned string matches the key used in config files and experiment
133    /// storage. It is identical to the `#[serde(rename_all = "snake_case")]`
134    /// serialization form.
135    ///
136    /// # Examples
137    ///
138    /// ```rust
139    /// use zeph_experiments::ParameterKind;
140    ///
141    /// assert_eq!(ParameterKind::FrequencyPenalty.as_str(), "frequency_penalty");
142    /// ```
143    #[must_use]
144    pub fn as_str(&self) -> &'static str {
145        match self {
146            Self::Temperature => "temperature",
147            Self::TopP => "top_p",
148            Self::TopK => "top_k",
149            Self::FrequencyPenalty => "frequency_penalty",
150            Self::PresencePenalty => "presence_penalty",
151            Self::RetrievalTopK => "retrieval_top_k",
152            Self::SimilarityThreshold => "similarity_threshold",
153            Self::TemporalDecay => "temporal_decay",
154            Self::GroupStructured => "group_structured",
155        }
156    }
157
158    /// Returns `true` if this parameter has integer semantics.
159    ///
160    /// Integer parameters produce a [`VariationValue::Int`] in `ConfigSnapshot::diff`
161    /// and are rounded before being applied to generation overrides.
162    ///
163    /// # Examples
164    ///
165    /// ```rust
166    /// use zeph_experiments::ParameterKind;
167    ///
168    /// assert!(ParameterKind::TopK.is_integer());
169    /// assert!(ParameterKind::RetrievalTopK.is_integer());
170    /// assert!(!ParameterKind::Temperature.is_integer());
171    /// ```
172    #[must_use]
173    pub fn is_integer(&self) -> bool {
174        match self {
175            Self::TopK | Self::RetrievalTopK => true,
176            Self::Temperature
177            | Self::TopP
178            | Self::FrequencyPenalty
179            | Self::PresencePenalty
180            | Self::SimilarityThreshold
181            | Self::TemporalDecay
182            | Self::GroupStructured => false,
183        }
184    }
185}
186
187impl std::fmt::Display for ParameterKind {
188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        f.pad(self.as_str())
190    }
191}
192
193#[non_exhaustive]
194/// The value for a single parameter variation.
195///
196/// Floating-point values use [`ordered_float::OrderedFloat`] to support hashing
197/// and equality, which are required for deduplication via [`std::collections::HashSet`].
198///
199/// # Examples
200///
201/// ```rust
202/// use zeph_experiments::VariationValue;
203///
204/// let f = VariationValue::from(0.7_f64);
205/// let i = VariationValue::from(40_i64);
206///
207/// assert!((f.as_f64() - 0.7).abs() < f64::EPSILON);
208/// assert_eq!(i.as_f64(), 40.0);
209/// assert_eq!(i.to_string(), "40");
210/// ```
211#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
212#[serde(tag = "type", content = "value")]
213pub enum VariationValue {
214    /// A floating-point parameter value.
215    Float(OrderedFloat<f64>),
216    /// An integer parameter value (used for `TopK`, `RetrievalTopK`).
217    Int(i64),
218}
219
220impl VariationValue {
221    /// Return the value as `f64`.
222    ///
223    /// `Int` variants are cast to `f64` via `as f64` (possible precision loss for
224    /// very large integers, but parameter values are always small).
225    ///
226    /// # Examples
227    ///
228    /// ```rust
229    /// use zeph_experiments::VariationValue;
230    ///
231    /// assert!((VariationValue::from(0.5_f64).as_f64() - 0.5).abs() < f64::EPSILON);
232    /// assert_eq!(VariationValue::from(10_i64).as_f64(), 10.0);
233    /// ```
234    #[must_use]
235    pub fn as_f64(&self) -> f64 {
236        match self {
237            Self::Float(f) => f.into_inner(),
238            #[allow(clippy::cast_precision_loss)]
239            Self::Int(i) => *i as f64,
240        }
241    }
242}
243
244impl From<f64> for VariationValue {
245    fn from(v: f64) -> Self {
246        Self::Float(OrderedFloat(v))
247    }
248}
249
250impl From<i64> for VariationValue {
251    fn from(v: i64) -> Self {
252        Self::Int(v)
253    }
254}
255
256impl std::fmt::Display for VariationValue {
257    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
258        match self {
259            Self::Float(v) => write!(f, "{v}"),
260            Self::Int(v) => write!(f, "{v}"),
261        }
262    }
263}
264
265/// Persisted record of a single variation trial.
266///
267/// Each time [`ExperimentEngine`] evaluates a candidate variation, it produces an
268/// `ExperimentResult` that is stored in `SQLite` (when memory is configured) and
269/// included in the [`ExperimentSessionReport`].
270///
271/// [`ExperimentEngine`]: crate::ExperimentEngine
272/// [`ExperimentSessionReport`]: crate::engine::ExperimentSessionReport
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct ExperimentResult {
275    /// Row ID in the `SQLite` experiments table. `None` when not yet persisted.
276    pub id: Option<i64>,
277    /// Session ID of the experiment session that produced this result.
278    pub session_id: SessionId,
279    /// The parameter variation that was tested.
280    pub variation: Variation,
281    /// Mean score of the current progressive baseline before this variation was tested.
282    pub baseline_score: f64,
283    /// Mean score achieved by the candidate configuration.
284    pub candidate_score: f64,
285    /// `candidate_score - baseline_score` (positive means improvement).
286    pub delta: f64,
287    /// Wall-clock latency for the candidate evaluation in milliseconds.
288    pub latency_ms: u64,
289    /// Total tokens consumed by judge calls during the candidate evaluation.
290    pub tokens_used: u64,
291    /// Whether this variation was accepted as the new baseline.
292    pub accepted: bool,
293    /// How this experiment was triggered.
294    pub source: ExperimentSource,
295    /// ISO-8601 timestamp when the result was recorded.
296    pub created_at: String,
297}
298
299/// How an experiment session was initiated.
300///
301/// # Examples
302///
303/// ```rust
304/// use zeph_experiments::ExperimentSource;
305///
306/// assert_eq!(ExperimentSource::Manual.as_str(), "manual");
307/// assert_eq!(ExperimentSource::Scheduled.to_string(), "scheduled");
308/// ```
309#[non_exhaustive]
310#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
311#[serde(rename_all = "snake_case")]
312pub enum ExperimentSource {
313    /// Started by the user (CLI, TUI, or API call).
314    Manual,
315    /// Started automatically by `zeph-scheduler` on a cron schedule.
316    Scheduled,
317}
318
319impl ExperimentSource {
320    /// Return the canonical `snake_case` name of this source.
321    ///
322    /// # Examples
323    ///
324    /// ```rust
325    /// use zeph_experiments::ExperimentSource;
326    ///
327    /// assert_eq!(ExperimentSource::Manual.as_str(), "manual");
328    /// ```
329    #[must_use]
330    pub fn as_str(&self) -> &'static str {
331        match self {
332            Self::Manual => "manual",
333            Self::Scheduled => "scheduled",
334        }
335    }
336}
337
338impl std::fmt::Display for ExperimentSource {
339    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
340        f.pad(self.as_str())
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    #![allow(clippy::approx_constant)]
347    use std::assert_matches;
348
349    use super::*;
350
351    #[test]
352    fn parameter_kind_as_str_all_variants() {
353        let cases = [
354            (ParameterKind::Temperature, "temperature"),
355            (ParameterKind::TopP, "top_p"),
356            (ParameterKind::TopK, "top_k"),
357            (ParameterKind::FrequencyPenalty, "frequency_penalty"),
358            (ParameterKind::PresencePenalty, "presence_penalty"),
359            (ParameterKind::RetrievalTopK, "retrieval_top_k"),
360            (ParameterKind::SimilarityThreshold, "similarity_threshold"),
361            (ParameterKind::TemporalDecay, "temporal_decay"),
362            (ParameterKind::GroupStructured, "group_structured"),
363        ];
364        assert_eq!(
365            cases.len(),
366            ParameterKind::ALL.len(),
367            "fixture must cover every ParameterKind variant"
368        );
369        for (kind, expected) in cases {
370            assert_eq!(kind.as_str(), expected);
371            assert_eq!(kind.to_string(), expected);
372        }
373    }
374
375    #[test]
376    fn parameter_kind_is_integer() {
377        assert!(ParameterKind::TopK.is_integer());
378        assert!(ParameterKind::RetrievalTopK.is_integer());
379        assert!(!ParameterKind::Temperature.is_integer());
380        assert!(!ParameterKind::TopP.is_integer());
381        assert!(!ParameterKind::FrequencyPenalty.is_integer());
382        assert!(!ParameterKind::PresencePenalty.is_integer());
383        assert!(!ParameterKind::SimilarityThreshold.is_integer());
384        assert!(!ParameterKind::TemporalDecay.is_integer());
385        assert!(!ParameterKind::GroupStructured.is_integer());
386    }
387
388    #[test]
389    fn variation_value_as_f64_float() {
390        let v = VariationValue::Float(OrderedFloat(3.14));
391        assert!((v.as_f64() - 3.14).abs() < f64::EPSILON);
392    }
393
394    #[test]
395    fn variation_value_as_f64_int() {
396        let v = VariationValue::Int(42);
397        assert!((v.as_f64() - 42.0).abs() < f64::EPSILON);
398    }
399
400    #[test]
401    fn variation_value_from_f64() {
402        let v = VariationValue::from(0.7_f64);
403        assert_matches!(v, VariationValue::Float(_));
404        assert!((v.as_f64() - 0.7).abs() < f64::EPSILON);
405    }
406
407    #[test]
408    fn variation_value_from_i64() {
409        let v = VariationValue::from(40_i64);
410        assert_matches!(v, VariationValue::Int(40));
411        assert!((v.as_f64() - 40.0).abs() < f64::EPSILON);
412    }
413
414    #[test]
415    fn variation_value_float_hash_eq() {
416        use std::collections::HashSet;
417        let a = VariationValue::Float(OrderedFloat(0.7));
418        let b = VariationValue::Float(OrderedFloat(0.7));
419        let c = VariationValue::Float(OrderedFloat(0.8));
420        let mut set = HashSet::new();
421        set.insert(a.clone());
422        assert!(set.contains(&b));
423        assert!(!set.contains(&c));
424    }
425
426    #[test]
427    fn variation_serde_roundtrip() {
428        let v = Variation {
429            parameter: ParameterKind::Temperature,
430            value: VariationValue::Float(OrderedFloat(0.7)),
431        };
432        let json = serde_json::to_string(&v).expect("serialize");
433        let v2: Variation = serde_json::from_str(&json).expect("deserialize");
434        assert_eq!(v, v2);
435    }
436
437    #[test]
438    fn experiment_source_as_str() {
439        assert_eq!(ExperimentSource::Manual.as_str(), "manual");
440        assert_eq!(ExperimentSource::Scheduled.as_str(), "scheduled");
441        assert_eq!(ExperimentSource::Manual.to_string(), "manual");
442        assert_eq!(ExperimentSource::Scheduled.to_string(), "scheduled");
443    }
444
445    /// Locks in the `f.pad` fix (#6066): `f.write_str` ignores width/fill/align flags.
446    /// `f.pad` must reproduce the same padding a plain `&str` would get under an
447    /// identical width specifier.
448    #[test]
449    fn parameter_kind_display_respects_width() {
450        assert_eq!(
451            format!("{:<20}", ParameterKind::TopK),
452            format!("{:<20}", "top_k")
453        );
454        assert_eq!(
455            format!("{:>20}", ParameterKind::SimilarityThreshold),
456            format!("{:>20}", "similarity_threshold")
457        );
458    }
459
460    #[test]
461    fn experiment_source_display_respects_width() {
462        assert_eq!(
463            format!("{:<12}", ExperimentSource::Manual),
464            format!("{:<12}", "manual")
465        );
466        assert_eq!(
467            format!("{:>12}", ExperimentSource::Scheduled),
468            format!("{:>12}", "scheduled")
469        );
470    }
471
472    #[test]
473    fn variation_value_int_display() {
474        let v = VariationValue::Int(42);
475        assert_eq!(v.to_string(), "42");
476    }
477
478    #[test]
479    fn experiment_result_serde_roundtrip() {
480        let result = ExperimentResult {
481            id: Some(1),
482            session_id: SessionId::new("sess-abc"),
483            variation: Variation {
484                parameter: ParameterKind::Temperature,
485                value: VariationValue::Float(OrderedFloat(0.7)),
486            },
487            baseline_score: 7.0,
488            candidate_score: 8.0,
489            delta: 1.0,
490            latency_ms: 500,
491            tokens_used: 1_000,
492            accepted: true,
493            source: ExperimentSource::Manual,
494            created_at: "2026-03-07 22:00:00".to_string(),
495        };
496        let json = serde_json::to_string(&result).expect("serialize");
497        let parsed: serde_json::Value = serde_json::from_str(&json).expect("parse");
498        assert_eq!(parsed["id"], 1); // Some(1) serializes as 1
499        assert_eq!(parsed["session_id"], "sess-abc");
500        assert_eq!(parsed["accepted"], true);
501        assert_eq!(parsed["source"], "manual");
502        assert_eq!(parsed["variation"]["parameter"], "temperature");
503
504        let result2: ExperimentResult = serde_json::from_str(&json).expect("deserialize");
505        assert_eq!(result2.id, result.id);
506        assert_eq!(result2.session_id, result.session_id);
507        assert_eq!(result2.variation, result.variation);
508        assert!(result2.accepted);
509        assert_eq!(result2.source, ExperimentSource::Manual);
510    }
511}