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
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73pub struct TuneCombo {
74 pub min_lexical_coverage: f32,
75 pub min_semantic_score: f32,
76 pub reranker_id: String,
77}
78
79impl TuneCombo {
80 pub fn all_combos() -> Vec<TuneCombo> {
81 let mut out = Vec::new();
82 for &lex in LEXICAL_FLOORS {
83 for &sem in SEMANTIC_FLOORS {
84 for &rr in RERANKER_IDS {
85 out.push(TuneCombo {
86 min_lexical_coverage: lex,
87 min_semantic_score: sem,
88 reranker_id: rr.to_string(),
89 });
90 }
91 }
92 }
93 out
94 }
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct ComboResult {
101 pub combo: TuneCombo,
102 pub mean_mrr: f64,
103 pub mean_tokens: f64,
104 pub objective: f64,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct TuneHistoryEntry {
112 pub timestamp: String,
113 pub before: TuneCombo,
114 pub after: TuneCombo,
115 pub train_objective: f64,
116 pub holdout_objective: f64,
117 pub holdout_mrr: f64,
118 pub baseline_holdout_objective: f64,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub memory_count_at_tune: Option<u64>,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct RetuneTriggerState {
130 pub current_memory_count: u64,
132 pub memory_count_at_last_tune: u64,
134 pub memories_added_since_tune: u64,
136 pub corpus_milestone_triggered: bool,
138 pub recent_regret_count: u64,
140 pub recent_served_count: u64,
142 pub regret_rate: f64,
144 pub drift_triggered: bool,
146 pub should_retune: bool,
148 pub last_tuned_at: Option<String>,
150}
151
152pub fn compute_retune_trigger(
159 conn: &rusqlite::Connection,
160 kimetsu_dir: &std::path::Path,
161) -> kimetsu_core::KimetsuResult<RetuneTriggerState> {
162 let current_memory_count: u64 = conn.query_row(
164 "SELECT COUNT(*) FROM memories WHERE invalidated_at IS NULL",
165 [],
166 |r| r.get(0),
167 )?;
168
169 let last_entry = latest_tune_history(kimetsu_dir)?;
171 let memory_count_at_last_tune = last_entry
172 .as_ref()
173 .and_then(|e| e.memory_count_at_tune)
174 .unwrap_or(0);
175 let last_tuned_at = last_entry.as_ref().map(|e| e.timestamp.clone());
176
177 let memories_added_since_tune = current_memory_count.saturating_sub(memory_count_at_last_tune);
178 let corpus_milestone_triggered = memories_added_since_tune >= RETUNE_CORPUS_MILESTONE;
179
180 let cutoff_secs = std::time::SystemTime::now()
182 .duration_since(std::time::UNIX_EPOCH)
183 .map(|d| d.as_secs())
184 .unwrap_or(0)
185 .saturating_sub(86_400);
186 let cutoff_iso = {
188 let dt = time::OffsetDateTime::from_unix_timestamp(cutoff_secs as i64)
189 .unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
190 dt.format(&time::format_description::well_known::Rfc3339)
191 .unwrap_or_default()
192 };
193
194 let recent_regret_count: u64 = conn.query_row(
195 "SELECT COUNT(*) FROM events WHERE kind = 'retrieval.regret' AND ts >= ?1",
196 rusqlite::params![cutoff_iso],
197 |r| r.get(0),
198 )?;
199
200 let recent_served_count: u64 = conn.query_row(
201 "SELECT COUNT(*) FROM events WHERE kind = 'context.served' AND ts >= ?1",
202 rusqlite::params![cutoff_iso],
203 |r| r.get(0),
204 )?;
205
206 let regret_rate = if recent_served_count > 0 {
207 recent_regret_count as f64 / recent_served_count as f64
208 } else {
209 0.0
210 };
211 let drift_triggered = regret_rate >= RETUNE_REGRET_RATE_THRESHOLD;
212 let should_retune = corpus_milestone_triggered || drift_triggered;
213
214 Ok(RetuneTriggerState {
215 current_memory_count,
216 memory_count_at_last_tune,
217 memories_added_since_tune,
218 corpus_milestone_triggered,
219 recent_regret_count,
220 recent_served_count,
221 regret_rate,
222 drift_triggered,
223 should_retune,
224 last_tuned_at,
225 })
226}
227
228pub const KNOWN_EMBEDDER_MODELS: &[(&str, &str, u32)] = &[
234 (
236 "jina-embeddings-v2-base-code",
237 "Jina v2 Code (768d, default)",
238 280,
239 ),
240 ("bge-small-en-v1.5", "BGE-small (384d, lightweight)", 130),
241 ("nomic-embed-text-v1.5", "Nomic Embed v1.5 (768d)", 270),
242 ("all-minilm-l6-v2", "MiniLM L6 (384d, fast)", 90),
243];
244
245#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct ModelAdvisorReport {
248 pub recommend_grid_run: bool,
250 pub reason: String,
252 pub current_embedder: String,
254 pub memories_to_reindex: u64,
256 pub estimated_reindex_tokens: u64,
258 pub candidate_models: Vec<ModelCandidate>,
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct ModelCandidate {
265 pub model_id: String,
266 pub description: String,
267 pub approx_download_mib: u32,
268}
269
270pub fn compute_model_advisor(
278 current_embedder: &str,
279 trigger: &RetuneTriggerState,
280) -> ModelAdvisorReport {
281 let recommend_grid_run = trigger.corpus_milestone_triggered;
282 let reason = if trigger.corpus_milestone_triggered {
283 format!(
284 "Corpus grew by {} memories since last tune (≥{} threshold). \
285 Re-running the embedder×reranker grid is recommended to verify \
286 the current model remains optimal.",
287 trigger.memories_added_since_tune, RETUNE_CORPUS_MILESTONE,
288 )
289 } else {
290 format!(
291 "No corpus milestone triggered ({} memories added, threshold {}). \
292 Grid re-run is optional.",
293 trigger.memories_added_since_tune, RETUNE_CORPUS_MILESTONE,
294 )
295 };
296
297 let memories_to_reindex = trigger.current_memory_count;
298 let estimated_reindex_tokens =
299 (memories_to_reindex.max(1) / 1_000 + 1).saturating_mul(REINDEX_TOKENS_PER_1K_MEMORIES);
300
301 let candidate_models = KNOWN_EMBEDDER_MODELS
302 .iter()
303 .map(|(id, desc, mib)| ModelCandidate {
304 model_id: id.to_string(),
305 description: desc.to_string(),
306 approx_download_mib: *mib,
307 })
308 .collect();
309
310 ModelAdvisorReport {
311 recommend_grid_run,
312 reason,
313 current_embedder: current_embedder.to_string(),
314 memories_to_reindex,
315 estimated_reindex_tokens,
316 candidate_models,
317 }
318}
319
320pub fn compute_objective(mean_mrr: f64, mean_tokens: f64, cost_weight: f64) -> f64 {
326 mean_mrr - cost_weight * mean_tokens
327}
328
329pub fn compute_objective_with_regret(
345 mean_mrr: f64,
346 mean_tokens: f64,
347 cost_weight: f64,
348 regret_rate: f64,
349) -> f64 {
350 mean_mrr - cost_weight * mean_tokens - REGRET_PENALTY_WEIGHT * regret_rate
351}
352
353pub fn count_regret_events(
359 conn: &rusqlite::Connection,
360 since: Option<&str>,
361 until: Option<&str>,
362) -> kimetsu_core::KimetsuResult<u64> {
363 let count: u64 = match (since, until) {
364 (Some(lo), Some(hi)) => conn.query_row(
365 "SELECT COUNT(*) FROM events \
366 WHERE kind = 'retrieval.regret' AND ts >= ?1 AND ts <= ?2",
367 rusqlite::params![lo, hi],
368 |r| r.get(0),
369 )?,
370 (Some(lo), None) => conn.query_row(
371 "SELECT COUNT(*) FROM events \
372 WHERE kind = 'retrieval.regret' AND ts >= ?1",
373 rusqlite::params![lo],
374 |r| r.get(0),
375 )?,
376 (None, Some(hi)) => conn.query_row(
377 "SELECT COUNT(*) FROM events \
378 WHERE kind = 'retrieval.regret' AND ts <= ?1",
379 rusqlite::params![hi],
380 |r| r.get(0),
381 )?,
382 (None, None) => conn.query_row(
383 "SELECT COUNT(*) FROM events WHERE kind = 'retrieval.regret'",
384 [],
385 |r| r.get(0),
386 )?,
387 };
388 Ok(count)
389}
390
391pub fn train_holdout_split(case_count: usize) -> (Vec<usize>, Vec<usize>) {
397 if case_count == 0 {
398 return (Vec::new(), Vec::new());
399 }
400 let holdout_size = (case_count / 5).max(1); let holdout: Vec<usize> = (0..case_count).filter(|i| i % 5 == 0).collect();
403 let train: Vec<usize> = (0..case_count).filter(|i| i % 5 != 0).collect();
404 let _ = holdout_size; (train, holdout)
406}
407
408pub fn select_winner(results: &[ComboResult]) -> Option<&ComboResult> {
411 results.iter().max_by(|a, b| {
412 a.objective
413 .partial_cmp(&b.objective)
414 .unwrap_or(std::cmp::Ordering::Equal)
415 })
416}
417
418pub fn append_tune_history(
422 kimetsu_dir: &std::path::Path,
423 entry: TuneHistoryEntry,
424) -> kimetsu_core::KimetsuResult<()> {
425 let path = kimetsu_dir.join("tune-history.json");
426 let mut entries: Vec<TuneHistoryEntry> = if path.exists() {
427 let text = std::fs::read_to_string(&path)?;
428 serde_json::from_str(&text).unwrap_or_default()
429 } else {
430 Vec::new()
431 };
432 entries.push(entry);
433 let json = serde_json::to_string_pretty(&entries)?;
434 std::fs::write(&path, json)?;
435 Ok(())
436}
437
438pub fn latest_tune_history(
440 kimetsu_dir: &std::path::Path,
441) -> kimetsu_core::KimetsuResult<Option<TuneHistoryEntry>> {
442 let path = kimetsu_dir.join("tune-history.json");
443 if !path.exists() {
444 return Ok(None);
445 }
446 let text = std::fs::read_to_string(&path)?;
447 let entries: Vec<TuneHistoryEntry> = serde_json::from_str(&text).unwrap_or_default();
448 Ok(entries.into_iter().last())
449}
450
451#[cfg(test)]
454mod tests {
455 use super::*;
456 use ulid::Ulid;
457
458 #[test]
459 fn all_combos_count_is_80() {
460 let combos = TuneCombo::all_combos();
461 assert_eq!(
462 combos.len(),
463 4 * 5 * 4,
464 "expected 4×5×4=80 combos, got {}",
465 combos.len()
466 );
467 }
468
469 #[test]
470 fn compute_objective_formula() {
471 let obj = compute_objective(0.75, 1000.0, 0.005);
472 assert!((obj - (-4.25)).abs() < 1e-9, "objective: {obj}");
474 }
475
476 #[test]
477 fn compute_objective_zero_cost_weight_is_just_mrr() {
478 let obj = compute_objective(0.85, 500.0, 0.0);
479 assert!((obj - 0.85).abs() < 1e-9, "objective with 0 cost: {obj}");
480 }
481
482 #[test]
483 fn train_holdout_split_80_20() {
484 let (train, holdout) = train_holdout_split(10);
485 assert_eq!(holdout, vec![0, 5]);
487 assert_eq!(train, vec![1, 2, 3, 4, 6, 7, 8, 9]);
488 assert_eq!(train.len() + holdout.len(), 10);
489 }
490
491 #[test]
492 fn train_holdout_split_empty() {
493 let (train, holdout) = train_holdout_split(0);
494 assert!(train.is_empty());
495 assert!(holdout.is_empty());
496 }
497
498 #[test]
499 fn select_winner_picks_highest_objective() {
500 let combos = vec![
501 ComboResult {
502 combo: TuneCombo {
503 min_lexical_coverage: 0.3,
504 min_semantic_score: 0.0,
505 reranker_id: "off".to_string(),
506 },
507 mean_mrr: 0.7,
508 mean_tokens: 100.0,
509 objective: 0.2,
510 },
511 ComboResult {
512 combo: TuneCombo {
513 min_lexical_coverage: 0.4,
514 min_semantic_score: 0.25,
515 reranker_id: "off".to_string(),
516 },
517 mean_mrr: 0.9,
518 mean_tokens: 80.0,
519 objective: 0.5,
520 },
521 ];
522 let winner = select_winner(&combos).expect("winner");
523 assert!((winner.objective - 0.5).abs() < 1e-9);
524 }
525
526 #[test]
527 fn tune_history_roundtrip() {
528 let tmp = std::env::temp_dir().join(format!("kimetsu-tune-hist-{}", Ulid::new()));
529 std::fs::create_dir_all(&tmp).unwrap();
530
531 let entry = TuneHistoryEntry {
532 timestamp: "2026-06-11T00:00:00Z".to_string(),
533 before: TuneCombo {
534 min_lexical_coverage: 0.5,
535 min_semantic_score: -1.0,
536 reranker_id: "off".to_string(),
537 },
538 after: TuneCombo {
539 min_lexical_coverage: 0.4,
540 min_semantic_score: 0.25,
541 reranker_id: "ms-marco-tinybert-l-2-v2".to_string(),
542 },
543 train_objective: 0.55,
544 holdout_objective: 0.50,
545 holdout_mrr: 0.70,
546 baseline_holdout_objective: 0.45,
547 memory_count_at_tune: None,
548 };
549
550 append_tune_history(&tmp, entry.clone()).unwrap();
551 let latest = latest_tune_history(&tmp).unwrap().unwrap();
552 assert!((latest.holdout_objective - 0.50).abs() < 1e-9);
553 assert_eq!(latest.after.reranker_id, "ms-marco-tinybert-l-2-v2");
554
555 std::fs::remove_dir_all(&tmp).ok();
556 }
557
558 #[test]
559 fn tune_history_empty_when_no_file() {
560 let tmp = std::env::temp_dir().join(format!("kimetsu-tune-empty-{}", Ulid::new()));
561 std::fs::create_dir_all(&tmp).unwrap();
562 let latest = latest_tune_history(&tmp).unwrap();
563 assert!(latest.is_none(), "no history file → None");
564 std::fs::remove_dir_all(&tmp).ok();
565 }
566
567 #[test]
570 fn compute_objective_with_regret_zero_rate_matches_base() {
571 let base = compute_objective(0.75, 500.0, 0.005);
572 let with_regret = compute_objective_with_regret(0.75, 500.0, 0.005, 0.0);
573 assert!(
574 (base - with_regret).abs() < 1e-9,
575 "zero regret_rate must give same result as base objective"
576 );
577 }
578
579 #[test]
580 fn compute_objective_with_regret_penalises_high_rate() {
581 let base = compute_objective(0.75, 500.0, 0.005);
582 let with_regret = compute_objective_with_regret(0.75, 500.0, 0.005, 0.10);
583 assert!(
585 with_regret < base,
586 "positive regret_rate must reduce the objective"
587 );
588 assert!(
589 (base - with_regret - REGRET_PENALTY_WEIGHT * 0.10).abs() < 1e-9,
590 "penalty term must equal REGRET_PENALTY_WEIGHT * regret_rate"
591 );
592 }
593
594 #[test]
595 fn compute_objective_with_regret_full_rate_shifts_by_weight() {
596 let base = compute_objective(0.8, 0.0, 0.0);
598 let with_full = compute_objective_with_regret(0.8, 0.0, 0.0, 1.0);
599 assert!(
600 (base - with_full - REGRET_PENALTY_WEIGHT).abs() < 1e-9,
601 "100% regret rate shifts objective by REGRET_PENALTY_WEIGHT"
602 );
603 }
604
605 use crate::{
608 project::{init_project, load_project},
609 projector,
610 user_brain::with_user_brain_disabled,
611 };
612 use kimetsu_core::{event::Event, ids::RunId};
613
614 fn trigger_test_root(label: &str) -> std::path::PathBuf {
615 let root =
616 std::env::temp_dir().join(format!("kimetsu-tune-trigger-{label}-{}", Ulid::new()));
617 kimetsu_core::paths::git_init_boundary(&root);
618 root
619 }
620
621 #[test]
622 fn retune_trigger_no_history_no_events() {
623 with_user_brain_disabled(|| {
624 let root = trigger_test_root("empty");
625 std::fs::create_dir_all(&root).expect("mkdir");
626 init_project(&root, false).expect("init");
627 let paths = kimetsu_core::paths::ProjectPaths::discover(&root).expect("paths");
628 let (_, _, conn) = load_project(&root).expect("load");
629 let state = compute_retune_trigger(&conn, &paths.kimetsu_dir).expect("trigger");
630 assert_eq!(state.current_memory_count, 0);
631 assert_eq!(state.memories_added_since_tune, 0);
632 assert!(!state.corpus_milestone_triggered);
633 assert!(!state.drift_triggered);
634 assert!(!state.should_retune);
635 assert!(state.last_tuned_at.is_none());
636 std::fs::remove_dir_all(&root).ok();
637 });
638 }
639
640 #[test]
641 fn retune_trigger_corpus_milestone_when_enough_memories() {
642 with_user_brain_disabled(|| {
643 let root = trigger_test_root("milestone");
644 std::fs::create_dir_all(&root).expect("mkdir");
645 init_project(&root, false).expect("init");
646 let paths = kimetsu_core::paths::ProjectPaths::discover(&root).expect("paths");
647
648 let entry = TuneHistoryEntry {
650 timestamp: "2026-01-01T00:00:00Z".to_string(),
651 before: TuneCombo {
652 min_lexical_coverage: 0.4,
653 min_semantic_score: 0.0,
654 reranker_id: "off".to_string(),
655 },
656 after: TuneCombo {
657 min_lexical_coverage: 0.4,
658 min_semantic_score: 0.0,
659 reranker_id: "off".to_string(),
660 },
661 train_objective: 0.5,
662 holdout_objective: 0.5,
663 holdout_mrr: 0.7,
664 baseline_holdout_objective: 0.45,
665 memory_count_at_tune: Some(0),
666 };
667 append_tune_history(&paths.kimetsu_dir, entry).expect("append");
668
669 for i in 0..RETUNE_CORPUS_MILESTONE {
671 crate::project::add_memory(
672 &root,
673 kimetsu_core::memory::MemoryScope::Project,
674 kimetsu_core::memory::MemoryKind::Fact,
675 &format!("milestone memory {i}"),
676 )
677 .expect("add memory");
678 }
679
680 let (_, _, conn) = load_project(&root).expect("load");
681 let state = compute_retune_trigger(&conn, &paths.kimetsu_dir).expect("trigger");
682 assert!(
683 state.corpus_milestone_triggered,
684 "milestone must trigger at ≥{RETUNE_CORPUS_MILESTONE} memories added"
685 );
686 assert!(state.should_retune);
687 std::fs::remove_dir_all(&root).ok();
688 });
689 }
690
691 #[test]
692 fn retune_trigger_drift_when_regret_rate_high() {
693 with_user_brain_disabled(|| {
694 let root = trigger_test_root("drift");
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 let (_, _, conn) = load_project(&root).expect("load");
699
700 let run_id = RunId::new();
702 let served_ev = Event::new(
703 run_id,
704 "context.served",
705 serde_json::json!({"query_hash":"abc","capsule_count":1,"skipped":false}),
706 );
707 projector::apply_events(&conn, &[served_ev]).expect("seed served");
708 let regret_ev = Event::new(
709 run_id,
710 "retrieval.regret",
711 serde_json::json!({"memory_id":"m1","dropped_at":0,"cited_at":1}),
712 );
713 projector::apply_events(&conn, &[regret_ev]).expect("seed regret");
714
715 let state = compute_retune_trigger(&conn, &paths.kimetsu_dir).expect("trigger");
716 assert!(
717 state.drift_triggered,
718 "regret_rate ({:.2}) must exceed threshold ({RETUNE_REGRET_RATE_THRESHOLD})",
719 state.regret_rate
720 );
721 assert!(state.should_retune);
722 std::fs::remove_dir_all(&root).ok();
723 });
724 }
725
726 #[test]
729 fn model_advisor_recommends_at_milestone() {
730 let trigger = RetuneTriggerState {
731 current_memory_count: 100,
732 memory_count_at_last_tune: 10,
733 memories_added_since_tune: 90,
734 corpus_milestone_triggered: true,
735 recent_regret_count: 0,
736 recent_served_count: 20,
737 regret_rate: 0.0,
738 drift_triggered: false,
739 should_retune: true,
740 last_tuned_at: Some("2026-01-01T00:00:00Z".to_string()),
741 };
742 let report = compute_model_advisor("jina-embeddings-v2-base-code", &trigger);
743 assert!(report.recommend_grid_run, "must recommend at milestone");
744 assert!(report.estimated_reindex_tokens > 0, "cost must be stated");
745 assert!(!report.candidate_models.is_empty());
746 }
747
748 #[test]
749 fn model_advisor_no_recommendation_below_milestone() {
750 let trigger = RetuneTriggerState {
751 current_memory_count: 30,
752 memory_count_at_last_tune: 25,
753 memories_added_since_tune: 5,
754 corpus_milestone_triggered: false,
755 recent_regret_count: 0,
756 recent_served_count: 10,
757 regret_rate: 0.0,
758 drift_triggered: false,
759 should_retune: false,
760 last_tuned_at: None,
761 };
762 let report = compute_model_advisor("jina-embeddings-v2-base-code", &trigger);
763 assert!(
764 !report.recommend_grid_run,
765 "must NOT recommend below milestone"
766 );
767 }
768
769 #[test]
772 fn count_regret_events_zero_in_empty_db() {
773 with_user_brain_disabled(|| {
774 let root = trigger_test_root("regret-count");
775 std::fs::create_dir_all(&root).expect("mkdir");
776 init_project(&root, false).expect("init");
777 let (_, _, conn) = load_project(&root).expect("load");
778 let count = count_regret_events(&conn, None, None).expect("count");
779 assert_eq!(count, 0);
780 std::fs::remove_dir_all(&root).ok();
781 });
782 }
783
784 #[test]
785 fn tune_history_entry_memory_count_roundtrip() {
786 let tmp = std::env::temp_dir().join(format!("kimetsu-tune-memcount-{}", Ulid::new()));
787 std::fs::create_dir_all(&tmp).unwrap();
788
789 let entry = TuneHistoryEntry {
790 timestamp: "2026-06-11T00:00:00Z".to_string(),
791 before: TuneCombo {
792 min_lexical_coverage: 0.5,
793 min_semantic_score: -1.0,
794 reranker_id: "off".to_string(),
795 },
796 after: TuneCombo {
797 min_lexical_coverage: 0.4,
798 min_semantic_score: 0.25,
799 reranker_id: "off".to_string(),
800 },
801 train_objective: 0.55,
802 holdout_objective: 0.50,
803 holdout_mrr: 0.70,
804 baseline_holdout_objective: 0.45,
805 memory_count_at_tune: Some(123),
806 };
807
808 append_tune_history(&tmp, entry).unwrap();
809 let latest = latest_tune_history(&tmp).unwrap().unwrap();
810 assert_eq!(
811 latest.memory_count_at_tune,
812 Some(123),
813 "memory_count_at_tune must round-trip"
814 );
815
816 std::fs::remove_dir_all(&tmp).ok();
817 }
818}