1use ordered_float::OrderedFloat;
7use serde::{Deserialize, Serialize};
8pub use zeph_llm::provider::GenerationOverrides;
9
10use super::types::{ParameterKind, Variation, VariationValue};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ConfigSnapshot {
19 pub temperature: f64,
20 pub top_p: f64,
21 pub top_k: f64,
22 pub frequency_penalty: f64,
23 pub presence_penalty: f64,
24 pub retrieval_top_k: f64,
25 pub similarity_threshold: f64,
26 pub temporal_decay: f64,
27}
28
29impl Default for ConfigSnapshot {
30 fn default() -> Self {
31 Self {
32 temperature: 0.7,
33 top_p: 0.9,
34 top_k: 40.0,
35 frequency_penalty: 0.0,
36 presence_penalty: 0.0,
37 retrieval_top_k: 5.0,
38 similarity_threshold: 0.35,
39 temporal_decay: 30.0,
40 }
41 }
42}
43
44impl ConfigSnapshot {
45 #[must_use]
52 pub fn from_config(config: &zeph_config::Config) -> Self {
53 let (temperature, top_p, top_k) = config.llm.candle.as_ref().map_or_else(
54 || {
55 tracing::debug!(
56 provider = ?config.llm.effective_provider(),
57 "LLM generation params not available for this provider; \
58 using defaults for experiment baseline (temperature=0.7, top_p=0.9, top_k=40)"
59 );
60 (0.7, 0.9, 40.0)
61 },
62 |c| {
63 (
64 c.generation.temperature,
65 c.generation.top_p.unwrap_or(0.9),
66 #[allow(clippy::cast_precision_loss)]
67 c.generation.top_k.map_or(40.0, |k| k as f64),
68 )
69 },
70 );
71
72 Self {
73 temperature,
74 top_p,
75 top_k,
76 frequency_penalty: 0.0,
77 presence_penalty: 0.0,
78 #[allow(clippy::cast_precision_loss)]
79 retrieval_top_k: config.memory.semantic.recall_limit as f64,
80 similarity_threshold: f64::from(config.memory.cross_session_score_threshold),
81 temporal_decay: f64::from(config.memory.semantic.temporal_decay_half_life_days),
82 }
83 }
84
85 #[must_use]
87 pub fn apply(&self, variation: &Variation) -> Self {
88 let mut snapshot = self.clone();
89 snapshot.set(variation.parameter, variation.value.as_f64());
90 snapshot
91 }
92
93 #[must_use]
98 pub fn diff(&self, other: &ConfigSnapshot) -> Option<Variation> {
99 let kinds = [
100 ParameterKind::Temperature,
101 ParameterKind::TopP,
102 ParameterKind::TopK,
103 ParameterKind::FrequencyPenalty,
104 ParameterKind::PresencePenalty,
105 ParameterKind::RetrievalTopK,
106 ParameterKind::SimilarityThreshold,
107 ParameterKind::TemporalDecay,
108 ];
109 let mut result = None;
110 for kind in kinds {
111 let a = self.get(kind);
112 let b = other.get(kind);
113 if (a - b).abs() > f64::EPSILON {
114 if result.is_some() {
115 return None; }
117 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
118 let value = if kind.is_integer() {
119 VariationValue::Int(b.round() as i64)
120 } else {
121 VariationValue::Float(OrderedFloat(b))
122 };
123 result = Some(Variation {
124 parameter: kind,
125 value,
126 });
127 }
128 }
129 result
130 }
131
132 #[must_use]
134 pub fn get(&self, kind: ParameterKind) -> f64 {
135 #[allow(unreachable_patterns)]
136 match kind {
137 ParameterKind::Temperature => self.temperature,
138 ParameterKind::TopP => self.top_p,
139 ParameterKind::TopK => self.top_k,
140 ParameterKind::FrequencyPenalty => self.frequency_penalty,
141 ParameterKind::PresencePenalty => self.presence_penalty,
142 ParameterKind::RetrievalTopK => self.retrieval_top_k,
143 ParameterKind::SimilarityThreshold => self.similarity_threshold,
144 ParameterKind::TemporalDecay => self.temporal_decay,
145 _ => 0.0,
146 }
147 }
148
149 pub fn set(&mut self, kind: ParameterKind, value: f64) {
151 #[allow(unreachable_patterns)]
152 match kind {
153 ParameterKind::Temperature => self.temperature = value,
154 ParameterKind::TopP => self.top_p = value,
155 ParameterKind::TopK => self.top_k = value,
156 ParameterKind::FrequencyPenalty => self.frequency_penalty = value,
157 ParameterKind::PresencePenalty => self.presence_penalty = value,
158 ParameterKind::RetrievalTopK => self.retrieval_top_k = value,
159 ParameterKind::SimilarityThreshold => self.similarity_threshold = value,
160 ParameterKind::TemporalDecay => self.temporal_decay = value,
161 _ => {}
162 }
163 }
164
165 #[must_use]
169 pub fn to_generation_overrides(&self) -> GenerationOverrides {
170 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
171 GenerationOverrides {
172 temperature: Some(self.temperature),
173 top_p: Some(self.top_p),
174 top_k: Some(self.top_k.round() as usize),
175 frequency_penalty: Some(self.frequency_penalty),
176 presence_penalty: Some(self.presence_penalty),
177 }
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 #![allow(
184 clippy::field_reassign_with_default,
185 clippy::semicolon_if_nothing_returned,
186 clippy::type_complexity
187 )]
188
189 use super::*;
190 use ordered_float::OrderedFloat;
191
192 #[test]
193 fn default_snapshot_fields() {
194 let s = ConfigSnapshot::default();
195 assert!((s.temperature - 0.7).abs() < f64::EPSILON);
196 assert!((s.top_p - 0.9).abs() < f64::EPSILON);
197 assert!((s.top_k - 40.0).abs() < f64::EPSILON);
198 assert!((s.frequency_penalty - 0.0).abs() < f64::EPSILON);
199 assert!((s.presence_penalty - 0.0).abs() < f64::EPSILON);
200 assert!((s.retrieval_top_k - 5.0).abs() < f64::EPSILON);
201 assert!((s.similarity_threshold - 0.35).abs() < 1e-6);
202 assert!((s.temporal_decay - 30.0).abs() < f64::EPSILON);
203 }
204
205 #[test]
206 fn apply_changes_single_param() {
207 let baseline = ConfigSnapshot::default();
208 let variation = Variation {
209 parameter: ParameterKind::Temperature,
210 value: VariationValue::Float(OrderedFloat(1.0)),
211 };
212 let applied = baseline.apply(&variation);
213 assert!((applied.temperature - 1.0).abs() < f64::EPSILON);
214 assert!((applied.top_p - 0.9).abs() < f64::EPSILON); }
216
217 #[test]
218 fn apply_with_int_value() {
219 let baseline = ConfigSnapshot::default();
220 let variation = Variation {
221 parameter: ParameterKind::TopK,
222 value: VariationValue::Int(50),
223 };
224 let applied = baseline.apply(&variation);
225 assert!((applied.top_k - 50.0).abs() < f64::EPSILON);
226 }
227
228 #[test]
229 fn diff_returns_single_changed_param() {
230 let a = ConfigSnapshot::default();
231 let mut b = ConfigSnapshot::default();
232 b.temperature = 1.0;
233 let variation = a.diff(&b);
234 assert!(variation.is_some());
235 let v = variation.unwrap();
236 assert_eq!(v.parameter, ParameterKind::Temperature);
237 assert!((v.value.as_f64() - 1.0).abs() < f64::EPSILON);
238 }
239
240 #[test]
241 fn diff_returns_none_for_identical_snapshots() {
242 let a = ConfigSnapshot::default();
243 let b = ConfigSnapshot::default();
244 assert!(a.diff(&b).is_none());
245 }
246
247 #[test]
248 fn diff_returns_none_for_multiple_changes() {
249 let a = ConfigSnapshot::default();
250 let mut b = ConfigSnapshot::default();
251 b.temperature = 1.0;
252 b.top_p = 0.5;
253 assert!(a.diff(&b).is_none());
254 }
255
256 #[test]
257 fn get_all_kinds() {
258 let s = ConfigSnapshot {
259 temperature: 0.1,
260 top_p: 0.2,
261 top_k: 3.0,
262 frequency_penalty: 0.4,
263 presence_penalty: 0.5,
264 retrieval_top_k: 6.0,
265 similarity_threshold: 0.7,
266 temporal_decay: 8.0,
267 };
268 assert!((s.get(ParameterKind::Temperature) - 0.1).abs() < f64::EPSILON);
269 assert!((s.get(ParameterKind::TopP) - 0.2).abs() < f64::EPSILON);
270 assert!((s.get(ParameterKind::TopK) - 3.0).abs() < f64::EPSILON);
271 assert!((s.get(ParameterKind::FrequencyPenalty) - 0.4).abs() < f64::EPSILON);
272 assert!((s.get(ParameterKind::PresencePenalty) - 0.5).abs() < f64::EPSILON);
273 assert!((s.get(ParameterKind::RetrievalTopK) - 6.0).abs() < f64::EPSILON);
274 assert!((s.get(ParameterKind::SimilarityThreshold) - 0.7).abs() < f64::EPSILON);
275 assert!((s.get(ParameterKind::TemporalDecay) - 8.0).abs() < f64::EPSILON);
276 }
277
278 #[test]
279 fn set_all_kinds() {
280 let mut s = ConfigSnapshot::default();
281 s.set(ParameterKind::Temperature, 1.1);
282 s.set(ParameterKind::TopP, 0.8);
283 s.set(ParameterKind::TopK, 20.0);
284 s.set(ParameterKind::FrequencyPenalty, -0.5);
285 s.set(ParameterKind::PresencePenalty, 0.3);
286 s.set(ParameterKind::RetrievalTopK, 10.0);
287 s.set(ParameterKind::SimilarityThreshold, 0.5);
288 s.set(ParameterKind::TemporalDecay, 60.0);
289 assert!((s.temperature - 1.1).abs() < f64::EPSILON);
290 assert!((s.top_p - 0.8).abs() < f64::EPSILON);
291 assert!((s.top_k - 20.0).abs() < f64::EPSILON);
292 assert!((s.frequency_penalty + 0.5).abs() < f64::EPSILON);
293 assert!((s.presence_penalty - 0.3).abs() < f64::EPSILON);
294 assert!((s.retrieval_top_k - 10.0).abs() < f64::EPSILON);
295 assert!((s.similarity_threshold - 0.5).abs() < f64::EPSILON);
296 assert!((s.temporal_decay - 60.0).abs() < f64::EPSILON);
297 }
298
299 #[test]
300 fn to_generation_overrides_rounds_top_k() {
301 let mut s = ConfigSnapshot::default();
302 s.top_k = 39.9;
304 let overrides = s.to_generation_overrides();
305 assert_eq!(overrides.top_k, Some(40));
306 }
307
308 #[test]
309 fn to_generation_overrides_contains_all_llm_fields() {
310 let s = ConfigSnapshot::default();
311 let overrides = s.to_generation_overrides();
312 assert!(overrides.temperature.is_some());
313 assert!(overrides.top_p.is_some());
314 assert!(overrides.top_k.is_some());
315 assert!(overrides.frequency_penalty.is_some());
316 assert!(overrides.presence_penalty.is_some());
317 }
318
319 #[test]
320 fn diff_integer_param_produces_int_value() {
321 let a = ConfigSnapshot::default();
322 let mut b = ConfigSnapshot::default();
323 b.top_k = 50.0;
324 let variation = a.diff(&b).expect("should have one diff");
325 assert_eq!(variation.parameter, ParameterKind::TopK);
326 assert!(
327 matches!(variation.value, VariationValue::Int(50)),
328 "expected Int(50), got {:?}",
329 variation.value
330 );
331 }
332
333 #[test]
334 fn diff_retrieval_top_k_produces_int_value() {
335 let a = ConfigSnapshot::default();
336 let mut b = ConfigSnapshot::default();
337 b.retrieval_top_k = 10.0;
338 let variation = a.diff(&b).expect("should have one diff");
339 assert_eq!(variation.parameter, ParameterKind::RetrievalTopK);
340 assert!(matches!(variation.value, VariationValue::Int(10)));
341 }
342
343 #[test]
344 fn diff_all_eight_kinds() {
345 let fields: &[(ParameterKind, fn(&mut ConfigSnapshot))] = &[
346 (ParameterKind::Temperature, |s| s.temperature = 1.5),
347 (ParameterKind::TopP, |s| s.top_p = 0.5),
348 (ParameterKind::TopK, |s| s.top_k = 20.0),
349 (ParameterKind::FrequencyPenalty, |s| {
350 s.frequency_penalty = 0.5;
351 }),
352 (ParameterKind::PresencePenalty, |s| s.presence_penalty = 0.5),
353 (ParameterKind::RetrievalTopK, |s| s.retrieval_top_k = 10.0),
354 (ParameterKind::SimilarityThreshold, |s| {
355 s.similarity_threshold = 0.8;
356 }),
357 (ParameterKind::TemporalDecay, |s| s.temporal_decay = 60.0),
358 ];
359 for (kind, mutate) in fields {
360 let a = ConfigSnapshot::default();
361 let mut b = ConfigSnapshot::default();
362 mutate(&mut b);
363 let v = a
364 .diff(&b)
365 .unwrap_or_else(|| panic!("expected diff for {kind:?}"));
366 assert_eq!(v.parameter, *kind);
367 }
368 }
369
370 #[test]
371 fn snapshot_serde_roundtrip() {
372 let s = ConfigSnapshot {
373 temperature: 1.2,
374 top_p: 0.85,
375 top_k: 50.0,
376 frequency_penalty: -0.1,
377 presence_penalty: 0.2,
378 retrieval_top_k: 7.0,
379 similarity_threshold: 0.4,
380 temporal_decay: 45.0,
381 };
382 let json = serde_json::to_string(&s).unwrap();
383 let s2: ConfigSnapshot = serde_json::from_str(&json).unwrap();
384 assert!((s2.temperature - s.temperature).abs() < f64::EPSILON);
385 assert!((s2.top_k - s.top_k).abs() < f64::EPSILON);
386 }
387}