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 kinds = [
145 ParameterKind::Temperature,
146 ParameterKind::TopP,
147 ParameterKind::TopK,
148 ParameterKind::FrequencyPenalty,
149 ParameterKind::PresencePenalty,
150 ParameterKind::RetrievalTopK,
151 ParameterKind::SimilarityThreshold,
152 ParameterKind::TemporalDecay,
153 ParameterKind::GroupStructured,
154 ];
155 let mut result = None;
156 for kind in kinds {
157 let a = self.get(kind);
158 let b = other.get(kind);
159 if (a - b).abs() > f64::EPSILON {
160 if result.is_some() {
161 return None; }
163 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
164 let value = if kind.is_integer() {
165 VariationValue::Int(b.round() as i64)
166 } else {
167 VariationValue::Float(OrderedFloat(b))
168 };
169 result = Some(Variation {
170 parameter: kind,
171 value,
172 });
173 }
174 }
175 result
176 }
177
178 #[must_use]
192 pub fn get(&self, kind: ParameterKind) -> f64 {
193 #[allow(unreachable_patterns)]
194 match kind {
195 ParameterKind::Temperature => self.temperature,
196 ParameterKind::TopP => self.top_p,
197 ParameterKind::TopK => self.top_k,
198 ParameterKind::FrequencyPenalty => self.frequency_penalty,
199 ParameterKind::PresencePenalty => self.presence_penalty,
200 ParameterKind::RetrievalTopK => self.retrieval_top_k,
201 ParameterKind::SimilarityThreshold => self.similarity_threshold,
202 ParameterKind::TemporalDecay => self.temporal_decay,
203 ParameterKind::GroupStructured => self.group_structured,
204 _ => 0.0,
205 }
206 }
207
208 pub fn set(&mut self, kind: ParameterKind, value: f64) {
222 #[allow(unreachable_patterns)]
223 match kind {
224 ParameterKind::Temperature => self.temperature = value,
225 ParameterKind::TopP => self.top_p = value,
226 ParameterKind::TopK => self.top_k = value,
227 ParameterKind::FrequencyPenalty => self.frequency_penalty = value,
228 ParameterKind::PresencePenalty => self.presence_penalty = value,
229 ParameterKind::RetrievalTopK => self.retrieval_top_k = value,
230 ParameterKind::SimilarityThreshold => self.similarity_threshold = value,
231 ParameterKind::TemporalDecay => self.temporal_decay = value,
232 ParameterKind::GroupStructured => self.group_structured = value,
233 _ => {}
234 }
235 }
236
237 #[must_use]
241 pub fn to_generation_overrides(&self) -> GenerationOverrides {
242 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
243 GenerationOverrides {
244 temperature: Some(self.temperature),
245 top_p: Some(self.top_p),
246 top_k: Some(self.top_k.round() as usize),
247 frequency_penalty: Some(self.frequency_penalty),
248 presence_penalty: Some(self.presence_penalty),
249 }
250 }
251}
252
253#[cfg(test)]
254mod tests {
255 #![allow(
256 clippy::field_reassign_with_default,
257 clippy::semicolon_if_nothing_returned,
258 clippy::type_complexity
259 )]
260
261 use super::*;
262 use ordered_float::OrderedFloat;
263
264 #[test]
265 fn default_snapshot_fields() {
266 let s = ConfigSnapshot::default();
267 assert!((s.temperature - 0.7).abs() < f64::EPSILON);
268 assert!((s.top_p - 0.9).abs() < f64::EPSILON);
269 assert!((s.top_k - 40.0).abs() < f64::EPSILON);
270 assert!((s.frequency_penalty - 0.0).abs() < f64::EPSILON);
271 assert!((s.presence_penalty - 0.0).abs() < f64::EPSILON);
272 assert!((s.retrieval_top_k - 5.0).abs() < f64::EPSILON);
273 assert!((s.similarity_threshold - 0.35).abs() < 1e-6);
274 assert!((s.temporal_decay - 30.0).abs() < f64::EPSILON);
275 }
276
277 #[test]
278 fn apply_changes_single_param() {
279 let baseline = ConfigSnapshot::default();
280 let variation = Variation {
281 parameter: ParameterKind::Temperature,
282 value: VariationValue::Float(OrderedFloat(1.0)),
283 };
284 let applied = baseline.apply(&variation);
285 assert!((applied.temperature - 1.0).abs() < f64::EPSILON);
286 assert!((applied.top_p - 0.9).abs() < f64::EPSILON); }
288
289 #[test]
290 fn apply_with_int_value() {
291 let baseline = ConfigSnapshot::default();
292 let variation = Variation {
293 parameter: ParameterKind::TopK,
294 value: VariationValue::Int(50),
295 };
296 let applied = baseline.apply(&variation);
297 assert!((applied.top_k - 50.0).abs() < f64::EPSILON);
298 }
299
300 #[test]
301 fn diff_returns_single_changed_param() {
302 let a = ConfigSnapshot::default();
303 let mut b = ConfigSnapshot::default();
304 b.temperature = 1.0;
305 let variation = a.diff(&b);
306 assert!(variation.is_some());
307 let v = variation.unwrap();
308 assert_eq!(v.parameter, ParameterKind::Temperature);
309 assert!((v.value.as_f64() - 1.0).abs() < f64::EPSILON);
310 }
311
312 #[test]
313 fn diff_returns_none_for_identical_snapshots() {
314 let a = ConfigSnapshot::default();
315 let b = ConfigSnapshot::default();
316 assert!(a.diff(&b).is_none());
317 }
318
319 #[test]
320 fn diff_returns_none_for_multiple_changes() {
321 let a = ConfigSnapshot::default();
322 let mut b = ConfigSnapshot::default();
323 b.temperature = 1.0;
324 b.top_p = 0.5;
325 assert!(a.diff(&b).is_none());
326 }
327
328 #[test]
329 fn get_all_kinds() {
330 let s = ConfigSnapshot {
331 temperature: 0.1,
332 top_p: 0.2,
333 top_k: 3.0,
334 frequency_penalty: 0.4,
335 presence_penalty: 0.5,
336 retrieval_top_k: 6.0,
337 similarity_threshold: 0.7,
338 temporal_decay: 8.0,
339 group_structured: 0.0,
340 };
341 assert!((s.get(ParameterKind::Temperature) - 0.1).abs() < f64::EPSILON);
342 assert!((s.get(ParameterKind::TopP) - 0.2).abs() < f64::EPSILON);
343 assert!((s.get(ParameterKind::TopK) - 3.0).abs() < f64::EPSILON);
344 assert!((s.get(ParameterKind::FrequencyPenalty) - 0.4).abs() < f64::EPSILON);
345 assert!((s.get(ParameterKind::PresencePenalty) - 0.5).abs() < f64::EPSILON);
346 assert!((s.get(ParameterKind::RetrievalTopK) - 6.0).abs() < f64::EPSILON);
347 assert!((s.get(ParameterKind::SimilarityThreshold) - 0.7).abs() < f64::EPSILON);
348 assert!((s.get(ParameterKind::TemporalDecay) - 8.0).abs() < f64::EPSILON);
349 assert!((s.get(ParameterKind::GroupStructured) - 0.0).abs() < f64::EPSILON);
350 }
351
352 #[test]
353 fn set_all_kinds() {
354 let mut s = ConfigSnapshot::default();
355 s.set(ParameterKind::Temperature, 1.1);
356 s.set(ParameterKind::TopP, 0.8);
357 s.set(ParameterKind::TopK, 20.0);
358 s.set(ParameterKind::FrequencyPenalty, -0.5);
359 s.set(ParameterKind::PresencePenalty, 0.3);
360 s.set(ParameterKind::RetrievalTopK, 10.0);
361 s.set(ParameterKind::SimilarityThreshold, 0.5);
362 s.set(ParameterKind::TemporalDecay, 60.0);
363 s.set(ParameterKind::GroupStructured, 1.0);
364 assert!((s.temperature - 1.1).abs() < f64::EPSILON);
365 assert!((s.top_p - 0.8).abs() < f64::EPSILON);
366 assert!((s.top_k - 20.0).abs() < f64::EPSILON);
367 assert!((s.frequency_penalty + 0.5).abs() < f64::EPSILON);
368 assert!((s.presence_penalty - 0.3).abs() < f64::EPSILON);
369 assert!((s.retrieval_top_k - 10.0).abs() < f64::EPSILON);
370 assert!((s.similarity_threshold - 0.5).abs() < f64::EPSILON);
371 assert!((s.temporal_decay - 60.0).abs() < f64::EPSILON);
372 assert!((s.group_structured - 1.0).abs() < f64::EPSILON);
373 }
374
375 #[test]
376 fn to_generation_overrides_rounds_top_k() {
377 let mut s = ConfigSnapshot::default();
378 s.top_k = 39.9;
380 let overrides = s.to_generation_overrides();
381 assert_eq!(overrides.top_k, Some(40));
382 }
383
384 #[test]
385 fn to_generation_overrides_contains_all_llm_fields() {
386 let s = ConfigSnapshot::default();
387 let overrides = s.to_generation_overrides();
388 assert!(overrides.temperature.is_some());
389 assert!(overrides.top_p.is_some());
390 assert!(overrides.top_k.is_some());
391 assert!(overrides.frequency_penalty.is_some());
392 assert!(overrides.presence_penalty.is_some());
393 }
394
395 #[test]
396 fn diff_integer_param_produces_int_value() {
397 let a = ConfigSnapshot::default();
398 let mut b = ConfigSnapshot::default();
399 b.top_k = 50.0;
400 let variation = a.diff(&b).expect("should have one diff");
401 assert_eq!(variation.parameter, ParameterKind::TopK);
402 assert!(
403 matches!(variation.value, VariationValue::Int(50)),
404 "expected Int(50), got {:?}",
405 variation.value
406 );
407 }
408
409 #[test]
410 fn diff_retrieval_top_k_produces_int_value() {
411 let a = ConfigSnapshot::default();
412 let mut b = ConfigSnapshot::default();
413 b.retrieval_top_k = 10.0;
414 let variation = a.diff(&b).expect("should have one diff");
415 assert_eq!(variation.parameter, ParameterKind::RetrievalTopK);
416 assert!(matches!(variation.value, VariationValue::Int(10)));
417 }
418
419 #[test]
420 fn diff_all_nine_kinds() {
421 let fields: &[(ParameterKind, fn(&mut ConfigSnapshot))] = &[
422 (ParameterKind::Temperature, |s| s.temperature = 1.5),
423 (ParameterKind::TopP, |s| s.top_p = 0.5),
424 (ParameterKind::TopK, |s| s.top_k = 20.0),
425 (ParameterKind::FrequencyPenalty, |s| {
426 s.frequency_penalty = 0.5;
427 }),
428 (ParameterKind::PresencePenalty, |s| s.presence_penalty = 0.5),
429 (ParameterKind::RetrievalTopK, |s| s.retrieval_top_k = 10.0),
430 (ParameterKind::SimilarityThreshold, |s| {
431 s.similarity_threshold = 0.8;
432 }),
433 (ParameterKind::TemporalDecay, |s| s.temporal_decay = 60.0),
434 (ParameterKind::GroupStructured, |s| s.group_structured = 1.0),
435 ];
436 for (kind, mutate) in fields {
437 let a = ConfigSnapshot::default();
438 let mut b = ConfigSnapshot::default();
439 mutate(&mut b);
440 let v = a
441 .diff(&b)
442 .unwrap_or_else(|| panic!("expected diff for {kind:?}"));
443 assert_eq!(v.parameter, *kind);
444 }
445 }
446
447 #[test]
448 fn snapshot_serde_roundtrip() {
449 let s = ConfigSnapshot {
450 temperature: 1.2,
451 top_p: 0.85,
452 top_k: 50.0,
453 frequency_penalty: -0.1,
454 presence_penalty: 0.2,
455 retrieval_top_k: 7.0,
456 similarity_threshold: 0.4,
457 temporal_decay: 45.0,
458 group_structured: 0.0,
459 };
460 let json = serde_json::to_string(&s).unwrap();
461 let s2: ConfigSnapshot = serde_json::from_str(&json).unwrap();
462 assert!((s2.temperature - s.temperature).abs() < f64::EPSILON);
463 assert!((s2.top_k - s.top_k).abs() < f64::EPSILON);
464 }
465}