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}
76
77impl ParameterKind {
78    /// Return the canonical snake_case name of this parameter.
79    ///
80    /// The returned string matches the key used in config files and experiment
81    /// storage. It is identical to the `#[serde(rename_all = "snake_case")]`
82    /// serialization form.
83    ///
84    /// # Examples
85    ///
86    /// ```rust
87    /// use zeph_experiments::ParameterKind;
88    ///
89    /// assert_eq!(ParameterKind::FrequencyPenalty.as_str(), "frequency_penalty");
90    /// ```
91    #[must_use]
92    pub fn as_str(&self) -> &'static str {
93        #[allow(unreachable_patterns)]
94        match self {
95            Self::Temperature => "temperature",
96            Self::TopP => "top_p",
97            Self::TopK => "top_k",
98            Self::FrequencyPenalty => "frequency_penalty",
99            Self::PresencePenalty => "presence_penalty",
100            Self::RetrievalTopK => "retrieval_top_k",
101            Self::SimilarityThreshold => "similarity_threshold",
102            Self::TemporalDecay => "temporal_decay",
103            _ => "unknown",
104        }
105    }
106
107    /// Returns `true` if this parameter has integer semantics.
108    ///
109    /// Integer parameters produce a [`VariationValue::Int`] in `ConfigSnapshot::diff`
110    /// and are rounded before being applied to generation overrides.
111    ///
112    /// # Examples
113    ///
114    /// ```rust
115    /// use zeph_experiments::ParameterKind;
116    ///
117    /// assert!(ParameterKind::TopK.is_integer());
118    /// assert!(ParameterKind::RetrievalTopK.is_integer());
119    /// assert!(!ParameterKind::Temperature.is_integer());
120    /// ```
121    #[must_use]
122    pub fn is_integer(&self) -> bool {
123        matches!(self, Self::TopK | Self::RetrievalTopK)
124    }
125}
126
127impl std::fmt::Display for ParameterKind {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        f.write_str(self.as_str())
130    }
131}
132
133/// The value for a single parameter variation.
134///
135/// Floating-point values use [`ordered_float::OrderedFloat`] to support hashing
136/// and equality, which are required for deduplication via [`std::collections::HashSet`].
137///
138/// # Examples
139///
140/// ```rust
141/// use zeph_experiments::VariationValue;
142///
143/// let f = VariationValue::from(0.7_f64);
144/// let i = VariationValue::from(40_i64);
145///
146/// assert!((f.as_f64() - 0.7).abs() < f64::EPSILON);
147/// assert_eq!(i.as_f64(), 40.0);
148/// assert_eq!(i.to_string(), "40");
149/// ```
150#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
151#[serde(tag = "type", content = "value")]
152pub enum VariationValue {
153    /// A floating-point parameter value.
154    Float(OrderedFloat<f64>),
155    /// An integer parameter value (used for `TopK`, `RetrievalTopK`).
156    Int(i64),
157}
158
159impl VariationValue {
160    /// Return the value as `f64`.
161    ///
162    /// `Int` variants are cast to `f64` via `as f64` (possible precision loss for
163    /// very large integers, but parameter values are always small).
164    ///
165    /// # Examples
166    ///
167    /// ```rust
168    /// use zeph_experiments::VariationValue;
169    ///
170    /// assert!((VariationValue::from(0.5_f64).as_f64() - 0.5).abs() < f64::EPSILON);
171    /// assert_eq!(VariationValue::from(10_i64).as_f64(), 10.0);
172    /// ```
173    #[must_use]
174    pub fn as_f64(&self) -> f64 {
175        match self {
176            Self::Float(f) => f.into_inner(),
177            #[allow(clippy::cast_precision_loss)]
178            Self::Int(i) => *i as f64,
179        }
180    }
181}
182
183impl From<f64> for VariationValue {
184    fn from(v: f64) -> Self {
185        Self::Float(OrderedFloat(v))
186    }
187}
188
189impl From<i64> for VariationValue {
190    fn from(v: i64) -> Self {
191        Self::Int(v)
192    }
193}
194
195impl std::fmt::Display for VariationValue {
196    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197        match self {
198            Self::Float(v) => write!(f, "{v}"),
199            Self::Int(v) => write!(f, "{v}"),
200        }
201    }
202}
203
204/// Persisted record of a single variation trial.
205///
206/// Each time [`ExperimentEngine`] evaluates a candidate variation, it produces an
207/// `ExperimentResult` that is stored in SQLite (when memory is configured) and
208/// included in the [`ExperimentSessionReport`].
209///
210/// [`ExperimentEngine`]: crate::ExperimentEngine
211/// [`ExperimentSessionReport`]: crate::engine::ExperimentSessionReport
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct ExperimentResult {
214    /// Row ID in the SQLite experiments table (`-1` when not yet persisted).
215    pub id: i64,
216    /// Session ID of the experiment session that produced this result.
217    pub session_id: SessionId,
218    /// The parameter variation that was tested.
219    pub variation: Variation,
220    /// Mean score of the current progressive baseline before this variation was tested.
221    pub baseline_score: f64,
222    /// Mean score achieved by the candidate configuration.
223    pub candidate_score: f64,
224    /// `candidate_score - baseline_score` (positive means improvement).
225    pub delta: f64,
226    /// Wall-clock latency for the candidate evaluation in milliseconds.
227    pub latency_ms: u64,
228    /// Total tokens consumed by judge calls during the candidate evaluation.
229    pub tokens_used: u64,
230    /// Whether this variation was accepted as the new baseline.
231    pub accepted: bool,
232    /// How this experiment was triggered.
233    pub source: ExperimentSource,
234    /// ISO-8601 timestamp when the result was recorded.
235    pub created_at: String,
236}
237
238/// How an experiment session was initiated.
239///
240/// # Examples
241///
242/// ```rust
243/// use zeph_experiments::ExperimentSource;
244///
245/// assert_eq!(ExperimentSource::Manual.as_str(), "manual");
246/// assert_eq!(ExperimentSource::Scheduled.to_string(), "scheduled");
247/// ```
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249#[serde(rename_all = "snake_case")]
250pub enum ExperimentSource {
251    /// Started by the user (CLI, TUI, or API call).
252    Manual,
253    /// Started automatically by `zeph-scheduler` on a cron schedule.
254    Scheduled,
255}
256
257impl ExperimentSource {
258    /// Return the canonical snake_case name of this source.
259    ///
260    /// # Examples
261    ///
262    /// ```rust
263    /// use zeph_experiments::ExperimentSource;
264    ///
265    /// assert_eq!(ExperimentSource::Manual.as_str(), "manual");
266    /// ```
267    #[must_use]
268    pub fn as_str(&self) -> &'static str {
269        match self {
270            Self::Manual => "manual",
271            Self::Scheduled => "scheduled",
272        }
273    }
274}
275
276impl std::fmt::Display for ExperimentSource {
277    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278        f.write_str(self.as_str())
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    #![allow(clippy::approx_constant)]
285
286    use super::*;
287
288    #[test]
289    fn parameter_kind_as_str_all_variants() {
290        let cases = [
291            (ParameterKind::Temperature, "temperature"),
292            (ParameterKind::TopP, "top_p"),
293            (ParameterKind::TopK, "top_k"),
294            (ParameterKind::FrequencyPenalty, "frequency_penalty"),
295            (ParameterKind::PresencePenalty, "presence_penalty"),
296            (ParameterKind::RetrievalTopK, "retrieval_top_k"),
297            (ParameterKind::SimilarityThreshold, "similarity_threshold"),
298            (ParameterKind::TemporalDecay, "temporal_decay"),
299        ];
300        for (kind, expected) in cases {
301            assert_eq!(kind.as_str(), expected);
302            assert_eq!(kind.to_string(), expected);
303        }
304    }
305
306    #[test]
307    fn parameter_kind_is_integer() {
308        assert!(ParameterKind::TopK.is_integer());
309        assert!(ParameterKind::RetrievalTopK.is_integer());
310        assert!(!ParameterKind::Temperature.is_integer());
311        assert!(!ParameterKind::TopP.is_integer());
312        assert!(!ParameterKind::FrequencyPenalty.is_integer());
313        assert!(!ParameterKind::PresencePenalty.is_integer());
314        assert!(!ParameterKind::SimilarityThreshold.is_integer());
315        assert!(!ParameterKind::TemporalDecay.is_integer());
316    }
317
318    #[test]
319    fn variation_value_as_f64_float() {
320        let v = VariationValue::Float(OrderedFloat(3.14));
321        assert!((v.as_f64() - 3.14).abs() < f64::EPSILON);
322    }
323
324    #[test]
325    fn variation_value_as_f64_int() {
326        let v = VariationValue::Int(42);
327        assert!((v.as_f64() - 42.0).abs() < f64::EPSILON);
328    }
329
330    #[test]
331    fn variation_value_from_f64() {
332        let v = VariationValue::from(0.7_f64);
333        assert!(matches!(v, VariationValue::Float(_)));
334        assert!((v.as_f64() - 0.7).abs() < f64::EPSILON);
335    }
336
337    #[test]
338    fn variation_value_from_i64() {
339        let v = VariationValue::from(40_i64);
340        assert!(matches!(v, VariationValue::Int(40)));
341        assert!((v.as_f64() - 40.0).abs() < f64::EPSILON);
342    }
343
344    #[test]
345    fn variation_value_float_hash_eq() {
346        use std::collections::HashSet;
347        let a = VariationValue::Float(OrderedFloat(0.7));
348        let b = VariationValue::Float(OrderedFloat(0.7));
349        let c = VariationValue::Float(OrderedFloat(0.8));
350        let mut set = HashSet::new();
351        set.insert(a.clone());
352        assert!(set.contains(&b));
353        assert!(!set.contains(&c));
354    }
355
356    #[test]
357    fn variation_serde_roundtrip() {
358        let v = Variation {
359            parameter: ParameterKind::Temperature,
360            value: VariationValue::Float(OrderedFloat(0.7)),
361        };
362        let json = serde_json::to_string(&v).expect("serialize");
363        let v2: Variation = serde_json::from_str(&json).expect("deserialize");
364        assert_eq!(v, v2);
365    }
366
367    #[test]
368    fn experiment_source_as_str() {
369        assert_eq!(ExperimentSource::Manual.as_str(), "manual");
370        assert_eq!(ExperimentSource::Scheduled.as_str(), "scheduled");
371        assert_eq!(ExperimentSource::Manual.to_string(), "manual");
372        assert_eq!(ExperimentSource::Scheduled.to_string(), "scheduled");
373    }
374
375    #[test]
376    fn variation_value_int_display() {
377        let v = VariationValue::Int(42);
378        assert_eq!(v.to_string(), "42");
379    }
380
381    #[test]
382    fn experiment_result_serde_roundtrip() {
383        let result = ExperimentResult {
384            id: 1,
385            session_id: SessionId::new("sess-abc"),
386            variation: Variation {
387                parameter: ParameterKind::Temperature,
388                value: VariationValue::Float(OrderedFloat(0.7)),
389            },
390            baseline_score: 7.0,
391            candidate_score: 8.0,
392            delta: 1.0,
393            latency_ms: 500,
394            tokens_used: 1_000,
395            accepted: true,
396            source: ExperimentSource::Manual,
397            created_at: "2026-03-07 22:00:00".to_string(),
398        };
399        let json = serde_json::to_string(&result).expect("serialize");
400        let parsed: serde_json::Value = serde_json::from_str(&json).expect("parse");
401        assert_eq!(parsed["id"], 1);
402        assert_eq!(parsed["session_id"], "sess-abc");
403        assert_eq!(parsed["accepted"], true);
404        assert_eq!(parsed["source"], "manual");
405        assert_eq!(parsed["variation"]["parameter"], "temperature");
406
407        let result2: ExperimentResult = serde_json::from_str(&json).expect("deserialize");
408        assert_eq!(result2.id, result.id);
409        assert_eq!(result2.session_id, result.session_id);
410        assert_eq!(result2.variation, result.variation);
411        assert!(result2.accepted);
412        assert_eq!(result2.source, ExperimentSource::Manual);
413    }
414}