1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use khive_runtime::{FusionStrategy, RuntimeError};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
10#[serde(default)]
11pub struct RecallConfig {
12 pub relevance_weight: f64,
15 pub importance_weight: f64,
17 pub temporal_weight: f64,
19
20 pub reranker_weights: HashMap<String, f64>,
24 pub reranker_params: HashMap<String, serde_json::Value>,
26
27 pub temporal_half_life_days: f64,
30 pub decay_model: DecayModel,
32
33 pub candidate_multiplier: u32,
36 pub candidate_limit: Option<u32>,
39 pub fuse_strategy: FusionStrategy,
41 pub min_score: f64,
43 pub min_salience: f64,
45 pub include_breakdown: bool,
47
48 pub fallback_during_migration: bool,
52}
53
54impl Default for RecallConfig {
55 fn default() -> Self {
56 Self {
57 relevance_weight: 0.70,
58 importance_weight: 0.20,
59 temporal_weight: 0.10,
60 reranker_weights: HashMap::new(),
61 reranker_params: HashMap::new(),
62 temporal_half_life_days: 30.0,
63 decay_model: DecayModel::default(),
64 candidate_multiplier: 20,
65 candidate_limit: None,
66 fuse_strategy: FusionStrategy::default(),
67 min_score: 0.0,
68 min_salience: 0.0,
69 include_breakdown: false,
70 fallback_during_migration: true,
71 }
72 }
73}
74
75impl RecallConfig {
76 pub fn validate(&self) -> Result<(), RuntimeError> {
83 if self.relevance_weight < 0.0 {
84 return Err(RuntimeError::InvalidInput(
85 "relevance_weight must be non-negative".to_string(),
86 ));
87 }
88 if self.importance_weight < 0.0 {
89 return Err(RuntimeError::InvalidInput(
90 "importance_weight must be non-negative".to_string(),
91 ));
92 }
93 if self.temporal_weight < 0.0 {
94 return Err(RuntimeError::InvalidInput(
95 "temporal_weight must be non-negative".to_string(),
96 ));
97 }
98 let weight_sum = self.relevance_weight + self.importance_weight + self.temporal_weight;
99 if weight_sum <= 0.0 {
100 return Err(RuntimeError::InvalidInput(
101 "at least one of relevance_weight / importance_weight / temporal_weight must be positive".to_string(),
102 ));
103 }
104 for (name, &weight) in &self.reranker_weights {
105 if weight < 0.0 {
106 return Err(RuntimeError::InvalidInput(format!(
107 "reranker_weights[{name:?}] must be non-negative"
108 )));
109 }
110 }
111 if self.temporal_half_life_days <= 0.0 {
112 return Err(RuntimeError::InvalidInput(
113 "temporal_half_life_days must be positive".to_string(),
114 ));
115 }
116 if self.candidate_limit == Some(0) {
117 return Err(RuntimeError::InvalidInput(
118 "candidate_limit must be positive when provided".to_string(),
119 ));
120 }
121 if !self.min_score.is_finite() {
122 return Err(RuntimeError::InvalidInput(
123 "min_score must be finite".to_string(),
124 ));
125 }
126 if !self.min_salience.is_finite() {
127 return Err(RuntimeError::InvalidInput(
128 "min_salience must be finite".to_string(),
129 ));
130 }
131 Ok(())
132 }
133}
134
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
137#[serde(rename_all = "snake_case")]
138pub enum DecayModel {
139 #[default]
145 Exponential,
146 Hyperbolic,
148 PowerLaw {
150 half_life_days: f64,
153 },
154 None,
156}
157
158impl DecayModel {
159 pub fn apply(&self, salience: f64, age_days: f64, decay_factor: f64, _half_life: f64) -> f64 {
166 match self {
167 DecayModel::Exponential => {
168 salience * (-decay_factor * age_days).exp()
171 }
172 DecayModel::Hyperbolic => salience / (1.0 + decay_factor * age_days),
173 DecayModel::PowerLaw { half_life_days } => {
174 let hl = *half_life_days;
175 salience * hl / (hl + age_days)
176 }
177 DecayModel::None => salience,
178 }
179 }
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct ScoreBreakdown {
185 pub relevance: f64,
187 pub importance_raw: f64,
189 pub importance_decayed: f64,
191 pub temporal: f64,
193 pub weighted: WeightedContributions,
195}
196
197impl ScoreBreakdown {
198 pub fn total(&self) -> f64 {
200 self.weighted.relevance_contribution
201 + self.weighted.importance_contribution
202 + self.weighted.temporal_contribution
203 }
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct WeightedContributions {
209 pub relevance_contribution: f64,
210 pub importance_contribution: f64,
211 pub temporal_contribution: f64,
212}
213
214#[cfg(test)]
217mod tests {
218 use super::*;
219
220 #[test]
223 fn exponential_halves_at_decay_factor_half_life() {
224 let model = DecayModel::Exponential;
227 let salience = 1.0;
228 let decay_factor = 0.01;
229 let half_life_days = std::f64::consts::LN_2 / decay_factor;
230 let result = model.apply(salience, half_life_days, decay_factor, 30.0);
231 let diff = (result - 0.5).abs();
232 assert!(
233 diff < 1e-10,
234 "exponential should give 0.5 at ln(2)/decay_factor days, got {result}"
235 );
236 }
237
238 #[test]
239 fn exponential_full_salience_at_zero_age() {
240 let model = DecayModel::Exponential;
241 let result = model.apply(0.8, 0.0, 0.01, 30.0);
242 let diff = (result - 0.8).abs();
243 assert!(
244 diff < 1e-12,
245 "at age=0 salience should be unchanged, got {result}"
246 );
247 }
248
249 #[test]
250 fn exponential_uses_note_decay_factor_not_half_life() {
251 let model = DecayModel::Exponential;
255 let result = model.apply(1.0, 1.0, 1.0, 10.0);
256 let expected = (-1.0f64).exp();
257 assert!(
258 (result - expected).abs() < 1e-12,
259 "expected {expected}, got {result}"
260 );
261 }
262
263 #[test]
264 fn hyperbolic_halves_at_one_over_decay_factor() {
265 let model = DecayModel::Hyperbolic;
267 let salience = 1.0;
268 let k = 0.05;
269 let age = 1.0 / k; let result = model.apply(salience, age, k, 30.0);
271 let diff = (result - 0.5).abs();
272 assert!(
273 diff < 1e-10,
274 "hyperbolic at age=1/k should give 0.5, got {result}"
275 );
276 }
277
278 #[test]
279 fn hyperbolic_full_salience_at_zero_age() {
280 let model = DecayModel::Hyperbolic;
281 let result = model.apply(0.7, 0.0, 0.05, 30.0);
282 let diff = (result - 0.7).abs();
283 assert!(
284 diff < 1e-12,
285 "at age=0 salience should be unchanged, got {result}"
286 );
287 }
288
289 #[test]
290 fn powerlaw_halves_at_half_life() {
291 let hl = 30.0;
292 let model = DecayModel::PowerLaw { half_life_days: hl };
293 let salience = 1.0;
294 let result = model.apply(salience, hl, 0.01, hl);
296 let diff = (result - 0.5).abs();
297 assert!(
298 diff < 1e-10,
299 "power-law should give 0.5 at half-life, got {result}"
300 );
301 }
302
303 #[test]
304 fn decay_none_returns_salience_unchanged() {
305 let model = DecayModel::None;
306 let result = model.apply(0.6, 100.0, 0.99, 30.0);
307 let diff = (result - 0.6).abs();
308 assert!(
309 diff < 1e-12,
310 "None model must not alter salience, got {result}"
311 );
312 }
313
314 #[test]
317 fn default_config_validates() {
318 assert!(RecallConfig::default().validate().is_ok());
319 }
320
321 #[test]
322 fn negative_relevance_weight_fails_validation() {
323 let cfg = RecallConfig {
324 relevance_weight: -0.1,
325 ..RecallConfig::default()
326 };
327 assert!(cfg.validate().is_err());
328 }
329
330 #[test]
331 fn negative_importance_weight_fails_validation() {
332 let cfg = RecallConfig {
333 importance_weight: -1.0,
334 ..RecallConfig::default()
335 };
336 assert!(cfg.validate().is_err());
337 }
338
339 #[test]
340 fn negative_temporal_weight_fails_validation() {
341 let cfg = RecallConfig {
342 temporal_weight: -0.5,
343 ..RecallConfig::default()
344 };
345 assert!(cfg.validate().is_err());
346 }
347
348 #[test]
349 fn all_zero_weights_fails_validation() {
350 let cfg = RecallConfig {
351 relevance_weight: 0.0,
352 importance_weight: 0.0,
353 temporal_weight: 0.0,
354 ..RecallConfig::default()
355 };
356 assert!(cfg.validate().is_err());
357 }
358
359 #[test]
360 fn zero_half_life_fails_validation() {
361 let cfg = RecallConfig {
362 temporal_half_life_days: 0.0,
363 ..RecallConfig::default()
364 };
365 assert!(cfg.validate().is_err());
366 }
367
368 #[test]
369 fn negative_half_life_fails_validation() {
370 let cfg = RecallConfig {
371 temporal_half_life_days: -5.0,
372 ..RecallConfig::default()
373 };
374 assert!(cfg.validate().is_err());
375 }
376
377 #[test]
378 fn non_uniform_weights_validate() {
379 let cfg = RecallConfig {
380 relevance_weight: 0.5,
381 importance_weight: 0.3,
382 temporal_weight: 0.2,
383 ..RecallConfig::default()
384 };
385 assert!(cfg.validate().is_ok());
386 }
387
388 #[test]
391 fn default_config_roundtrip() {
392 let cfg = RecallConfig::default();
393 let json = serde_json::to_string(&cfg).expect("serialize");
394 let back: RecallConfig = serde_json::from_str(&json).expect("deserialize");
395 let diff = (cfg.relevance_weight - back.relevance_weight).abs();
396 assert!(diff < 1e-12);
397 assert_eq!(cfg.decay_model, back.decay_model);
398 }
399
400 #[test]
401 fn decay_model_exponential_roundtrip() {
402 let m = DecayModel::Exponential;
403 let json = serde_json::to_string(&m).expect("serialize");
404 let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
405 assert_eq!(m, back);
406 }
407
408 #[test]
409 fn decay_model_hyperbolic_roundtrip() {
410 let m = DecayModel::Hyperbolic;
411 let json = serde_json::to_string(&m).expect("serialize");
412 let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
413 assert_eq!(m, back);
414 }
415
416 #[test]
417 fn decay_model_powerlaw_roundtrip() {
418 let m = DecayModel::PowerLaw {
419 half_life_days: 14.0,
420 };
421 let json = serde_json::to_string(&m).expect("serialize");
422 let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
423 assert_eq!(m, back);
424 }
425
426 #[test]
427 fn decay_model_none_roundtrip() {
428 let m = DecayModel::None;
429 let json = serde_json::to_string(&m).expect("serialize");
430 let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
431 assert_eq!(m, back);
432 }
433
434 #[test]
435 fn partial_config_deserializes_with_defaults() {
436 let json = r#"{"relevance_weight": 0.5}"#;
438 let cfg: RecallConfig = serde_json::from_str(json).expect("deserialize partial");
439 let diff = (cfg.relevance_weight - 0.5).abs();
441 assert!(diff < 1e-12);
442 let diff2 = (cfg.importance_weight - 0.20).abs();
444 assert!(diff2 < 1e-12);
445 assert_eq!(cfg.decay_model, DecayModel::Exponential);
446 }
447
448 #[test]
451 fn new_fields_have_correct_defaults() {
452 let cfg = RecallConfig::default();
453 assert_eq!(cfg.candidate_limit, None);
454 assert_eq!(cfg.fuse_strategy, FusionStrategy::Rrf { k: 60 });
455 assert!(!cfg.include_breakdown);
456 }
457
458 #[test]
459 fn candidate_limit_zero_fails_validation() {
460 let cfg = RecallConfig {
461 candidate_limit: Some(0),
462 ..RecallConfig::default()
463 };
464 assert!(cfg.validate().is_err());
465 }
466
467 #[test]
468 fn candidate_limit_some_positive_validates() {
469 let cfg = RecallConfig {
470 candidate_limit: Some(100),
471 ..RecallConfig::default()
472 };
473 assert!(cfg.validate().is_ok());
474 }
475
476 #[test]
477 fn min_score_nan_fails_validation() {
478 let cfg = RecallConfig {
479 min_score: f64::NAN,
480 ..RecallConfig::default()
481 };
482 assert!(cfg.validate().is_err());
483 }
484
485 #[test]
486 fn min_salience_nan_fails_validation() {
487 let cfg = RecallConfig {
488 min_salience: f64::NAN,
489 ..RecallConfig::default()
490 };
491 assert!(cfg.validate().is_err());
492 }
493
494 #[test]
495 fn new_fields_roundtrip() {
496 let cfg = RecallConfig {
497 candidate_limit: Some(50),
498 fuse_strategy: FusionStrategy::Union,
499 include_breakdown: true,
500 ..RecallConfig::default()
501 };
502 let json = serde_json::to_string(&cfg).expect("serialize");
503 let back: RecallConfig = serde_json::from_str(&json).expect("deserialize");
504 assert_eq!(back.candidate_limit, Some(50));
505 assert_eq!(back.fuse_strategy, FusionStrategy::Union);
506 assert!(back.include_breakdown);
507 }
508
509 #[test]
510 fn partial_config_new_fields_use_defaults() {
511 let json = r#"{"temporal_weight": 0.15}"#;
513 let cfg: RecallConfig = serde_json::from_str(json).expect("deserialize partial");
514 assert_eq!(cfg.candidate_limit, None);
515 assert_eq!(cfg.fuse_strategy, FusionStrategy::Rrf { k: 60 });
516 assert!(!cfg.include_breakdown);
517 }
518
519 #[test]
522 fn score_breakdown_total_sums_contributions() {
523 let bd = ScoreBreakdown {
524 relevance: 0.5,
525 importance_raw: 0.8,
526 importance_decayed: 0.6,
527 temporal: 0.3,
528 weighted: WeightedContributions {
529 relevance_contribution: 0.35,
530 importance_contribution: 0.12,
531 temporal_contribution: 0.03,
532 },
533 };
534 let expected = 0.35 + 0.12 + 0.03;
535 let diff = (bd.total() - expected).abs();
536 assert!(
537 diff < 1e-12,
538 "total() should sum weighted contributions, got {}",
539 bd.total()
540 );
541 }
542}