1use ordered_float::OrderedFloat;
14use serde::{Deserialize, Serialize};
15pub use zeph_llm::provider::GenerationOverrides;
16
17use super::types::{ParameterKind, Variation, VariationValue};
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct ConfigSnapshot {
48 pub temperature: f64,
50 pub top_p: f64,
52 pub top_k: f64,
54 pub frequency_penalty: f64,
56 pub presence_penalty: f64,
58 pub retrieval_top_k: f64,
60 pub similarity_threshold: f64,
62 pub temporal_decay: f64,
64 pub group_structured: f64,
66}
67
68impl Default for ConfigSnapshot {
69 fn default() -> Self {
70 Self {
71 temperature: 0.7,
72 top_p: 0.9,
73 top_k: 40.0,
74 frequency_penalty: 0.0,
75 presence_penalty: 0.0,
76 retrieval_top_k: 5.0,
77 similarity_threshold: 0.35,
78 temporal_decay: 30.0,
79 group_structured: 0.0,
80 }
81 }
82}
83
84impl ConfigSnapshot {
85 #[must_use]
92 pub fn from_config(config: &zeph_config::Config) -> Self {
93 let (temperature, top_p, top_k) = config.llm.candle.as_ref().map_or_else(
94 || {
95 tracing::debug!(
96 provider = ?config.llm.effective_provider(),
97 "LLM generation params not available for this provider; \
98 using defaults for experiment baseline (temperature=0.7, top_p=0.9, top_k=40)"
99 );
100 (0.7, 0.9, 40.0)
101 },
102 |c| {
103 (
104 c.generation.temperature,
105 c.generation.top_p.unwrap_or(0.9),
106 #[allow(clippy::cast_precision_loss)]
107 c.generation.top_k.map_or(40.0, |k| k as f64),
108 )
109 },
110 );
111
112 Self {
113 temperature,
114 top_p,
115 top_k,
116 frequency_penalty: 0.0,
117 presence_penalty: 0.0,
118 #[allow(clippy::cast_precision_loss)]
119 retrieval_top_k: config.memory.semantic.recall_limit as f64,
120 similarity_threshold: f64::from(config.memory.cross_session_score_threshold),
121 temporal_decay: f64::from(config.memory.semantic.temporal_decay_half_life_days),
122 group_structured: if config.skills.group_structured {
123 1.0
124 } else {
125 0.0
126 },
127 }
128 }
129
130 #[must_use]
132 pub fn apply(&self, variation: &Variation) -> Self {
133 let mut snapshot = self.clone();
134 snapshot.set(variation.parameter, variation.value.as_f64());
135 snapshot
136 }
137
138 #[must_use]
143 pub fn diff(&self, other: &ConfigSnapshot) -> Option<Variation> {
144 let mut result = None;
145 for kind in ParameterKind::ALL {
146 let a = self.get(kind);
147 let b = other.get(kind);
148 if (a - b).abs() > f64::EPSILON {
149 if result.is_some() {
150 return None; }
152 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
153 let value = if kind.is_integer() {
154 VariationValue::Int(b.round() as i64)
155 } else {
156 VariationValue::Float(OrderedFloat(b))
157 };
158 result = Some(Variation {
159 parameter: kind,
160 value,
161 });
162 }
163 }
164 result
165 }
166
167 #[must_use]
182 pub fn get(&self, kind: ParameterKind) -> f64 {
183 match kind {
184 ParameterKind::Temperature => self.temperature,
185 ParameterKind::TopP => self.top_p,
186 ParameterKind::TopK => self.top_k,
187 ParameterKind::FrequencyPenalty => self.frequency_penalty,
188 ParameterKind::PresencePenalty => self.presence_penalty,
189 ParameterKind::RetrievalTopK => self.retrieval_top_k,
190 ParameterKind::SimilarityThreshold => self.similarity_threshold,
191 ParameterKind::TemporalDecay => self.temporal_decay,
192 ParameterKind::GroupStructured => self.group_structured,
193 }
194 }
195
196 pub fn set(&mut self, kind: ParameterKind, value: f64) {
211 match kind {
212 ParameterKind::Temperature => self.temperature = value,
213 ParameterKind::TopP => self.top_p = value,
214 ParameterKind::TopK => self.top_k = value,
215 ParameterKind::FrequencyPenalty => self.frequency_penalty = value,
216 ParameterKind::PresencePenalty => self.presence_penalty = value,
217 ParameterKind::RetrievalTopK => self.retrieval_top_k = value,
218 ParameterKind::SimilarityThreshold => self.similarity_threshold = value,
219 ParameterKind::TemporalDecay => self.temporal_decay = value,
220 ParameterKind::GroupStructured => self.group_structured = value,
221 }
222 }
223
224 #[must_use]
228 pub fn to_generation_overrides(&self) -> GenerationOverrides {
229 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
230 GenerationOverrides {
231 temperature: Some(self.temperature),
232 top_p: Some(self.top_p),
233 top_k: Some(self.top_k.round() as usize),
234 frequency_penalty: Some(self.frequency_penalty),
235 presence_penalty: Some(self.presence_penalty),
236 }
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 #![allow(
243 clippy::field_reassign_with_default,
244 clippy::semicolon_if_nothing_returned,
245 clippy::type_complexity
246 )]
247
248 use std::assert_matches;
249
250 use super::*;
251 use ordered_float::OrderedFloat;
252
253 #[test]
254 fn default_snapshot_fields() {
255 let s = ConfigSnapshot::default();
256 assert!((s.temperature - 0.7).abs() < f64::EPSILON);
257 assert!((s.top_p - 0.9).abs() < f64::EPSILON);
258 assert!((s.top_k - 40.0).abs() < f64::EPSILON);
259 assert!((s.frequency_penalty - 0.0).abs() < f64::EPSILON);
260 assert!((s.presence_penalty - 0.0).abs() < f64::EPSILON);
261 assert!((s.retrieval_top_k - 5.0).abs() < f64::EPSILON);
262 assert!((s.similarity_threshold - 0.35).abs() < 1e-6);
263 assert!((s.temporal_decay - 30.0).abs() < f64::EPSILON);
264 }
265
266 #[test]
267 fn apply_changes_single_param() {
268 let baseline = ConfigSnapshot::default();
269 let variation = Variation {
270 parameter: ParameterKind::Temperature,
271 value: VariationValue::Float(OrderedFloat(1.0)),
272 };
273 let applied = baseline.apply(&variation);
274 assert!((applied.temperature - 1.0).abs() < f64::EPSILON);
275 assert!((applied.top_p - 0.9).abs() < f64::EPSILON); }
277
278 #[test]
279 fn apply_with_int_value() {
280 let baseline = ConfigSnapshot::default();
281 let variation = Variation {
282 parameter: ParameterKind::TopK,
283 value: VariationValue::Int(50),
284 };
285 let applied = baseline.apply(&variation);
286 assert!((applied.top_k - 50.0).abs() < f64::EPSILON);
287 }
288
289 #[test]
290 fn diff_returns_single_changed_param() {
291 let a = ConfigSnapshot::default();
292 let mut b = ConfigSnapshot::default();
293 b.temperature = 1.0;
294 let variation = a.diff(&b);
295 assert!(variation.is_some());
296 let v = variation.unwrap();
297 assert_eq!(v.parameter, ParameterKind::Temperature);
298 assert!((v.value.as_f64() - 1.0).abs() < f64::EPSILON);
299 }
300
301 #[test]
302 fn diff_returns_none_for_identical_snapshots() {
303 let a = ConfigSnapshot::default();
304 let b = ConfigSnapshot::default();
305 assert!(a.diff(&b).is_none());
306 }
307
308 #[test]
309 fn diff_returns_none_for_multiple_changes() {
310 let a = ConfigSnapshot::default();
311 let mut b = ConfigSnapshot::default();
312 b.temperature = 1.0;
313 b.top_p = 0.5;
314 assert!(a.diff(&b).is_none());
315 }
316
317 #[test]
318 fn get_all_kinds() {
319 let s = ConfigSnapshot {
320 temperature: 0.1,
321 top_p: 0.2,
322 top_k: 3.0,
323 frequency_penalty: 0.4,
324 presence_penalty: 0.5,
325 retrieval_top_k: 6.0,
326 similarity_threshold: 0.7,
327 temporal_decay: 8.0,
328 group_structured: 0.0,
329 };
330 assert!((s.get(ParameterKind::Temperature) - 0.1).abs() < f64::EPSILON);
331 assert!((s.get(ParameterKind::TopP) - 0.2).abs() < f64::EPSILON);
332 assert!((s.get(ParameterKind::TopK) - 3.0).abs() < f64::EPSILON);
333 assert!((s.get(ParameterKind::FrequencyPenalty) - 0.4).abs() < f64::EPSILON);
334 assert!((s.get(ParameterKind::PresencePenalty) - 0.5).abs() < f64::EPSILON);
335 assert!((s.get(ParameterKind::RetrievalTopK) - 6.0).abs() < f64::EPSILON);
336 assert!((s.get(ParameterKind::SimilarityThreshold) - 0.7).abs() < f64::EPSILON);
337 assert!((s.get(ParameterKind::TemporalDecay) - 8.0).abs() < f64::EPSILON);
338 assert!((s.get(ParameterKind::GroupStructured) - 0.0).abs() < f64::EPSILON);
339 }
340
341 #[test]
342 fn set_all_kinds() {
343 let mut s = ConfigSnapshot::default();
344 s.set(ParameterKind::Temperature, 1.1);
345 s.set(ParameterKind::TopP, 0.8);
346 s.set(ParameterKind::TopK, 20.0);
347 s.set(ParameterKind::FrequencyPenalty, -0.5);
348 s.set(ParameterKind::PresencePenalty, 0.3);
349 s.set(ParameterKind::RetrievalTopK, 10.0);
350 s.set(ParameterKind::SimilarityThreshold, 0.5);
351 s.set(ParameterKind::TemporalDecay, 60.0);
352 s.set(ParameterKind::GroupStructured, 1.0);
353 assert!((s.temperature - 1.1).abs() < f64::EPSILON);
354 assert!((s.top_p - 0.8).abs() < f64::EPSILON);
355 assert!((s.top_k - 20.0).abs() < f64::EPSILON);
356 assert!((s.frequency_penalty + 0.5).abs() < f64::EPSILON);
357 assert!((s.presence_penalty - 0.3).abs() < f64::EPSILON);
358 assert!((s.retrieval_top_k - 10.0).abs() < f64::EPSILON);
359 assert!((s.similarity_threshold - 0.5).abs() < f64::EPSILON);
360 assert!((s.temporal_decay - 60.0).abs() < f64::EPSILON);
361 assert!((s.group_structured - 1.0).abs() < f64::EPSILON);
362 }
363
364 #[test]
365 fn to_generation_overrides_rounds_top_k() {
366 let mut s = ConfigSnapshot::default();
367 s.top_k = 39.9;
369 let overrides = s.to_generation_overrides();
370 assert_eq!(overrides.top_k, Some(40));
371 }
372
373 #[test]
374 fn to_generation_overrides_contains_all_llm_fields() {
375 let s = ConfigSnapshot::default();
376 let overrides = s.to_generation_overrides();
377 assert!(overrides.temperature.is_some());
378 assert!(overrides.top_p.is_some());
379 assert!(overrides.top_k.is_some());
380 assert!(overrides.frequency_penalty.is_some());
381 assert!(overrides.presence_penalty.is_some());
382 }
383
384 #[test]
385 fn diff_integer_param_produces_int_value() {
386 let a = ConfigSnapshot::default();
387 let mut b = ConfigSnapshot::default();
388 b.top_k = 50.0;
389 let variation = a.diff(&b).expect("should have one diff");
390 assert_eq!(variation.parameter, ParameterKind::TopK);
391 assert!(
392 matches!(variation.value, VariationValue::Int(50)),
393 "expected Int(50), got {:?}",
394 variation.value
395 );
396 }
397
398 #[test]
399 fn diff_retrieval_top_k_produces_int_value() {
400 let a = ConfigSnapshot::default();
401 let mut b = ConfigSnapshot::default();
402 b.retrieval_top_k = 10.0;
403 let variation = a.diff(&b).expect("should have one diff");
404 assert_eq!(variation.parameter, ParameterKind::RetrievalTopK);
405 assert_matches!(variation.value, VariationValue::Int(10));
406 }
407
408 #[test]
409 fn diff_all_kinds() {
410 let fields: &[(ParameterKind, fn(&mut ConfigSnapshot))] = &[
411 (ParameterKind::Temperature, |s| s.temperature = 1.5),
412 (ParameterKind::TopP, |s| s.top_p = 0.5),
413 (ParameterKind::TopK, |s| s.top_k = 20.0),
414 (ParameterKind::FrequencyPenalty, |s| {
415 s.frequency_penalty = 0.5;
416 }),
417 (ParameterKind::PresencePenalty, |s| s.presence_penalty = 0.5),
418 (ParameterKind::RetrievalTopK, |s| s.retrieval_top_k = 10.0),
419 (ParameterKind::SimilarityThreshold, |s| {
420 s.similarity_threshold = 0.8;
421 }),
422 (ParameterKind::TemporalDecay, |s| s.temporal_decay = 60.0),
423 (ParameterKind::GroupStructured, |s| s.group_structured = 1.0),
424 ];
425 assert_eq!(
428 fields.len(),
429 ParameterKind::ALL.len(),
430 "fixture must cover every ParameterKind variant"
431 );
432 for (kind, mutate) in fields {
433 let a = ConfigSnapshot::default();
434 let mut b = ConfigSnapshot::default();
435 mutate(&mut b);
436 let v = a
437 .diff(&b)
438 .unwrap_or_else(|| panic!("expected diff for {kind:?}"));
439 assert_eq!(v.parameter, *kind);
440 }
441 }
442
443 #[test]
444 fn get_set_round_trip_all_kinds() {
445 let mut s = ConfigSnapshot::default();
448 for (i, kind) in ParameterKind::ALL.into_iter().enumerate() {
449 #[allow(clippy::cast_precision_loss)]
450 let value = i as f64 + 1.0;
451 s.set(kind, value);
452 assert!(
453 (s.get(kind) - value).abs() < f64::EPSILON,
454 "get/set round-trip failed for {kind:?}"
455 );
456 }
457 }
458
459 #[test]
460 fn snapshot_serde_roundtrip() {
461 let s = ConfigSnapshot {
462 temperature: 1.2,
463 top_p: 0.85,
464 top_k: 50.0,
465 frequency_penalty: -0.1,
466 presence_penalty: 0.2,
467 retrieval_top_k: 7.0,
468 similarity_threshold: 0.4,
469 temporal_decay: 45.0,
470 group_structured: 0.0,
471 };
472 let json = serde_json::to_string(&s).unwrap();
473 let s2: ConfigSnapshot = serde_json::from_str(&json).unwrap();
474 assert!((s2.temperature - s.temperature).abs() < f64::EPSILON);
475 assert!((s2.top_k - s.top_k).abs() < f64::EPSILON);
476 }
477}