1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use khive_fusion::FusionStrategy;
6use khive_runtime::RuntimeError;
7
8#[derive(Debug, Clone)]
10pub enum MinScoreError {
11 NotFinite,
13 OutOfRange(f64),
15}
16
17impl std::fmt::Display for MinScoreError {
18 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19 match self {
20 Self::NotFinite => write!(f, "min_score must be finite"),
21 Self::OutOfRange(v) => write!(
22 f,
23 "min_score {v} out of range: must be 0.0–1.0 (fraction) or 0–100 (percent)"
24 ),
25 }
26 }
27}
28
29impl From<MinScoreError> for RuntimeError {
30 fn from(e: MinScoreError) -> Self {
31 RuntimeError::InvalidInput(e.to_string())
32 }
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(default)]
39pub struct RecallConfig {
40 pub relevance_weight: f64,
43 pub salience_weight: f64,
45 pub temporal_weight: f64,
47
48 pub reranker_weights: HashMap<String, f64>,
52
53 pub temporal_half_life_days: f64,
56 pub decay_model: DecayModel,
58
59 pub candidate_multiplier: u32,
62 pub candidate_limit: Option<u32>,
65 pub fuse_strategy: FusionStrategy,
67 pub min_score: f64,
69 pub min_salience: f64,
71 pub include_breakdown: bool,
73
74 pub scoring: Option<crate::scoring::ScoringConfig>,
82
83 pub brain_profile: Option<BrainProfileHint>,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct BrainProfileHint {
117 pub profile_id: String,
119 #[serde(default = "BrainProfileHint::default_boost")]
121 pub boost: f64,
122 #[serde(default = "BrainProfileHint::default_threshold")]
126 pub threshold: f64,
127}
128
129impl BrainProfileHint {
130 fn default_boost() -> f64 {
131 1.3
132 }
133 fn default_threshold() -> f64 {
134 0.6
135 }
136}
137
138impl Default for RecallConfig {
152 fn default() -> Self {
153 Self {
154 relevance_weight: 0.70,
155 salience_weight: 0.20,
156 temporal_weight: 0.10,
157 reranker_weights: HashMap::new(),
158 temporal_half_life_days: 30.0,
159 decay_model: DecayModel::default(),
160 candidate_multiplier: 20,
161 candidate_limit: None,
162 fuse_strategy: FusionStrategy::Weighted {
168 weights: vec![0.7, 0.3],
169 },
170 min_score: 0.0,
171 min_salience: 0.0,
172 include_breakdown: false,
173 scoring: None,
174 brain_profile: None,
175 }
176 }
177}
178
179impl RecallConfig {
180 pub fn validate(&self) -> Result<(), RuntimeError> {
187 if self.relevance_weight < 0.0 {
188 return Err(RuntimeError::InvalidInput(
189 "relevance_weight must be non-negative".to_string(),
190 ));
191 }
192 if self.salience_weight < 0.0 {
193 return Err(RuntimeError::InvalidInput(
194 "salience_weight must be non-negative".to_string(),
195 ));
196 }
197 if self.temporal_weight < 0.0 {
198 return Err(RuntimeError::InvalidInput(
199 "temporal_weight must be non-negative".to_string(),
200 ));
201 }
202 let weight_sum = self.relevance_weight + self.salience_weight + self.temporal_weight;
203 if weight_sum <= 0.0 {
204 return Err(RuntimeError::InvalidInput(
205 "at least one of relevance_weight / salience_weight / temporal_weight must be positive".to_string(),
206 ));
207 }
208 for (name, &weight) in &self.reranker_weights {
209 if weight < 0.0 {
210 return Err(RuntimeError::InvalidInput(format!(
211 "reranker_weights[{name:?}] must be non-negative"
212 )));
213 }
214 }
215 if self.temporal_half_life_days <= 0.0 {
216 return Err(RuntimeError::InvalidInput(
217 "temporal_half_life_days must be positive".to_string(),
218 ));
219 }
220 if self.candidate_limit == Some(0) {
221 return Err(RuntimeError::InvalidInput(
222 "candidate_limit must be positive when provided".to_string(),
223 ));
224 }
225 if !self.min_score.is_finite() {
226 return Err(RuntimeError::InvalidInput(
227 "min_score must be finite".to_string(),
228 ));
229 }
230 if !self.min_salience.is_finite() {
231 return Err(RuntimeError::InvalidInput(
232 "min_salience must be finite".to_string(),
233 ));
234 }
235 Ok(())
236 }
237}
238
239#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
241#[serde(rename_all = "snake_case")]
242pub enum DecayModel {
243 #[default]
249 Exponential,
250 Hyperbolic,
252 PowerLaw {
254 half_life_days: f64,
257 },
258 None,
260}
261
262impl DecayModel {
263 pub fn apply(&self, salience: f64, age_days: f64, decay_factor: f64, _half_life: f64) -> f64 {
270 match self {
271 DecayModel::Exponential => {
272 salience * (-decay_factor * age_days).exp()
275 }
276 DecayModel::Hyperbolic => salience / (1.0 + decay_factor * age_days),
277 DecayModel::PowerLaw { half_life_days } => {
278 let hl = *half_life_days;
279 salience * hl / (hl + age_days)
280 }
281 DecayModel::None => salience,
282 }
283 }
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct ScoreBreakdown {
289 pub relevance: f64,
291 pub salience_raw: f64,
293 pub salience_decayed: f64,
295 pub temporal: f64,
297 pub weighted: WeightedContributions,
299}
300
301impl ScoreBreakdown {
302 pub fn total(&self) -> f64 {
304 self.weighted.relevance_contribution
305 + self.weighted.salience_contribution
306 + self.weighted.temporal_contribution
307 }
308}
309
310#[derive(Debug, Clone, Serialize, Deserialize)]
312pub struct WeightedContributions {
313 pub relevance_contribution: f64,
314 pub salience_contribution: f64,
315 pub temporal_contribution: f64,
316}
317
318#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
327 fn exponential_halves_at_decay_factor_half_life() {
328 let model = DecayModel::Exponential;
331 let salience = 1.0;
332 let decay_factor = 0.01;
333 let half_life_days = std::f64::consts::LN_2 / decay_factor;
334 let result = model.apply(salience, half_life_days, decay_factor, 30.0);
335 let diff = (result - 0.5).abs();
336 assert!(
337 diff < 1e-10,
338 "exponential should give 0.5 at ln(2)/decay_factor days, got {result}"
339 );
340 }
341
342 #[test]
343 fn exponential_full_salience_at_zero_age() {
344 let model = DecayModel::Exponential;
345 let result = model.apply(0.8, 0.0, 0.01, 30.0);
346 let diff = (result - 0.8).abs();
347 assert!(
348 diff < 1e-12,
349 "at age=0 salience should be unchanged, got {result}"
350 );
351 }
352
353 #[test]
354 fn exponential_uses_note_decay_factor_not_half_life() {
355 let model = DecayModel::Exponential;
359 let result = model.apply(1.0, 1.0, 1.0, 10.0);
360 let expected = (-1.0f64).exp();
361 assert!(
362 (result - expected).abs() < 1e-12,
363 "expected {expected}, got {result}"
364 );
365 }
366
367 #[test]
368 fn hyperbolic_halves_at_one_over_decay_factor() {
369 let model = DecayModel::Hyperbolic;
371 let salience = 1.0;
372 let k = 0.05;
373 let age = 1.0 / k; let result = model.apply(salience, age, k, 30.0);
375 let diff = (result - 0.5).abs();
376 assert!(
377 diff < 1e-10,
378 "hyperbolic at age=1/k should give 0.5, got {result}"
379 );
380 }
381
382 #[test]
383 fn hyperbolic_full_salience_at_zero_age() {
384 let model = DecayModel::Hyperbolic;
385 let result = model.apply(0.7, 0.0, 0.05, 30.0);
386 let diff = (result - 0.7).abs();
387 assert!(
388 diff < 1e-12,
389 "at age=0 salience should be unchanged, got {result}"
390 );
391 }
392
393 #[test]
394 fn powerlaw_halves_at_half_life() {
395 let hl = 30.0;
396 let model = DecayModel::PowerLaw { half_life_days: hl };
397 let salience = 1.0;
398 let result = model.apply(salience, hl, 0.01, hl);
400 let diff = (result - 0.5).abs();
401 assert!(
402 diff < 1e-10,
403 "power-law should give 0.5 at half-life, got {result}"
404 );
405 }
406
407 #[test]
408 fn decay_none_returns_salience_unchanged() {
409 let model = DecayModel::None;
410 let result = model.apply(0.6, 100.0, 0.99, 30.0);
411 let diff = (result - 0.6).abs();
412 assert!(
413 diff < 1e-12,
414 "None model must not alter salience, got {result}"
415 );
416 }
417
418 #[test]
421 fn default_config_validates() {
422 assert!(RecallConfig::default().validate().is_ok());
423 }
424
425 #[test]
426 fn negative_relevance_weight_fails_validation() {
427 let cfg = RecallConfig {
428 relevance_weight: -0.1,
429 ..RecallConfig::default()
430 };
431 assert!(cfg.validate().is_err());
432 }
433
434 #[test]
435 fn negative_salience_weight_fails_validation() {
436 let cfg = RecallConfig {
437 salience_weight: -1.0,
438 ..RecallConfig::default()
439 };
440 assert!(cfg.validate().is_err());
441 }
442
443 #[test]
444 fn negative_temporal_weight_fails_validation() {
445 let cfg = RecallConfig {
446 temporal_weight: -0.5,
447 ..RecallConfig::default()
448 };
449 assert!(cfg.validate().is_err());
450 }
451
452 #[test]
453 fn all_zero_weights_fails_validation() {
454 let cfg = RecallConfig {
455 relevance_weight: 0.0,
456 salience_weight: 0.0,
457 temporal_weight: 0.0,
458 ..RecallConfig::default()
459 };
460 assert!(cfg.validate().is_err());
461 }
462
463 #[test]
464 fn zero_half_life_fails_validation() {
465 let cfg = RecallConfig {
466 temporal_half_life_days: 0.0,
467 ..RecallConfig::default()
468 };
469 assert!(cfg.validate().is_err());
470 }
471
472 #[test]
473 fn negative_half_life_fails_validation() {
474 let cfg = RecallConfig {
475 temporal_half_life_days: -5.0,
476 ..RecallConfig::default()
477 };
478 assert!(cfg.validate().is_err());
479 }
480
481 #[test]
482 fn non_uniform_weights_validate() {
483 let cfg = RecallConfig {
484 relevance_weight: 0.5,
485 salience_weight: 0.3,
486 temporal_weight: 0.2,
487 ..RecallConfig::default()
488 };
489 assert!(cfg.validate().is_ok());
490 }
491
492 #[test]
495 fn default_config_roundtrip() {
496 let cfg = RecallConfig::default();
497 let json = serde_json::to_string(&cfg).expect("serialize");
498 let back: RecallConfig = serde_json::from_str(&json).expect("deserialize");
499 let diff = (cfg.relevance_weight - back.relevance_weight).abs();
500 assert!(diff < 1e-12);
501 assert_eq!(cfg.decay_model, back.decay_model);
502 }
503
504 #[test]
505 fn decay_model_exponential_roundtrip() {
506 let m = DecayModel::Exponential;
507 let json = serde_json::to_string(&m).expect("serialize");
508 let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
509 assert_eq!(m, back);
510 }
511
512 #[test]
513 fn decay_model_hyperbolic_roundtrip() {
514 let m = DecayModel::Hyperbolic;
515 let json = serde_json::to_string(&m).expect("serialize");
516 let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
517 assert_eq!(m, back);
518 }
519
520 #[test]
521 fn decay_model_powerlaw_roundtrip() {
522 let m = DecayModel::PowerLaw {
523 half_life_days: 14.0,
524 };
525 let json = serde_json::to_string(&m).expect("serialize");
526 let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
527 assert_eq!(m, back);
528 }
529
530 #[test]
531 fn decay_model_none_roundtrip() {
532 let m = DecayModel::None;
533 let json = serde_json::to_string(&m).expect("serialize");
534 let back: DecayModel = serde_json::from_str(&json).expect("deserialize");
535 assert_eq!(m, back);
536 }
537
538 #[test]
539 fn partial_config_deserializes_with_defaults() {
540 let json = r#"{"relevance_weight": 0.5}"#;
542 let cfg: RecallConfig = serde_json::from_str(json).expect("deserialize partial");
543 let diff = (cfg.relevance_weight - 0.5).abs();
545 assert!(diff < 1e-12);
546 let diff2 = (cfg.salience_weight - 0.20).abs();
548 assert!(diff2 < 1e-12);
549 assert_eq!(cfg.decay_model, DecayModel::Exponential);
550 }
551
552 #[test]
555 fn new_fields_have_correct_defaults() {
556 let cfg = RecallConfig::default();
557 assert_eq!(cfg.candidate_limit, None);
558 assert!(
560 matches!(
561 cfg.fuse_strategy,
562 FusionStrategy::Weighted { ref weights } if weights == &vec![0.7_f64, 0.3_f64]
563 ),
564 "default fuse_strategy should be Weighted [0.7, 0.3], got {:?}",
565 cfg.fuse_strategy
566 );
567 assert!(!cfg.include_breakdown);
568 }
569
570 #[test]
571 fn candidate_limit_zero_fails_validation() {
572 let cfg = RecallConfig {
573 candidate_limit: Some(0),
574 ..RecallConfig::default()
575 };
576 assert!(cfg.validate().is_err());
577 }
578
579 #[test]
580 fn candidate_limit_some_positive_validates() {
581 let cfg = RecallConfig {
582 candidate_limit: Some(100),
583 ..RecallConfig::default()
584 };
585 assert!(cfg.validate().is_ok());
586 }
587
588 #[test]
589 fn min_score_nan_fails_validation() {
590 let cfg = RecallConfig {
591 min_score: f64::NAN,
592 ..RecallConfig::default()
593 };
594 assert!(cfg.validate().is_err());
595 }
596
597 #[test]
598 fn min_salience_nan_fails_validation() {
599 let cfg = RecallConfig {
600 min_salience: f64::NAN,
601 ..RecallConfig::default()
602 };
603 assert!(cfg.validate().is_err());
604 }
605
606 #[test]
607 fn new_fields_roundtrip() {
608 let cfg = RecallConfig {
609 candidate_limit: Some(50),
610 fuse_strategy: FusionStrategy::Union,
611 include_breakdown: true,
612 ..RecallConfig::default()
613 };
614 let json = serde_json::to_string(&cfg).expect("serialize");
615 let back: RecallConfig = serde_json::from_str(&json).expect("deserialize");
616 assert_eq!(back.candidate_limit, Some(50));
617 assert_eq!(back.fuse_strategy, FusionStrategy::Union);
618 assert!(back.include_breakdown);
619 }
620
621 #[test]
622 fn partial_config_new_fields_use_defaults() {
623 let json = r#"{"temporal_weight": 0.15}"#;
625 let cfg: RecallConfig = serde_json::from_str(json).expect("deserialize partial");
626 assert_eq!(cfg.candidate_limit, None);
627 assert!(
629 matches!(cfg.fuse_strategy, FusionStrategy::Weighted { .. }),
630 "partial config must deserialize fuse_strategy to Weighted default"
631 );
632 assert!(!cfg.include_breakdown);
633 }
634
635 #[test]
638 fn score_breakdown_total_sums_contributions() {
639 let bd = ScoreBreakdown {
640 relevance: 0.5,
641 salience_raw: 0.8,
642 salience_decayed: 0.6,
643 temporal: 0.3,
644 weighted: WeightedContributions {
645 relevance_contribution: 0.35,
646 salience_contribution: 0.12,
647 temporal_contribution: 0.03,
648 },
649 };
650 let expected = 0.35 + 0.12 + 0.03;
651 let diff = (bd.total() - expected).abs();
652 assert!(
653 diff < 1e-12,
654 "total() should sum weighted contributions, got {}",
655 bd.total()
656 );
657 }
658}