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}
65
66impl Default for ConfigSnapshot {
67 fn default() -> Self {
68 Self {
69 temperature: 0.7,
70 top_p: 0.9,
71 top_k: 40.0,
72 frequency_penalty: 0.0,
73 presence_penalty: 0.0,
74 retrieval_top_k: 5.0,
75 similarity_threshold: 0.35,
76 temporal_decay: 30.0,
77 }
78 }
79}
80
81impl ConfigSnapshot {
82 #[must_use]
89 pub fn from_config(config: &zeph_config::Config) -> Self {
90 let (temperature, top_p, top_k) = config.llm.candle.as_ref().map_or_else(
91 || {
92 tracing::debug!(
93 provider = ?config.llm.effective_provider(),
94 "LLM generation params not available for this provider; \
95 using defaults for experiment baseline (temperature=0.7, top_p=0.9, top_k=40)"
96 );
97 (0.7, 0.9, 40.0)
98 },
99 |c| {
100 (
101 c.generation.temperature,
102 c.generation.top_p.unwrap_or(0.9),
103 #[allow(clippy::cast_precision_loss)]
104 c.generation.top_k.map_or(40.0, |k| k as f64),
105 )
106 },
107 );
108
109 Self {
110 temperature,
111 top_p,
112 top_k,
113 frequency_penalty: 0.0,
114 presence_penalty: 0.0,
115 #[allow(clippy::cast_precision_loss)]
116 retrieval_top_k: config.memory.semantic.recall_limit as f64,
117 similarity_threshold: f64::from(config.memory.cross_session_score_threshold),
118 temporal_decay: f64::from(config.memory.semantic.temporal_decay_half_life_days),
119 }
120 }
121
122 #[must_use]
124 pub fn apply(&self, variation: &Variation) -> Self {
125 let mut snapshot = self.clone();
126 snapshot.set(variation.parameter, variation.value.as_f64());
127 snapshot
128 }
129
130 #[must_use]
135 pub fn diff(&self, other: &ConfigSnapshot) -> Option<Variation> {
136 let kinds = [
137 ParameterKind::Temperature,
138 ParameterKind::TopP,
139 ParameterKind::TopK,
140 ParameterKind::FrequencyPenalty,
141 ParameterKind::PresencePenalty,
142 ParameterKind::RetrievalTopK,
143 ParameterKind::SimilarityThreshold,
144 ParameterKind::TemporalDecay,
145 ];
146 let mut result = None;
147 for kind in kinds {
148 let a = self.get(kind);
149 let b = other.get(kind);
150 if (a - b).abs() > f64::EPSILON {
151 if result.is_some() {
152 return None; }
154 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
155 let value = if kind.is_integer() {
156 VariationValue::Int(b.round() as i64)
157 } else {
158 VariationValue::Float(OrderedFloat(b))
159 };
160 result = Some(Variation {
161 parameter: kind,
162 value,
163 });
164 }
165 }
166 result
167 }
168
169 #[must_use]
183 pub fn get(&self, kind: ParameterKind) -> f64 {
184 #[allow(unreachable_patterns)]
185 match kind {
186 ParameterKind::Temperature => self.temperature,
187 ParameterKind::TopP => self.top_p,
188 ParameterKind::TopK => self.top_k,
189 ParameterKind::FrequencyPenalty => self.frequency_penalty,
190 ParameterKind::PresencePenalty => self.presence_penalty,
191 ParameterKind::RetrievalTopK => self.retrieval_top_k,
192 ParameterKind::SimilarityThreshold => self.similarity_threshold,
193 ParameterKind::TemporalDecay => self.temporal_decay,
194 _ => 0.0,
195 }
196 }
197
198 pub fn set(&mut self, kind: ParameterKind, value: f64) {
212 #[allow(unreachable_patterns)]
213 match kind {
214 ParameterKind::Temperature => self.temperature = value,
215 ParameterKind::TopP => self.top_p = value,
216 ParameterKind::TopK => self.top_k = value,
217 ParameterKind::FrequencyPenalty => self.frequency_penalty = value,
218 ParameterKind::PresencePenalty => self.presence_penalty = value,
219 ParameterKind::RetrievalTopK => self.retrieval_top_k = value,
220 ParameterKind::SimilarityThreshold => self.similarity_threshold = value,
221 ParameterKind::TemporalDecay => self.temporal_decay = value,
222 _ => {}
223 }
224 }
225
226 #[must_use]
230 pub fn to_generation_overrides(&self) -> GenerationOverrides {
231 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
232 GenerationOverrides {
233 temperature: Some(self.temperature),
234 top_p: Some(self.top_p),
235 top_k: Some(self.top_k.round() as usize),
236 frequency_penalty: Some(self.frequency_penalty),
237 presence_penalty: Some(self.presence_penalty),
238 }
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 #![allow(
245 clippy::field_reassign_with_default,
246 clippy::semicolon_if_nothing_returned,
247 clippy::type_complexity
248 )]
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 };
329 assert!((s.get(ParameterKind::Temperature) - 0.1).abs() < f64::EPSILON);
330 assert!((s.get(ParameterKind::TopP) - 0.2).abs() < f64::EPSILON);
331 assert!((s.get(ParameterKind::TopK) - 3.0).abs() < f64::EPSILON);
332 assert!((s.get(ParameterKind::FrequencyPenalty) - 0.4).abs() < f64::EPSILON);
333 assert!((s.get(ParameterKind::PresencePenalty) - 0.5).abs() < f64::EPSILON);
334 assert!((s.get(ParameterKind::RetrievalTopK) - 6.0).abs() < f64::EPSILON);
335 assert!((s.get(ParameterKind::SimilarityThreshold) - 0.7).abs() < f64::EPSILON);
336 assert!((s.get(ParameterKind::TemporalDecay) - 8.0).abs() < f64::EPSILON);
337 }
338
339 #[test]
340 fn set_all_kinds() {
341 let mut s = ConfigSnapshot::default();
342 s.set(ParameterKind::Temperature, 1.1);
343 s.set(ParameterKind::TopP, 0.8);
344 s.set(ParameterKind::TopK, 20.0);
345 s.set(ParameterKind::FrequencyPenalty, -0.5);
346 s.set(ParameterKind::PresencePenalty, 0.3);
347 s.set(ParameterKind::RetrievalTopK, 10.0);
348 s.set(ParameterKind::SimilarityThreshold, 0.5);
349 s.set(ParameterKind::TemporalDecay, 60.0);
350 assert!((s.temperature - 1.1).abs() < f64::EPSILON);
351 assert!((s.top_p - 0.8).abs() < f64::EPSILON);
352 assert!((s.top_k - 20.0).abs() < f64::EPSILON);
353 assert!((s.frequency_penalty + 0.5).abs() < f64::EPSILON);
354 assert!((s.presence_penalty - 0.3).abs() < f64::EPSILON);
355 assert!((s.retrieval_top_k - 10.0).abs() < f64::EPSILON);
356 assert!((s.similarity_threshold - 0.5).abs() < f64::EPSILON);
357 assert!((s.temporal_decay - 60.0).abs() < f64::EPSILON);
358 }
359
360 #[test]
361 fn to_generation_overrides_rounds_top_k() {
362 let mut s = ConfigSnapshot::default();
363 s.top_k = 39.9;
365 let overrides = s.to_generation_overrides();
366 assert_eq!(overrides.top_k, Some(40));
367 }
368
369 #[test]
370 fn to_generation_overrides_contains_all_llm_fields() {
371 let s = ConfigSnapshot::default();
372 let overrides = s.to_generation_overrides();
373 assert!(overrides.temperature.is_some());
374 assert!(overrides.top_p.is_some());
375 assert!(overrides.top_k.is_some());
376 assert!(overrides.frequency_penalty.is_some());
377 assert!(overrides.presence_penalty.is_some());
378 }
379
380 #[test]
381 fn diff_integer_param_produces_int_value() {
382 let a = ConfigSnapshot::default();
383 let mut b = ConfigSnapshot::default();
384 b.top_k = 50.0;
385 let variation = a.diff(&b).expect("should have one diff");
386 assert_eq!(variation.parameter, ParameterKind::TopK);
387 assert!(
388 matches!(variation.value, VariationValue::Int(50)),
389 "expected Int(50), got {:?}",
390 variation.value
391 );
392 }
393
394 #[test]
395 fn diff_retrieval_top_k_produces_int_value() {
396 let a = ConfigSnapshot::default();
397 let mut b = ConfigSnapshot::default();
398 b.retrieval_top_k = 10.0;
399 let variation = a.diff(&b).expect("should have one diff");
400 assert_eq!(variation.parameter, ParameterKind::RetrievalTopK);
401 assert!(matches!(variation.value, VariationValue::Int(10)));
402 }
403
404 #[test]
405 fn diff_all_eight_kinds() {
406 let fields: &[(ParameterKind, fn(&mut ConfigSnapshot))] = &[
407 (ParameterKind::Temperature, |s| s.temperature = 1.5),
408 (ParameterKind::TopP, |s| s.top_p = 0.5),
409 (ParameterKind::TopK, |s| s.top_k = 20.0),
410 (ParameterKind::FrequencyPenalty, |s| {
411 s.frequency_penalty = 0.5;
412 }),
413 (ParameterKind::PresencePenalty, |s| s.presence_penalty = 0.5),
414 (ParameterKind::RetrievalTopK, |s| s.retrieval_top_k = 10.0),
415 (ParameterKind::SimilarityThreshold, |s| {
416 s.similarity_threshold = 0.8;
417 }),
418 (ParameterKind::TemporalDecay, |s| s.temporal_decay = 60.0),
419 ];
420 for (kind, mutate) in fields {
421 let a = ConfigSnapshot::default();
422 let mut b = ConfigSnapshot::default();
423 mutate(&mut b);
424 let v = a
425 .diff(&b)
426 .unwrap_or_else(|| panic!("expected diff for {kind:?}"));
427 assert_eq!(v.parameter, *kind);
428 }
429 }
430
431 #[test]
432 fn snapshot_serde_roundtrip() {
433 let s = ConfigSnapshot {
434 temperature: 1.2,
435 top_p: 0.85,
436 top_k: 50.0,
437 frequency_penalty: -0.1,
438 presence_penalty: 0.2,
439 retrieval_top_k: 7.0,
440 similarity_threshold: 0.4,
441 temporal_decay: 45.0,
442 };
443 let json = serde_json::to_string(&s).unwrap();
444 let s2: ConfigSnapshot = serde_json::from_str(&json).unwrap();
445 assert!((s2.temperature - s.temperature).abs() < f64::EPSILON);
446 assert!((s2.top_k - s.top_k).abs() < f64::EPSILON);
447 }
448}