Skip to main content

kimetsu_brain/
tune.rs

1//! v1.5 / S2: Self-Tuning Brain sweep engine.
2//!
3//! Pure functions for objective scoring, holdout splitting, and history I/O.
4//! The sweep itself is driven from the CLI (kimetsu-cli) which calls
5//! `evaluate_combo` in-process with injected embedder + optional reranker.
6//!
7//! Sweep space (config-addressable only):
8//!   - min_lexical_coverage ∈ {0.3, 0.4, 0.5, 0.6}
9//!   - min_semantic_score   ∈ {-1.0(auto), 0.0, 0.25, 0.35, 0.45}
10//!   - reranker id ∈ {off, ms-marco-tinybert-l-2-v2, jina-reranker-v1-tiny-en,
11//!     ms-marco-minilm-l-4-v2}
12//!
13//! NOT swept (compile-time or complex-deploy):
14//!   - RERANK_POOL (compile-time const in the daemon) — deferred.
15//!
16//! Objective (S2.3):
17//!   mean_MRR - cost_weight * mean_injected_tokens - REGRET_PENALTY_WEIGHT * regret_rate
18//!
19//! S2.1 Re-tune triggers:
20//!   - Corpus milestone: ≥50 memories added since last tune.
21//!   - Drift: insights hit-rate decline OR regret-rate rise beyond thresholds.
22//!
23//! S2.2 Model re-selection advisor:
24//!   Recommends re-running the embedder×reranker grid at corpus milestones.
25//!   Reports download+reindex cost. Never auto-switches.
26
27use serde::{Deserialize, Serialize};
28
29// ─── S2.1: Re-tune trigger constants ─────────────────────────────────────────
30
31/// Corpus milestone: propose a re-tune when ≥ this many memories have been
32/// added since the last tune run.
33pub const RETUNE_CORPUS_MILESTONE: u64 = 50;
34
35/// Drift threshold: propose a re-tune when the regret rate (regrets / served
36/// events in the last 24h window) rises above this fraction.
37pub const RETUNE_REGRET_RATE_THRESHOLD: f64 = 0.10;
38
39/// S2.2: approximate token cost to reindex 1 000 memories when switching
40/// the embedder model (conservative estimate based on batch embed overhead).
41/// Used to report the cost of a full embedder switch in the advisor output.
42pub const REINDEX_TOKENS_PER_1K_MEMORIES: u64 = 2_000;
43
44/// S2.3 Regret penalty weight in the tune objective.
45///
46/// Weighting rationale:
47///   A floor config that generates a regret has caused the model to work
48///   harder than necessary (re-discover context that the brain dropped).
49///   We penalise the *rate* of regrets (regrets / served events) rather than
50///   the raw count so that the penalty is comparable across eval sets of
51///   different sizes.
52///
53///   Weight = 0.5 was chosen so that a 100 % regret rate (pathological)
54///   shifts the objective by −0.5, roughly equivalent to a 0.5-rank MRR
55///   drop.  At realistic rates (< 10 %) the penalty is < 0.05 — meaningful
56///   signal without overwhelming the MRR term.
57pub const REGRET_PENALTY_WEIGHT: f64 = 0.5;
58
59// ─── Sweep parameter space ────────────────────────────────────────────────────
60
61pub 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/// v2.6: how the lexical and semantic rankings are merged. See
71/// [`crate::fusion`] for why this is a real ranking decision and not plumbing.
72///
73/// It is swept rather than defaulted because BM25 and cosine live on different
74/// scales, and which merge rule wins depends on the corpus — the whole reason
75/// the semantic floor already had to be calibrated per embedder family. The
76/// shipped default stays `linear` until a corpus says otherwise; this is how
77/// you get it to say so.
78pub const FUSION_MODES: &[&str] = &["linear", "rrf"];
79
80// ─── Combo ────────────────────────────────────────────────────────────────────
81
82#[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    /// v2.6: `"linear"` or `"rrf"`. `#[serde(default)]` keeps tune-history
88    /// files written before v2.6 deserializing cleanly — they predate the
89    /// dimension, so they describe linear runs.
90    #[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// ─── Per-combo result ─────────────────────────────────────────────────────────
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct ComboResult {
123    pub combo: TuneCombo,
124    pub mean_mrr: f64,
125    pub mean_tokens: f64,
126    /// mean_mrr − cost_weight * mean_tokens
127    pub objective: f64,
128}
129
130// ─── Tune history entry ───────────────────────────────────────────────────────
131
132#[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    /// S2.1: corpus size (active memory count) at the time of this tune run.
142    /// `None` for history entries written before S2 (backward compat).
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub memory_count_at_tune: Option<u64>,
145}
146
147// ─── S2.1: Re-tune trigger state ─────────────────────────────────────────────
148
149/// Trigger state for S2.1 re-tune proposals.  Computed cheaply (no sweep).
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct RetuneTriggerState {
152    /// Active memory count right now.
153    pub current_memory_count: u64,
154    /// Active memory count at the last tune, or 0 if never tuned.
155    pub memory_count_at_last_tune: u64,
156    /// Memories added since the last tune.
157    pub memories_added_since_tune: u64,
158    /// Whether the corpus milestone threshold has been crossed.
159    pub corpus_milestone_triggered: bool,
160    /// Regrets in the last 24 h window.
161    pub recent_regret_count: u64,
162    /// Context-served events in the last 24 h window.
163    pub recent_served_count: u64,
164    /// Regret rate = recent_regret_count / recent_served_count (0.0 when served=0).
165    pub regret_rate: f64,
166    /// Whether the drift threshold has been crossed.
167    pub drift_triggered: bool,
168    /// True when either trigger is active.
169    pub should_retune: bool,
170    /// Timestamp of the last tune, or `None` if never tuned.
171    pub last_tuned_at: Option<String>,
172}
173
174/// Compute re-tune trigger state from the DB without running any sweep.
175///
176/// Reads:
177/// - Active memory count (current and at last tune from `tune-history.json`).
178/// - `retrieval.regret` event count in the last 24 h.
179/// - `context.served` event count in the last 24 h.
180pub fn compute_retune_trigger(
181    conn: &rusqlite::Connection,
182    kimetsu_dir: &std::path::Path,
183) -> kimetsu_core::KimetsuResult<RetuneTriggerState> {
184    // Current active memory count.
185    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    // Last tune entry (if any).
192    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    // Regret / served counts in the last 24 h.
203    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    // Convert unix-secs cutoff to an approximate ISO string for the SQL comparison.
209    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
250// ─── S2.2: Model re-selection advisor ────────────────────────────────────────
251
252/// Known embedder models and their approximate on-disk download sizes (MiB).
253///
254/// These are estimates for the advisor report; actual sizes vary by format.
255pub const KNOWN_EMBEDDER_MODELS: &[(&str, &str, u32)] = &[
256    // (model_id, description, approx_download_mib)
257    (
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/// Model re-selection advisor recommendation.
268#[derive(Debug, Clone, Serialize, Deserialize)]
269pub struct ModelAdvisorReport {
270    /// Whether the advisor recommends re-running the grid now.
271    pub recommend_grid_run: bool,
272    /// Reason for the recommendation.
273    pub reason: String,
274    /// Currently active embedder model id.
275    pub current_embedder: String,
276    /// Approximate number of memories to re-embed if the model changes.
277    pub memories_to_reindex: u64,
278    /// Estimated token cost to reindex (conservative lower-bound).
279    pub estimated_reindex_tokens: u64,
280    /// Estimated approximate download size for all candidate models (MiB).
281    pub candidate_models: Vec<ModelCandidate>,
282}
283
284/// A candidate embedder model for the grid sweep.
285#[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
292/// Compute the model re-selection advisor report.
293///
294/// Does NOT run the sweep — only computes the metadata needed for the
295/// advisor recommendation.  The actual grid run is a separate `brain tune`
296/// invocation (reuses the existing sweep machinery).
297///
298/// `trigger` must be pre-computed via [`compute_retune_trigger`].
299pub 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
342// ─── Pure functions ───────────────────────────────────────────────────────────
343
344/// Compute the tuning objective for a combo result.
345///
346/// `objective = mean_mrr - cost_weight * mean_tokens`
347pub fn compute_objective(mean_mrr: f64, mean_tokens: f64, cost_weight: f64) -> f64 {
348    mean_mrr - cost_weight * mean_tokens
349}
350
351/// S2.3: Compute the tuning objective with a regret penalty term.
352///
353/// Extended objective:
354/// ```text
355/// objective = mean_mrr
356///           - cost_weight   * mean_tokens
357///           - REGRET_PENALTY_WEIGHT * regret_rate
358/// ```
359///
360/// `regret_rate` = regrets_for_this_combo / total_served_events.
361/// A floor configuration that drops capsules later cited by the model
362/// incurs a higher `regret_rate` and is penalised.
363///
364/// Weight: [`REGRET_PENALTY_WEIGHT`] = 0.5 — see module docs for
365/// calibration rationale.
366pub 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
375/// Count `retrieval.regret` events in `conn` within an optional ISO-8601
376/// timestamp window `[since, until]`.
377///
378/// Used by the sweep to collect regret signal per evaluation window so the
379/// objective function can penalise floor configs that generated regrets.
380pub 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
413/// Split cases into (train, holdout) using a deterministic seed derived from
414/// `case_count`. 80 % train, 20 % holdout. Indices into `cases` are returned.
415///
416/// The split is stable: the same set of N cases always produces the same
417/// train/holdout partition regardless of case order.
418pub 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); // ≥1 holdout
423    // Deterministic: pick every 5th index as holdout.
424    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; // used via filter logic above
427    (train, holdout)
428}
429
430/// Select the best combo from a slice of `ComboResult` by objective score.
431/// Returns `None` when the slice is empty.
432pub 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
440// ─── Tune history I/O ─────────────────────────────────────────────────────────
441
442/// Append a `TuneHistoryEntry` to `.kimetsu/tune-history.json`.
443pub 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
460/// Read the latest entry from `.kimetsu/tune-history.json`, if any.
461pub 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// ─── Unit tests ───────────────────────────────────────────────────────────────
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use ulid::Ulid;
479
480    /// The sweep space is the product of every swept dimension. Asserting the
481    /// arithmetic (rather than a literal) keeps this honest when a dimension is
482    /// added: v2.6 added `fusion`, which doubled the grid from 80 to 160.
483    #[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        // Every combo must be distinct: a duplicated point wastes a full
500        // evaluation pass over the corpus.
501        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        // 0.75 - 0.005 * 1000 = 0.75 - 5.0 = -4.25
520        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        // Indices 0..10, every 5th (0,5) → holdout, rest → train.
533        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    // ─── S2.3: regret-penalised objective ────────────────────────────────────
619
620    #[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        // penalty = 0.5 * 0.10 = 0.05
635        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        // regret_rate = 1.0 → penalty = REGRET_PENALTY_WEIGHT
648        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    // ─── S2.1: RetuneTriggerState ─────────────────────────────────────────────
657
658    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            // Seed a fake tune-history entry with memory_count_at_tune = 0.
700            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            // Add RETUNE_CORPUS_MILESTONE memories via the add_memory API.
723            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            // Seed 1 served event + 1 regret event (rate = 100% >> threshold).
754            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    // ─── S2.2: ModelAdvisorReport ─────────────────────────────────────────────
780
781    #[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    // ─── S2.3: count_regret_events ────────────────────────────────────────────
823
824    #[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}