1use serde::{Deserialize, Serialize};
28
29pub const RETUNE_CORPUS_MILESTONE: u64 = 50;
34
35pub const RETUNE_REGRET_RATE_THRESHOLD: f64 = 0.10;
38
39pub const REINDEX_TOKENS_PER_1K_MEMORIES: u64 = 2_000;
43
44pub const REGRET_PENALTY_WEIGHT: f64 = 0.5;
58
59pub const LEXICAL_FLOORS: &[f32] = &[0.3, 0.4, 0.5, 0.6];
62pub const SEMANTIC_FLOORS: &[f32] = &[-1.0, 0.0, 0.25, 0.35, 0.45];
63pub const RERANKER_IDS: &[&str] = &[
64 "off",
65 "ms-marco-tinybert-l-2-v2",
66 "jina-reranker-v1-tiny-en",
67 "ms-marco-minilm-l-4-v2",
68];
69
70pub const FUSION_MODES: &[&str] = &["linear", "rrf"];
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83pub struct TuneCombo {
84 pub min_lexical_coverage: f32,
85 pub min_semantic_score: f32,
86 pub reranker_id: String,
87 #[serde(default = "default_fusion_mode")]
91 pub fusion: String,
92}
93
94fn default_fusion_mode() -> String {
95 "linear".to_string()
96}
97
98impl TuneCombo {
99 pub fn all_combos() -> Vec<TuneCombo> {
100 let mut out = Vec::new();
101 for &lex in LEXICAL_FLOORS {
102 for &sem in SEMANTIC_FLOORS {
103 for &rr in RERANKER_IDS {
104 for &fusion in FUSION_MODES {
105 out.push(TuneCombo {
106 min_lexical_coverage: lex,
107 min_semantic_score: sem,
108 reranker_id: rr.to_string(),
109 fusion: fusion.to_string(),
110 });
111 }
112 }
113 }
114 }
115 out
116 }
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct ComboResult {
123 pub combo: TuneCombo,
124 pub mean_mrr: f64,
125 pub mean_tokens: f64,
126 pub objective: f64,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct TuneHistoryEntry {
134 pub timestamp: String,
135 pub before: TuneCombo,
136 pub after: TuneCombo,
137 pub train_objective: f64,
138 pub holdout_objective: f64,
139 pub holdout_mrr: f64,
140 pub baseline_holdout_objective: f64,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub memory_count_at_tune: Option<u64>,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct RetuneTriggerState {
152 pub current_memory_count: u64,
154 pub memory_count_at_last_tune: u64,
156 pub memories_added_since_tune: u64,
158 pub corpus_milestone_triggered: bool,
160 pub recent_regret_count: u64,
162 pub recent_served_count: u64,
164 pub regret_rate: f64,
166 pub drift_triggered: bool,
168 pub should_retune: bool,
170 pub last_tuned_at: Option<String>,
172}
173
174pub fn compute_retune_trigger(
181 conn: &rusqlite::Connection,
182 kimetsu_dir: &std::path::Path,
183) -> kimetsu_core::KimetsuResult<RetuneTriggerState> {
184 let current_memory_count: u64 = conn.query_row(
186 "SELECT COUNT(*) FROM memories WHERE invalidated_at IS NULL",
187 [],
188 |r| r.get(0),
189 )?;
190
191 let last_entry = latest_tune_history(kimetsu_dir)?;
193 let memory_count_at_last_tune = last_entry
194 .as_ref()
195 .and_then(|e| e.memory_count_at_tune)
196 .unwrap_or(0);
197 let last_tuned_at = last_entry.as_ref().map(|e| e.timestamp.clone());
198
199 let memories_added_since_tune = current_memory_count.saturating_sub(memory_count_at_last_tune);
200 let corpus_milestone_triggered = memories_added_since_tune >= RETUNE_CORPUS_MILESTONE;
201
202 let cutoff_secs = std::time::SystemTime::now()
204 .duration_since(std::time::UNIX_EPOCH)
205 .map(|d| d.as_secs())
206 .unwrap_or(0)
207 .saturating_sub(86_400);
208 let cutoff_iso = {
210 let dt = time::OffsetDateTime::from_unix_timestamp(cutoff_secs as i64)
211 .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
212 dt.format(&time::format_description::well_known::Rfc3339)
213 .unwrap_or_default()
214 };
215
216 let recent_regret_count: u64 = conn.query_row(
217 "SELECT COUNT(*) FROM events WHERE kind = 'retrieval.regret' AND ts >= ?1",
218 rusqlite::params![cutoff_iso],
219 |r| r.get(0),
220 )?;
221
222 let recent_served_count: u64 = conn.query_row(
223 "SELECT COUNT(*) FROM events WHERE kind = 'context.served' AND ts >= ?1",
224 rusqlite::params![cutoff_iso],
225 |r| r.get(0),
226 )?;
227
228 let regret_rate = if recent_served_count > 0 {
229 recent_regret_count as f64 / recent_served_count as f64
230 } else {
231 0.0
232 };
233 let drift_triggered = regret_rate >= RETUNE_REGRET_RATE_THRESHOLD;
234 let should_retune = corpus_milestone_triggered || drift_triggered;
235
236 Ok(RetuneTriggerState {
237 current_memory_count,
238 memory_count_at_last_tune,
239 memories_added_since_tune,
240 corpus_milestone_triggered,
241 recent_regret_count,
242 recent_served_count,
243 regret_rate,
244 drift_triggered,
245 should_retune,
246 last_tuned_at,
247 })
248}
249
250pub const KNOWN_EMBEDDER_MODELS: &[(&str, &str, u32)] = &[
256 (
258 "jina-embeddings-v2-base-code",
259 "Jina v2 Code (768d, default)",
260 280,
261 ),
262 ("bge-small-en-v1.5", "BGE-small (384d, lightweight)", 130),
263 ("nomic-embed-text-v1.5", "Nomic Embed v1.5 (768d)", 270),
264 ("all-minilm-l6-v2", "MiniLM L6 (384d, fast)", 90),
265];
266
267#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct ModelAdvisorReport {
270 pub recommend_grid_run: bool,
272 pub reason: String,
274 pub current_embedder: String,
276 pub memories_to_reindex: u64,
278 pub estimated_reindex_tokens: u64,
280 pub candidate_models: Vec<ModelCandidate>,
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct ModelCandidate {
287 pub model_id: String,
288 pub description: String,
289 pub approx_download_mib: u32,
290}
291
292pub fn compute_model_advisor(
300 current_embedder: &str,
301 trigger: &RetuneTriggerState,
302) -> ModelAdvisorReport {
303 let recommend_grid_run = trigger.corpus_milestone_triggered;
304 let reason = if trigger.corpus_milestone_triggered {
305 format!(
306 "Corpus grew by {} memories since last tune (≥{} threshold). \
307 Re-running the embedder×reranker grid is recommended to verify \
308 the current model remains optimal.",
309 trigger.memories_added_since_tune, RETUNE_CORPUS_MILESTONE,
310 )
311 } else {
312 format!(
313 "No corpus milestone triggered ({} memories added, threshold {}). \
314 Grid re-run is optional.",
315 trigger.memories_added_since_tune, RETUNE_CORPUS_MILESTONE,
316 )
317 };
318
319 let memories_to_reindex = trigger.current_memory_count;
320 let estimated_reindex_tokens =
321 (memories_to_reindex.max(1) / 1_000 + 1).saturating_mul(REINDEX_TOKENS_PER_1K_MEMORIES);
322
323 let candidate_models = KNOWN_EMBEDDER_MODELS
324 .iter()
325 .map(|(id, desc, mib)| ModelCandidate {
326 model_id: id.to_string(),
327 description: desc.to_string(),
328 approx_download_mib: *mib,
329 })
330 .collect();
331
332 ModelAdvisorReport {
333 recommend_grid_run,
334 reason,
335 current_embedder: current_embedder.to_string(),
336 memories_to_reindex,
337 estimated_reindex_tokens,
338 candidate_models,
339 }
340}
341
342pub fn compute_objective(mean_mrr: f64, mean_tokens: f64, cost_weight: f64) -> f64 {
348 mean_mrr - cost_weight * mean_tokens
349}
350
351pub fn compute_objective_with_regret(
367 mean_mrr: f64,
368 mean_tokens: f64,
369 cost_weight: f64,
370 regret_rate: f64,
371) -> f64 {
372 mean_mrr - cost_weight * mean_tokens - REGRET_PENALTY_WEIGHT * regret_rate
373}
374
375pub fn count_regret_events(
381 conn: &rusqlite::Connection,
382 since: Option<&str>,
383 until: Option<&str>,
384) -> kimetsu_core::KimetsuResult<u64> {
385 let count: u64 = match (since, until) {
386 (Some(lo), Some(hi)) => conn.query_row(
387 "SELECT COUNT(*) FROM events \
388 WHERE kind = 'retrieval.regret' AND ts >= ?1 AND ts <= ?2",
389 rusqlite::params![lo, hi],
390 |r| r.get(0),
391 )?,
392 (Some(lo), None) => conn.query_row(
393 "SELECT COUNT(*) FROM events \
394 WHERE kind = 'retrieval.regret' AND ts >= ?1",
395 rusqlite::params![lo],
396 |r| r.get(0),
397 )?,
398 (None, Some(hi)) => conn.query_row(
399 "SELECT COUNT(*) FROM events \
400 WHERE kind = 'retrieval.regret' AND ts <= ?1",
401 rusqlite::params![hi],
402 |r| r.get(0),
403 )?,
404 (None, None) => conn.query_row(
405 "SELECT COUNT(*) FROM events WHERE kind = 'retrieval.regret'",
406 [],
407 |r| r.get(0),
408 )?,
409 };
410 Ok(count)
411}
412
413pub fn train_holdout_split(case_count: usize) -> (Vec<usize>, Vec<usize>) {
419 if case_count == 0 {
420 return (Vec::new(), Vec::new());
421 }
422 let holdout_size = (case_count / 5).max(1); let holdout: Vec<usize> = (0..case_count).filter(|i| i % 5 == 0).collect();
425 let train: Vec<usize> = (0..case_count).filter(|i| i % 5 != 0).collect();
426 let _ = holdout_size; (train, holdout)
428}
429
430pub fn select_winner(results: &[ComboResult]) -> Option<&ComboResult> {
433 results.iter().max_by(|a, b| {
434 a.objective
435 .partial_cmp(&b.objective)
436 .unwrap_or(std::cmp::Ordering::Equal)
437 })
438}
439
440pub fn append_tune_history(
444 kimetsu_dir: &std::path::Path,
445 entry: TuneHistoryEntry,
446) -> kimetsu_core::KimetsuResult<()> {
447 let path = kimetsu_dir.join("tune-history.json");
448 let mut entries: Vec<TuneHistoryEntry> = if path.exists() {
449 let text = std::fs::read_to_string(&path)?;
450 serde_json::from_str(&text).unwrap_or_default()
451 } else {
452 Vec::new()
453 };
454 entries.push(entry);
455 let json = serde_json::to_string_pretty(&entries)?;
456 std::fs::write(&path, json)?;
457 Ok(())
458}
459
460pub fn latest_tune_history(
462 kimetsu_dir: &std::path::Path,
463) -> kimetsu_core::KimetsuResult<Option<TuneHistoryEntry>> {
464 let path = kimetsu_dir.join("tune-history.json");
465 if !path.exists() {
466 return Ok(None);
467 }
468 let text = std::fs::read_to_string(&path)?;
469 let entries: Vec<TuneHistoryEntry> = serde_json::from_str(&text).unwrap_or_default();
470 Ok(entries.into_iter().last())
471}
472
473#[cfg(test)]
476mod tests {
477 use super::*;
478 use ulid::Ulid;
479
480 #[test]
484 fn all_combos_covers_the_full_grid() {
485 let combos = TuneCombo::all_combos();
486 let expected =
487 LEXICAL_FLOORS.len() * SEMANTIC_FLOORS.len() * RERANKER_IDS.len() * FUSION_MODES.len();
488 assert_eq!(
489 combos.len(),
490 expected,
491 "expected {}×{}×{}×{}={expected} combos, got {}",
492 LEXICAL_FLOORS.len(),
493 SEMANTIC_FLOORS.len(),
494 RERANKER_IDS.len(),
495 FUSION_MODES.len(),
496 combos.len()
497 );
498
499 let mut keys: Vec<String> = combos
502 .iter()
503 .map(|c| {
504 format!(
505 "{}|{}|{}|{}",
506 c.min_lexical_coverage, c.min_semantic_score, c.reranker_id, c.fusion
507 )
508 })
509 .collect();
510 keys.sort();
511 let before = keys.len();
512 keys.dedup();
513 assert_eq!(before, keys.len(), "sweep grid contains duplicate combos");
514 }
515
516 #[test]
517 fn compute_objective_formula() {
518 let obj = compute_objective(0.75, 1000.0, 0.005);
519 assert!((obj - (-4.25)).abs() < 1e-9, "objective: {obj}");
521 }
522
523 #[test]
524 fn compute_objective_zero_cost_weight_is_just_mrr() {
525 let obj = compute_objective(0.85, 500.0, 0.0);
526 assert!((obj - 0.85).abs() < 1e-9, "objective with 0 cost: {obj}");
527 }
528
529 #[test]
530 fn train_holdout_split_80_20() {
531 let (train, holdout) = train_holdout_split(10);
532 assert_eq!(holdout, vec![0, 5]);
534 assert_eq!(train, vec![1, 2, 3, 4, 6, 7, 8, 9]);
535 assert_eq!(train.len() + holdout.len(), 10);
536 }
537
538 #[test]
539 fn train_holdout_split_empty() {
540 let (train, holdout) = train_holdout_split(0);
541 assert!(train.is_empty());
542 assert!(holdout.is_empty());
543 }
544
545 #[test]
546 fn select_winner_picks_highest_objective() {
547 let combos = vec![
548 ComboResult {
549 combo: TuneCombo {
550 min_lexical_coverage: 0.3,
551 min_semantic_score: 0.0,
552 reranker_id: "off".to_string(),
553 fusion: "linear".to_string(),
554 },
555 mean_mrr: 0.7,
556 mean_tokens: 100.0,
557 objective: 0.2,
558 },
559 ComboResult {
560 combo: TuneCombo {
561 min_lexical_coverage: 0.4,
562 min_semantic_score: 0.25,
563 reranker_id: "off".to_string(),
564 fusion: "linear".to_string(),
565 },
566 mean_mrr: 0.9,
567 mean_tokens: 80.0,
568 objective: 0.5,
569 },
570 ];
571 let winner = select_winner(&combos).expect("winner");
572 assert!((winner.objective - 0.5).abs() < 1e-9);
573 }
574
575 #[test]
576 fn tune_history_roundtrip() {
577 let tmp = std::env::temp_dir().join(format!("kimetsu-tune-hist-{}", Ulid::new()));
578 std::fs::create_dir_all(&tmp).unwrap();
579
580 let entry = TuneHistoryEntry {
581 timestamp: "2026-06-11T00:00:00Z".to_string(),
582 before: TuneCombo {
583 min_lexical_coverage: 0.5,
584 min_semantic_score: -1.0,
585 reranker_id: "off".to_string(),
586 fusion: "linear".to_string(),
587 },
588 after: TuneCombo {
589 min_lexical_coverage: 0.4,
590 min_semantic_score: 0.25,
591 reranker_id: "ms-marco-tinybert-l-2-v2".to_string(),
592 fusion: "linear".to_string(),
593 },
594 train_objective: 0.55,
595 holdout_objective: 0.50,
596 holdout_mrr: 0.70,
597 baseline_holdout_objective: 0.45,
598 memory_count_at_tune: None,
599 };
600
601 append_tune_history(&tmp, entry.clone()).unwrap();
602 let latest = latest_tune_history(&tmp).unwrap().unwrap();
603 assert!((latest.holdout_objective - 0.50).abs() < 1e-9);
604 assert_eq!(latest.after.reranker_id, "ms-marco-tinybert-l-2-v2");
605
606 std::fs::remove_dir_all(&tmp).ok();
607 }
608
609 #[test]
610 fn tune_history_empty_when_no_file() {
611 let tmp = std::env::temp_dir().join(format!("kimetsu-tune-empty-{}", Ulid::new()));
612 std::fs::create_dir_all(&tmp).unwrap();
613 let latest = latest_tune_history(&tmp).unwrap();
614 assert!(latest.is_none(), "no history file → None");
615 std::fs::remove_dir_all(&tmp).ok();
616 }
617
618 #[test]
621 fn compute_objective_with_regret_zero_rate_matches_base() {
622 let base = compute_objective(0.75, 500.0, 0.005);
623 let with_regret = compute_objective_with_regret(0.75, 500.0, 0.005, 0.0);
624 assert!(
625 (base - with_regret).abs() < 1e-9,
626 "zero regret_rate must give same result as base objective"
627 );
628 }
629
630 #[test]
631 fn compute_objective_with_regret_penalises_high_rate() {
632 let base = compute_objective(0.75, 500.0, 0.005);
633 let with_regret = compute_objective_with_regret(0.75, 500.0, 0.005, 0.10);
634 assert!(
636 with_regret < base,
637 "positive regret_rate must reduce the objective"
638 );
639 assert!(
640 (base - with_regret - REGRET_PENALTY_WEIGHT * 0.10).abs() < 1e-9,
641 "penalty term must equal REGRET_PENALTY_WEIGHT * regret_rate"
642 );
643 }
644
645 #[test]
646 fn compute_objective_with_regret_full_rate_shifts_by_weight() {
647 let base = compute_objective(0.8, 0.0, 0.0);
649 let with_full = compute_objective_with_regret(0.8, 0.0, 0.0, 1.0);
650 assert!(
651 (base - with_full - REGRET_PENALTY_WEIGHT).abs() < 1e-9,
652 "100% regret rate shifts objective by REGRET_PENALTY_WEIGHT"
653 );
654 }
655
656 use crate::{
659 project::{init_project, load_project},
660 projector,
661 user_brain::with_user_brain_disabled,
662 };
663 use kimetsu_core::{event::Event, ids::RunId};
664
665 fn trigger_test_root(label: &str) -> std::path::PathBuf {
666 let root =
667 std::env::temp_dir().join(format!("kimetsu-tune-trigger-{label}-{}", Ulid::new()));
668 kimetsu_core::paths::git_init_boundary(&root);
669 root
670 }
671
672 #[test]
673 fn retune_trigger_no_history_no_events() {
674 with_user_brain_disabled(|| {
675 let root = trigger_test_root("empty");
676 std::fs::create_dir_all(&root).expect("mkdir");
677 init_project(&root, false).expect("init");
678 let paths = kimetsu_core::paths::ProjectPaths::discover(&root).expect("paths");
679 let (_, _, conn) = load_project(&root).expect("load");
680 let state = compute_retune_trigger(&conn, &paths.kimetsu_dir).expect("trigger");
681 assert_eq!(state.current_memory_count, 0);
682 assert_eq!(state.memories_added_since_tune, 0);
683 assert!(!state.corpus_milestone_triggered);
684 assert!(!state.drift_triggered);
685 assert!(!state.should_retune);
686 assert!(state.last_tuned_at.is_none());
687 std::fs::remove_dir_all(&root).ok();
688 });
689 }
690
691 #[test]
692 fn retune_trigger_corpus_milestone_when_enough_memories() {
693 with_user_brain_disabled(|| {
694 let root = trigger_test_root("milestone");
695 std::fs::create_dir_all(&root).expect("mkdir");
696 init_project(&root, false).expect("init");
697 let paths = kimetsu_core::paths::ProjectPaths::discover(&root).expect("paths");
698
699 let entry = TuneHistoryEntry {
701 timestamp: "2026-01-01T00:00:00Z".to_string(),
702 before: TuneCombo {
703 min_lexical_coverage: 0.4,
704 min_semantic_score: 0.0,
705 reranker_id: "off".to_string(),
706 fusion: "linear".to_string(),
707 },
708 after: TuneCombo {
709 min_lexical_coverage: 0.4,
710 min_semantic_score: 0.0,
711 reranker_id: "off".to_string(),
712 fusion: "linear".to_string(),
713 },
714 train_objective: 0.5,
715 holdout_objective: 0.5,
716 holdout_mrr: 0.7,
717 baseline_holdout_objective: 0.45,
718 memory_count_at_tune: Some(0),
719 };
720 append_tune_history(&paths.kimetsu_dir, entry).expect("append");
721
722 for i in 0..RETUNE_CORPUS_MILESTONE {
724 crate::project::add_memory(
725 &root,
726 kimetsu_core::memory::MemoryScope::Project,
727 kimetsu_core::memory::MemoryKind::Fact,
728 &format!("milestone memory {i}"),
729 )
730 .expect("add memory");
731 }
732
733 let (_, _, conn) = load_project(&root).expect("load");
734 let state = compute_retune_trigger(&conn, &paths.kimetsu_dir).expect("trigger");
735 assert!(
736 state.corpus_milestone_triggered,
737 "milestone must trigger at ≥{RETUNE_CORPUS_MILESTONE} memories added"
738 );
739 assert!(state.should_retune);
740 std::fs::remove_dir_all(&root).ok();
741 });
742 }
743
744 #[test]
745 fn retune_trigger_drift_when_regret_rate_high() {
746 with_user_brain_disabled(|| {
747 let root = trigger_test_root("drift");
748 std::fs::create_dir_all(&root).expect("mkdir");
749 init_project(&root, false).expect("init");
750 let paths = kimetsu_core::paths::ProjectPaths::discover(&root).expect("paths");
751 let (_, _, conn) = load_project(&root).expect("load");
752
753 let run_id = RunId::new();
755 let served_ev = Event::new(
756 run_id,
757 "context.served",
758 serde_json::json!({"query_hash":"abc","capsule_count":1,"skipped":false}),
759 );
760 projector::apply_events(&conn, &[served_ev]).expect("seed served");
761 let regret_ev = Event::new(
762 run_id,
763 "retrieval.regret",
764 serde_json::json!({"memory_id":"m1","dropped_at":0,"cited_at":1}),
765 );
766 projector::apply_events(&conn, &[regret_ev]).expect("seed regret");
767
768 let state = compute_retune_trigger(&conn, &paths.kimetsu_dir).expect("trigger");
769 assert!(
770 state.drift_triggered,
771 "regret_rate ({:.2}) must exceed threshold ({RETUNE_REGRET_RATE_THRESHOLD})",
772 state.regret_rate
773 );
774 assert!(state.should_retune);
775 std::fs::remove_dir_all(&root).ok();
776 });
777 }
778
779 #[test]
782 fn model_advisor_recommends_at_milestone() {
783 let trigger = RetuneTriggerState {
784 current_memory_count: 100,
785 memory_count_at_last_tune: 10,
786 memories_added_since_tune: 90,
787 corpus_milestone_triggered: true,
788 recent_regret_count: 0,
789 recent_served_count: 20,
790 regret_rate: 0.0,
791 drift_triggered: false,
792 should_retune: true,
793 last_tuned_at: Some("2026-01-01T00:00:00Z".to_string()),
794 };
795 let report = compute_model_advisor("jina-embeddings-v2-base-code", &trigger);
796 assert!(report.recommend_grid_run, "must recommend at milestone");
797 assert!(report.estimated_reindex_tokens > 0, "cost must be stated");
798 assert!(!report.candidate_models.is_empty());
799 }
800
801 #[test]
802 fn model_advisor_no_recommendation_below_milestone() {
803 let trigger = RetuneTriggerState {
804 current_memory_count: 30,
805 memory_count_at_last_tune: 25,
806 memories_added_since_tune: 5,
807 corpus_milestone_triggered: false,
808 recent_regret_count: 0,
809 recent_served_count: 10,
810 regret_rate: 0.0,
811 drift_triggered: false,
812 should_retune: false,
813 last_tuned_at: None,
814 };
815 let report = compute_model_advisor("jina-embeddings-v2-base-code", &trigger);
816 assert!(
817 !report.recommend_grid_run,
818 "must NOT recommend below milestone"
819 );
820 }
821
822 #[test]
825 fn count_regret_events_zero_in_empty_db() {
826 with_user_brain_disabled(|| {
827 let root = trigger_test_root("regret-count");
828 std::fs::create_dir_all(&root).expect("mkdir");
829 init_project(&root, false).expect("init");
830 let (_, _, conn) = load_project(&root).expect("load");
831 let count = count_regret_events(&conn, None, None).expect("count");
832 assert_eq!(count, 0);
833 std::fs::remove_dir_all(&root).ok();
834 });
835 }
836
837 #[test]
838 fn tune_history_entry_memory_count_roundtrip() {
839 let tmp = std::env::temp_dir().join(format!("kimetsu-tune-memcount-{}", Ulid::new()));
840 std::fs::create_dir_all(&tmp).unwrap();
841
842 let entry = TuneHistoryEntry {
843 timestamp: "2026-06-11T00:00:00Z".to_string(),
844 before: TuneCombo {
845 min_lexical_coverage: 0.5,
846 min_semantic_score: -1.0,
847 reranker_id: "off".to_string(),
848 fusion: "linear".to_string(),
849 },
850 after: TuneCombo {
851 min_lexical_coverage: 0.4,
852 min_semantic_score: 0.25,
853 reranker_id: "off".to_string(),
854 fusion: "linear".to_string(),
855 },
856 train_objective: 0.55,
857 holdout_objective: 0.50,
858 holdout_mrr: 0.70,
859 baseline_holdout_objective: 0.45,
860 memory_count_at_tune: Some(123),
861 };
862
863 append_tune_history(&tmp, entry).unwrap();
864 let latest = latest_tune_history(&tmp).unwrap().unwrap();
865 assert_eq!(
866 latest.memory_count_at_tune,
867 Some(123),
868 "memory_count_at_tune must round-trip"
869 );
870
871 std::fs::remove_dir_all(&tmp).ok();
872 }
873}