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