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