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}