Skip to main content

kimetsu_brain/
maintain.rs

1//! v2.6: the brain's background upkeep.
2//!
3//! Kimetsu accumulated a shelf of maintenance passes — consolidation, query
4//! routing, pruning, digest refresh, self-tuning, skill graduation — and every
5//! one of them was a CLI command a human had to remember to run. In practice
6//! nobody did, so a long-lived brain drifted: near-duplicates piled up, the
7//! query-routing index went stale, skills never graduated, and the tune
8//! triggers fired into a terminal nobody was reading.
9//!
10//! ## Why this is not a scheduler
11//!
12//! The obvious fix is a resident daemon with a timer. Kimetsu already has a
13//! daemon — the embedder — and deliberately keeps it a dumb model cache with a
14//! 300 ms client budget, because anything it does slowly is something the hook
15//! waits for.
16//!
17//! So upkeep works the way the rest of the system already does: hooks are
18//! stateless and cheap, and anything expensive is a detached spawn. Each pass
19//! records when it last ran; [`due_passes`] answers "what is overdue"; the
20//! session hooks fire a detached `kimetsu brain maintain` when anything is, and
21//! return immediately. No resident process, no timer thread, nothing new to
22//! supervise, and no way for upkeep to sit in front of a prompt.
23//!
24//! ## Free tier
25//!
26//! Every pass here is deterministic or statistical: co-citation stapling,
27//! query-route rebuilding, usefulness pruning, digest assembly, skill-candidate
28//! detection. No model call, so upkeep runs on the Free tier. Reflection (Deep)
29//! is deliberately absent — it is a model call, and a background model call is
30//! exactly the kind of surprise bill this project exists to avoid.
31
32use std::collections::BTreeMap;
33use std::path::Path;
34
35use kimetsu_core::KimetsuResult;
36use serde::{Deserialize, Serialize};
37
38/// A unit of upkeep.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
40pub enum Pass {
41    /// Co-citation stapling + query-route rebuilding (`brain reinforce`).
42    Reinforce,
43    /// Rebuild the repo digest so the next warm start is current.
44    Digest,
45    /// Report memories that have earned pruning. Never destructive here — it
46    /// surfaces candidates; retiring one stays a human decision.
47    Prune,
48    /// Detect memories that have been cited enough to become skills.
49    Skills,
50}
51
52impl Pass {
53    pub const ALL: [Pass; 4] = [Pass::Reinforce, Pass::Digest, Pass::Prune, Pass::Skills];
54
55    pub fn as_str(self) -> &'static str {
56        match self {
57            Pass::Reinforce => "reinforce",
58            Pass::Digest => "digest",
59            Pass::Prune => "prune",
60            Pass::Skills => "skills",
61        }
62    }
63
64    /// How long before this pass is worth running again.
65    ///
66    /// These are deliberately long. Upkeep that runs constantly is a
67    /// background CPU cost the user did not ask for; the passes here converge
68    /// on evidence that accumulates over days, not minutes.
69    pub fn interval_secs(self) -> u64 {
70        match self {
71            // Consolidation needs citations to accumulate before it has
72            // anything new to staple.
73            Pass::Reinforce => 24 * 60 * 60,
74            // The digest also self-refreshes on staleness at warm start; this
75            // is the backstop for a repo nobody has opened in a while.
76            Pass::Digest => 12 * 60 * 60,
77            Pass::Prune => 7 * 24 * 60 * 60,
78            Pass::Skills => 24 * 60 * 60,
79        }
80    }
81}
82
83impl std::str::FromStr for Pass {
84    type Err = String;
85
86    fn from_str(value: &str) -> Result<Self, Self::Err> {
87        match value.trim().to_ascii_lowercase().as_str() {
88            "reinforce" => Ok(Pass::Reinforce),
89            "digest" => Ok(Pass::Digest),
90            "prune" => Ok(Pass::Prune),
91            "skills" => Ok(Pass::Skills),
92            other => Err(format!("unknown maintenance pass `{other}`")),
93        }
94    }
95}
96
97/// When each pass last ran, persisted beside the digest in `.kimetsu/`.
98#[derive(Debug, Clone, Default, Serialize, Deserialize)]
99pub struct MaintenanceState {
100    /// Pass name → unix seconds of its last run.
101    #[serde(default)]
102    pub last_run: BTreeMap<String, u64>,
103}
104
105impl MaintenanceState {
106    pub fn last_run(&self, pass: Pass) -> Option<u64> {
107        self.last_run.get(pass.as_str()).copied()
108    }
109
110    pub fn mark_ran(&mut self, pass: Pass, now: u64) {
111        self.last_run.insert(pass.as_str().to_string(), now);
112    }
113}
114
115pub fn state_path(kimetsu_dir: &Path) -> std::path::PathBuf {
116    kimetsu_dir.join("maintenance.json")
117}
118
119/// Best-effort load — a missing or corrupt file means "nothing has ever run",
120/// which makes every pass due. That is the safe direction: the worst case is
121/// one extra upkeep run.
122pub fn load_state(kimetsu_dir: &Path) -> MaintenanceState {
123    std::fs::read_to_string(state_path(kimetsu_dir))
124        .ok()
125        .and_then(|text| serde_json::from_str(&text).ok())
126        .unwrap_or_default()
127}
128
129pub fn save_state(kimetsu_dir: &Path, state: &MaintenanceState) -> KimetsuResult<()> {
130    let path = state_path(kimetsu_dir);
131    if let Some(parent) = path.parent() {
132        std::fs::create_dir_all(parent)?;
133    }
134    let tmp = path.with_extension("json.tmp");
135    std::fs::write(&tmp, serde_json::to_string_pretty(state)?)?;
136    std::fs::rename(&tmp, &path)?;
137    Ok(())
138}
139
140/// Which passes are overdue at `now`. Pure, so the schedule is unit-testable
141/// without touching a clock or a disk.
142pub fn due_passes(state: &MaintenanceState, now: u64) -> Vec<Pass> {
143    Pass::ALL
144        .into_iter()
145        .filter(|pass| match state.last_run(*pass) {
146            None => true,
147            Some(last) => now.saturating_sub(last) >= pass.interval_secs(),
148        })
149        .collect()
150}
151
152/// What one pass did.
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct PassOutcome {
155    pub pass: String,
156    /// One line of human-readable detail.
157    pub detail: String,
158    /// False when the pass errored. Upkeep is best-effort: one failing pass
159    /// must not stop the others, and must not fail the command.
160    pub ok: bool,
161}
162
163/// Run `passes` against the brain at `start`.
164///
165/// Best-effort throughout: a pass that errors is reported and skipped, because
166/// this runs detached where nobody is watching for a non-zero exit.
167pub fn run_passes(start: &Path, passes: &[Pass]) -> Vec<PassOutcome> {
168    passes.iter().map(|pass| run_pass(start, *pass)).collect()
169}
170
171fn run_pass(start: &Path, pass: Pass) -> PassOutcome {
172    let detail = match pass {
173        Pass::Reinforce => crate::reinforce::reinforce(start, true, true).map(|summary| {
174            format!(
175                "{} staple(s), {} route(s)",
176                summary.staples_created, summary.routes_built
177            )
178        }),
179        Pass::Digest => Ok(match crate::digest::build_or_load_digest(start, true) {
180            Some(digest) => format!("rebuilt ({} chars)", digest.len()),
181            None => "nothing to digest yet".to_string(),
182        }),
183        Pass::Prune => crate::maintenance::prune_low_usefulness(
184            start,
185            crate::maintenance::PruneOptions {
186                // Report only. Retiring a memory is a decision with a blast
187                // radius, and background code has no business making it.
188                apply: false,
189                ..Default::default()
190            },
191        )
192        .map(|summary| format!("{} prune candidate(s)", summary.candidates.len())),
193        Pass::Skills => crate::project::load_project_readonly(start).and_then(|(_p, _c, conn)| {
194            crate::skill_synthesis::find_synthesis_candidates(&conn)
195                .map(|candidates| format!("{} skill candidate(s)", candidates.len()))
196        }),
197    };
198
199    match detail {
200        Ok(detail) => PassOutcome {
201            pass: pass.as_str().to_string(),
202            detail,
203            ok: true,
204        },
205        Err(err) => PassOutcome {
206            pass: pass.as_str().to_string(),
207            detail: err.to_string(),
208            ok: false,
209        },
210    }
211}
212
213/// Unix seconds now.
214pub fn now_unix() -> u64 {
215    std::time::SystemTime::now()
216        .duration_since(std::time::UNIX_EPOCH)
217        .map(|d| d.as_secs())
218        .unwrap_or(0)
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn everything_is_due_on_a_brain_that_has_never_run_upkeep() {
227        let state = MaintenanceState::default();
228        assert_eq!(due_passes(&state, 1_000_000), Pass::ALL.to_vec());
229    }
230
231    #[test]
232    fn a_pass_that_just_ran_is_not_due() {
233        let now = 1_000_000;
234        let mut state = MaintenanceState::default();
235        state.mark_ran(Pass::Reinforce, now);
236        let due = due_passes(&state, now + 60);
237        assert!(!due.contains(&Pass::Reinforce), "got: {due:?}");
238        assert!(
239            due.contains(&Pass::Digest),
240            "the others are untouched: {due:?}"
241        );
242    }
243
244    #[test]
245    fn a_pass_becomes_due_again_after_its_interval() {
246        let now = 1_000_000;
247        let mut state = MaintenanceState::default();
248        state.mark_ran(Pass::Reinforce, now);
249        assert!(
250            !due_passes(&state, now + Pass::Reinforce.interval_secs() - 1)
251                .contains(&Pass::Reinforce)
252        );
253        assert!(
254            due_passes(&state, now + Pass::Reinforce.interval_secs()).contains(&Pass::Reinforce)
255        );
256    }
257
258    /// A clock that jumped backwards (NTP correction, a restored backup) must
259    /// not make a pass permanently un-due through an underflow.
260    #[test]
261    fn a_backwards_clock_does_not_wedge_the_schedule() {
262        let mut state = MaintenanceState::default();
263        state.mark_ran(Pass::Digest, 2_000_000);
264        let due = due_passes(&state, 1_000_000);
265        assert!(
266            !due.contains(&Pass::Digest),
267            "saturating_sub yields 0, so it is simply not yet due: {due:?}"
268        );
269    }
270
271    #[test]
272    fn state_round_trips_and_a_corrupt_file_makes_everything_due() {
273        let dir = std::env::temp_dir().join(format!("kimetsu-maintain-{}", now_unix()));
274        std::fs::create_dir_all(&dir).expect("mkdir");
275
276        let mut state = MaintenanceState::default();
277        state.mark_ran(Pass::Skills, 42);
278        save_state(&dir, &state).expect("save");
279        assert_eq!(load_state(&dir).last_run(Pass::Skills), Some(42));
280
281        std::fs::write(state_path(&dir), "{ not json").expect("corrupt");
282        assert_eq!(
283            due_passes(&load_state(&dir), 1_000_000),
284            Pass::ALL.to_vec(),
285            "a corrupt file must fail towards running upkeep, not away from it"
286        );
287
288        std::fs::remove_dir_all(&dir).ok();
289    }
290
291    #[test]
292    fn pass_names_round_trip() {
293        for pass in Pass::ALL {
294            assert_eq!(pass.as_str().parse::<Pass>(), Ok(pass));
295        }
296        assert!("reflect".parse::<Pass>().is_err(), "Deep-tier, not upkeep");
297    }
298
299    /// Upkeep on a directory that is not a brain must report failures rather
300    /// than panicking or erroring out — it runs detached, where an early exit
301    /// would silently skip the remaining passes.
302    #[test]
303    fn passes_are_best_effort_against_a_missing_brain() {
304        let dir = std::env::temp_dir().join(format!("kimetsu-maintain-nobrain-{}", now_unix()));
305        std::fs::create_dir_all(&dir).expect("mkdir");
306        let outcomes = run_passes(&dir, &Pass::ALL);
307        assert_eq!(outcomes.len(), Pass::ALL.len(), "every pass is attempted");
308        std::fs::remove_dir_all(&dir).ok();
309    }
310}