1use ordered_float::OrderedFloat;
5use serde::{Deserialize, Serialize};
6use zeph_common::SessionId;
7
8#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
28pub struct Variation {
29 pub parameter: ParameterKind,
31 pub value: VariationValue,
33}
34
35#[non_exhaustive]
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
57#[serde(rename_all = "snake_case")]
58pub enum ParameterKind {
59 Temperature,
61 TopP,
63 TopK,
65 FrequencyPenalty,
67 PresencePenalty,
69 RetrievalTopK,
71 SimilarityThreshold,
73 TemporalDecay,
75 GroupStructured,
80}
81
82impl ParameterKind {
83 #[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 #[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.pad(self.as_str())
136 }
137}
138
139#[non_exhaustive]
140#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
158#[serde(tag = "type", content = "value")]
159pub enum VariationValue {
160 Float(OrderedFloat<f64>),
162 Int(i64),
164}
165
166impl VariationValue {
167 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct ExperimentResult {
221 pub id: Option<i64>,
223 pub session_id: SessionId,
225 pub variation: Variation,
227 pub baseline_score: f64,
229 pub candidate_score: f64,
231 pub delta: f64,
233 pub latency_ms: u64,
235 pub tokens_used: u64,
237 pub accepted: bool,
239 pub source: ExperimentSource,
241 pub created_at: String,
243}
244
245#[non_exhaustive]
256#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
257#[serde(rename_all = "snake_case")]
258pub enum ExperimentSource {
259 Manual,
261 Scheduled,
263}
264
265impl ExperimentSource {
266 #[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.pad(self.as_str())
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 #![allow(clippy::approx_constant)]
293 use std::assert_matches;
294
295 use super::*;
296
297 #[test]
298 fn parameter_kind_as_str_all_variants() {
299 let cases = [
300 (ParameterKind::Temperature, "temperature"),
301 (ParameterKind::TopP, "top_p"),
302 (ParameterKind::TopK, "top_k"),
303 (ParameterKind::FrequencyPenalty, "frequency_penalty"),
304 (ParameterKind::PresencePenalty, "presence_penalty"),
305 (ParameterKind::RetrievalTopK, "retrieval_top_k"),
306 (ParameterKind::SimilarityThreshold, "similarity_threshold"),
307 (ParameterKind::TemporalDecay, "temporal_decay"),
308 (ParameterKind::GroupStructured, "group_structured"),
309 ];
310 for (kind, expected) in cases {
311 assert_eq!(kind.as_str(), expected);
312 assert_eq!(kind.to_string(), expected);
313 }
314 }
315
316 #[test]
317 fn parameter_kind_is_integer() {
318 assert!(ParameterKind::TopK.is_integer());
319 assert!(ParameterKind::RetrievalTopK.is_integer());
320 assert!(!ParameterKind::Temperature.is_integer());
321 assert!(!ParameterKind::TopP.is_integer());
322 assert!(!ParameterKind::FrequencyPenalty.is_integer());
323 assert!(!ParameterKind::PresencePenalty.is_integer());
324 assert!(!ParameterKind::SimilarityThreshold.is_integer());
325 assert!(!ParameterKind::TemporalDecay.is_integer());
326 assert!(!ParameterKind::GroupStructured.is_integer());
327 }
328
329 #[test]
330 fn variation_value_as_f64_float() {
331 let v = VariationValue::Float(OrderedFloat(3.14));
332 assert!((v.as_f64() - 3.14).abs() < f64::EPSILON);
333 }
334
335 #[test]
336 fn variation_value_as_f64_int() {
337 let v = VariationValue::Int(42);
338 assert!((v.as_f64() - 42.0).abs() < f64::EPSILON);
339 }
340
341 #[test]
342 fn variation_value_from_f64() {
343 let v = VariationValue::from(0.7_f64);
344 assert_matches!(v, VariationValue::Float(_));
345 assert!((v.as_f64() - 0.7).abs() < f64::EPSILON);
346 }
347
348 #[test]
349 fn variation_value_from_i64() {
350 let v = VariationValue::from(40_i64);
351 assert_matches!(v, VariationValue::Int(40));
352 assert!((v.as_f64() - 40.0).abs() < f64::EPSILON);
353 }
354
355 #[test]
356 fn variation_value_float_hash_eq() {
357 use std::collections::HashSet;
358 let a = VariationValue::Float(OrderedFloat(0.7));
359 let b = VariationValue::Float(OrderedFloat(0.7));
360 let c = VariationValue::Float(OrderedFloat(0.8));
361 let mut set = HashSet::new();
362 set.insert(a.clone());
363 assert!(set.contains(&b));
364 assert!(!set.contains(&c));
365 }
366
367 #[test]
368 fn variation_serde_roundtrip() {
369 let v = Variation {
370 parameter: ParameterKind::Temperature,
371 value: VariationValue::Float(OrderedFloat(0.7)),
372 };
373 let json = serde_json::to_string(&v).expect("serialize");
374 let v2: Variation = serde_json::from_str(&json).expect("deserialize");
375 assert_eq!(v, v2);
376 }
377
378 #[test]
379 fn experiment_source_as_str() {
380 assert_eq!(ExperimentSource::Manual.as_str(), "manual");
381 assert_eq!(ExperimentSource::Scheduled.as_str(), "scheduled");
382 assert_eq!(ExperimentSource::Manual.to_string(), "manual");
383 assert_eq!(ExperimentSource::Scheduled.to_string(), "scheduled");
384 }
385
386 #[test]
390 fn parameter_kind_display_respects_width() {
391 assert_eq!(
392 format!("{:<20}", ParameterKind::TopK),
393 format!("{:<20}", "top_k")
394 );
395 assert_eq!(
396 format!("{:>20}", ParameterKind::SimilarityThreshold),
397 format!("{:>20}", "similarity_threshold")
398 );
399 }
400
401 #[test]
402 fn experiment_source_display_respects_width() {
403 assert_eq!(
404 format!("{:<12}", ExperimentSource::Manual),
405 format!("{:<12}", "manual")
406 );
407 assert_eq!(
408 format!("{:>12}", ExperimentSource::Scheduled),
409 format!("{:>12}", "scheduled")
410 );
411 }
412
413 #[test]
414 fn variation_value_int_display() {
415 let v = VariationValue::Int(42);
416 assert_eq!(v.to_string(), "42");
417 }
418
419 #[test]
420 fn experiment_result_serde_roundtrip() {
421 let result = ExperimentResult {
422 id: Some(1),
423 session_id: SessionId::new("sess-abc"),
424 variation: Variation {
425 parameter: ParameterKind::Temperature,
426 value: VariationValue::Float(OrderedFloat(0.7)),
427 },
428 baseline_score: 7.0,
429 candidate_score: 8.0,
430 delta: 1.0,
431 latency_ms: 500,
432 tokens_used: 1_000,
433 accepted: true,
434 source: ExperimentSource::Manual,
435 created_at: "2026-03-07 22:00:00".to_string(),
436 };
437 let json = serde_json::to_string(&result).expect("serialize");
438 let parsed: serde_json::Value = serde_json::from_str(&json).expect("parse");
439 assert_eq!(parsed["id"], 1); assert_eq!(parsed["session_id"], "sess-abc");
441 assert_eq!(parsed["accepted"], true);
442 assert_eq!(parsed["source"], "manual");
443 assert_eq!(parsed["variation"]["parameter"], "temperature");
444
445 let result2: ExperimentResult = serde_json::from_str(&json).expect("deserialize");
446 assert_eq!(result2.id, result.id);
447 assert_eq!(result2.session_id, result.session_id);
448 assert_eq!(result2.variation, result.variation);
449 assert!(result2.accepted);
450 assert_eq!(result2.source, ExperimentSource::Manual);
451 }
452}