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// ─── Combo ────────────────────────────────────────────────────────────────────
71
72#[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// ─── Per-combo result ─────────────────────────────────────────────────────────
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct ComboResult {
101    pub combo: TuneCombo,
102    pub mean_mrr: f64,
103    pub mean_tokens: f64,
104    /// mean_mrr − cost_weight * mean_tokens
105    pub objective: f64,
106}
107
108// ─── Tune history entry ───────────────────────────────────────────────────────
109
110#[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    /// S2.1: corpus size (active memory count) at the time of this tune run.
120    /// `None` for history entries written before S2 (backward compat).
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub memory_count_at_tune: Option<u64>,
123}
124
125// ─── S2.1: Re-tune trigger state ─────────────────────────────────────────────
126
127/// Trigger state for S2.1 re-tune proposals.  Computed cheaply (no sweep).
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct RetuneTriggerState {
130    /// Active memory count right now.
131    pub current_memory_count: u64,
132    /// Active memory count at the last tune, or 0 if never tuned.
133    pub memory_count_at_last_tune: u64,
134    /// Memories added since the last tune.
135    pub memories_added_since_tune: u64,
136    /// Whether the corpus milestone threshold has been crossed.
137    pub corpus_milestone_triggered: bool,
138    /// Regrets in the last 24 h window.
139    pub recent_regret_count: u64,
140    /// Context-served events in the last 24 h window.
141    pub recent_served_count: u64,
142    /// Regret rate = recent_regret_count / recent_served_count (0.0 when served=0).
143    pub regret_rate: f64,
144    /// Whether the drift threshold has been crossed.
145    pub drift_triggered: bool,
146    /// True when either trigger is active.
147    pub should_retune: bool,
148    /// Timestamp of the last tune, or `None` if never tuned.
149    pub last_tuned_at: Option<String>,
150}
151
152/// Compute re-tune trigger state from the DB without running any sweep.
153///
154/// Reads:
155/// - Active memory count (current and at last tune from `tune-history.json`).
156/// - `retrieval.regret` event count in the last 24 h.
157/// - `context.served` event count in the last 24 h.
158pub fn compute_retune_trigger(
159    conn: &rusqlite::Connection,
160    kimetsu_dir: &std::path::Path,
161) -> kimetsu_core::KimetsuResult<RetuneTriggerState> {
162    // Current active memory count.
163    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    // Last tune entry (if any).
170    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    // Regret / served counts in the last 24 h.
181    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    // Convert unix-secs cutoff to an approximate ISO string for the SQL comparison.
187    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
228// ─── S2.2: Model re-selection advisor ────────────────────────────────────────
229
230/// Known embedder models and their approximate on-disk download sizes (MiB).
231///
232/// These are estimates for the advisor report; actual sizes vary by format.
233pub const KNOWN_EMBEDDER_MODELS: &[(&str, &str, u32)] = &[
234    // (model_id, description, approx_download_mib)
235    (
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/// Model re-selection advisor recommendation.
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct ModelAdvisorReport {
248    /// Whether the advisor recommends re-running the grid now.
249    pub recommend_grid_run: bool,
250    /// Reason for the recommendation.
251    pub reason: String,
252    /// Currently active embedder model id.
253    pub current_embedder: String,
254    /// Approximate number of memories to re-embed if the model changes.
255    pub memories_to_reindex: u64,
256    /// Estimated token cost to reindex (conservative lower-bound).
257    pub estimated_reindex_tokens: u64,
258    /// Estimated approximate download size for all candidate models (MiB).
259    pub candidate_models: Vec<ModelCandidate>,
260}
261
262/// A candidate embedder model for the grid sweep.
263#[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
270/// Compute the model re-selection advisor report.
271///
272/// Does NOT run the sweep — only computes the metadata needed for the
273/// advisor recommendation.  The actual grid run is a separate `brain tune`
274/// invocation (reuses the existing sweep machinery).
275///
276/// `trigger` must be pre-computed via [`compute_retune_trigger`].
277pub 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
320// ─── Pure functions ───────────────────────────────────────────────────────────
321
322/// Compute the tuning objective for a combo result.
323///
324/// `objective = mean_mrr - cost_weight * mean_tokens`
325pub fn compute_objective(mean_mrr: f64, mean_tokens: f64, cost_weight: f64) -> f64 {
326    mean_mrr - cost_weight * mean_tokens
327}
328
329/// S2.3: Compute the tuning objective with a regret penalty term.
330///
331/// Extended objective:
332/// ```text
333/// objective = mean_mrr
334///           - cost_weight   * mean_tokens
335///           - REGRET_PENALTY_WEIGHT * regret_rate
336/// ```
337///
338/// `regret_rate` = regrets_for_this_combo / total_served_events.
339/// A floor configuration that drops capsules later cited by the model
340/// incurs a higher `regret_rate` and is penalised.
341///
342/// Weight: [`REGRET_PENALTY_WEIGHT`] = 0.5 — see module docs for
343/// calibration rationale.
344pub 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
353/// Count `retrieval.regret` events in `conn` within an optional ISO-8601
354/// timestamp window `[since, until]`.
355///
356/// Used by the sweep to collect regret signal per evaluation window so the
357/// objective function can penalise floor configs that generated regrets.
358pub 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
391/// Split cases into (train, holdout) using a deterministic seed derived from
392/// `case_count`. 80 % train, 20 % holdout. Indices into `cases` are returned.
393///
394/// The split is stable: the same set of N cases always produces the same
395/// train/holdout partition regardless of case order.
396pub 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); // ≥1 holdout
401    // Deterministic: pick every 5th index as holdout.
402    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; // used via filter logic above
405    (train, holdout)
406}
407
408/// Select the best combo from a slice of `ComboResult` by objective score.
409/// Returns `None` when the slice is empty.
410pub 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
418// ─── Tune history I/O ─────────────────────────────────────────────────────────
419
420/// Append a `TuneHistoryEntry` to `.kimetsu/tune-history.json`.
421pub 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
438/// Read the latest entry from `.kimetsu/tune-history.json`, if any.
439pub 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// ─── Unit tests ───────────────────────────────────────────────────────────────
452
453#[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        // 0.75 - 0.005 * 1000 = 0.75 - 5.0 = -4.25
473        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        // Indices 0..10, every 5th (0,5) → holdout, rest → train.
486        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    // ─── S2.3: regret-penalised objective ────────────────────────────────────
568
569    #[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        // penalty = 0.5 * 0.10 = 0.05
584        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        // regret_rate = 1.0 → penalty = REGRET_PENALTY_WEIGHT
597        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    // ─── S2.1: RetuneTriggerState ─────────────────────────────────────────────
606
607    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            // Seed a fake tune-history entry with memory_count_at_tune = 0.
649            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            // Add RETUNE_CORPUS_MILESTONE memories via the add_memory API.
670            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            // Seed 1 served event + 1 regret event (rate = 100% >> threshold).
701            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    // ─── S2.2: ModelAdvisorReport ─────────────────────────────────────────────
727
728    #[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    // ─── S2.3: count_regret_events ────────────────────────────────────────────
770
771    #[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}