Skip to main content

kimetsu_brain/
inject_policy.rs

1//! v2.6: deciding whether a proactive recall is worth interrupting for.
2//!
3//! Kimetsu's proactive hooks surface a memory mid-task — before a command that
4//! matches a known failure, or after one that just failed. Whether to speak is
5//! the whole ballgame: a memory system that interrupts too often gets muted,
6//! and one that never speaks is just a database.
7//!
8//! Through v2.5 the rule was a fixed score threshold: inject when the top
9//! capsule scores ≥ 0.45, or ≥ 0.35 when the agent is visibly looping. Those
10//! numbers were picked by hand, apply identically to every brain and every
11//! user, and never move no matter how the injections land.
12//!
13//! *Remember When It Matters* (arXiv 2607.08716) measured what this is worth:
14//! a memory agent that decides per turn whether to inject a reminder or stay
15//! silent is worth +8.3 pp on Terminal-Bench 2.0 over the same agent without
16//! one. The decision is the mechanism, not the retrieval.
17//!
18//! ## The model
19//!
20//! A logistic regression over [`Features`] — score, kind, novelty, how often
21//! this exact command has already failed, how much has already been injected
22//! this session, how long since the last injection, and how strong the
23//! evidence for the failure was.
24//!
25//! Small, linear, and inspectable on purpose. `kimetsu brain policy --status`
26//! prints the weights; a user can see why the brain is talking more or less
27//! than it used to, which is not true of anything larger.
28//!
29//! ## The prior is today's behaviour
30//!
31//! [`Policy::prior`] is fitted by hand so that its decision boundary is
32//! *exactly* the old fixed threshold: p = 0.5 at score 0.45, and at 0.35 in
33//! loop mode. Every other feature starts at weight zero.
34//!
35//! That matters more than the model does. A brain with no injection history
36//! behaves precisely as it did before this module existed, so nothing changes
37//! on upgrade; training can only move the boundary once there is evidence
38//! about how injections actually landed. There is no cold-start regression to
39//! trade against the eventual gain.
40//!
41//! ## Where the labels come from
42//!
43//! Each proactive injection writes a `proactive.injected` event carrying its
44//! feature vector and the memory id. An injection is **positive** when that
45//! memory was cited in the same session, and **negative** otherwise: the agent
46//! was handed the memory and did not use it, which is the definition of an
47//! interruption that was not worth it.
48//!
49//! This is Free-tier: gradient descent over a handful of floats, no model call.
50
51use kimetsu_core::KimetsuResult;
52use rusqlite::Connection;
53use serde::{Deserialize, Serialize};
54
55/// Number of features. Fixed so a persisted weight vector can be validated on
56/// load — a policy file from a future version with more features is rejected
57/// rather than silently misread.
58pub const FEATURE_COUNT: usize = 7;
59
60/// The old fixed score threshold, and the loop-mode threshold. The prior's
61/// decision boundary is pinned to these so an untrained brain is unchanged.
62pub const LEGACY_MIN_SCORE: f32 = 0.45;
63pub const LEGACY_LOOP_MIN_SCORE: f32 = 0.35;
64
65/// The abstain floor retrieval uses on the proactive path, below which a
66/// candidate is not even offered to the policy.
67///
68/// Deliberately well under both legacy thresholds: the policy is supposed to
69/// make the call, and a hard floor at the old threshold would make a trained
70/// policy unable to ever speak sooner than the rule it replaced. It exists only
71/// so obvious noise never reaches the decision.
72pub const POLICY_RECALL_FLOOR: f32 = 0.20;
73
74/// Minimum labelled examples before a fitted policy is allowed to replace the
75/// prior. Below this, the fit is noise: a handful of injections cannot say
76/// anything about a decision boundary, and a policy fitted on five examples
77/// would swing wildly with each new one.
78pub const MIN_TRAINING_EXAMPLES: usize = 40;
79
80/// What the hook knows at the moment it decides whether to speak.
81#[derive(Debug, Clone, Copy, PartialEq)]
82pub struct Features {
83    /// Composite broker score of the best candidate capsule, in `[0, 1]`.
84    pub score: f32,
85    /// 1.0 when the agent is visibly repeating a failing command. A stuck
86    /// agent should be interrupted sooner — this is the signal the old rule
87    /// expressed by dropping the threshold.
88    pub loop_mode: f32,
89    /// 1.0 when the capsule is a `failure_pattern` — the kind most likely to
90    /// prevent a wasted attempt rather than merely inform one.
91    pub is_failure_pattern: f32,
92    /// 1.0 when nothing has been injected yet this session, falling towards 0
93    /// as the session accumulates injections. Encodes "the tenth interruption
94    /// is worth less than the first".
95    pub novelty: f32,
96    /// How many times this exact command has already been observed failing,
97    /// normalized: `min(count, 5) / 5`.
98    pub repeat_count: f32,
99    /// Time since the last injection, normalized against the refractory
100    /// window: 0 immediately after one, 1 once well clear of it.
101    pub recovery: f32,
102    /// How strong the evidence was that something failed: 0 for a substring
103    /// guess, 0.5 for a toolchain summary line, 1 for a real exit code.
104    /// A guess should have to clear a higher bar than a fact.
105    pub evidence: f32,
106}
107
108impl Features {
109    /// Feature vector in weight order. Order is part of the persisted format.
110    pub fn to_vec(self) -> [f32; FEATURE_COUNT] {
111        [
112            self.score,
113            self.loop_mode,
114            self.is_failure_pattern,
115            self.novelty,
116            self.repeat_count,
117            self.recovery,
118            self.evidence,
119        ]
120    }
121
122    /// Human-readable names, aligned with [`Self::to_vec`]. Used by
123    /// `brain policy --status` so the weights mean something on screen.
124    pub const NAMES: [&'static str; FEATURE_COUNT] = [
125        "score",
126        "loop_mode",
127        "is_failure_pattern",
128        "novelty",
129        "repeat_count",
130        "recovery",
131        "evidence",
132    ];
133
134    /// Reconstruct from a persisted vector (event payloads, training data).
135    pub fn from_slice(v: &[f32]) -> Option<Self> {
136        if v.len() != FEATURE_COUNT {
137            return None;
138        }
139        Some(Self {
140            score: v[0],
141            loop_mode: v[1],
142            is_failure_pattern: v[2],
143            novelty: v[3],
144            repeat_count: v[4],
145            recovery: v[5],
146            evidence: v[6],
147        })
148    }
149}
150
151/// A fitted (or prior) injection policy.
152#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
153pub struct Policy {
154    pub weights: Vec<f32>,
155    pub bias: f32,
156    /// How many labelled examples produced these weights. 0 = the hand-set
157    /// prior, which is the legacy threshold rule.
158    pub trained_on: usize,
159    /// RFC 3339 timestamp of the fit, or `None` for the prior.
160    pub trained_at: Option<String>,
161}
162
163impl Policy {
164    /// The hand-set prior: exactly the pre-v2.6 fixed-threshold rule.
165    ///
166    /// `z = W_SCORE·score + W_LOOP·loop_mode + BIAS`, solved so that `z = 0`
167    /// (p = 0.5) at score 0.45 normally and at 0.35 in loop mode. Every other
168    /// weight is zero, so no other feature can move the decision until the
169    /// policy has been trained on real outcomes.
170    pub fn prior() -> Self {
171        // W_SCORE·0.45 + BIAS = 0  and  W_SCORE·0.35 + W_LOOP + BIAS = 0
172        //   => BIAS = -0.45·W_SCORE,  W_LOOP = 0.10·W_SCORE
173        const W_SCORE: f32 = 20.0;
174        let mut weights = vec![0.0; FEATURE_COUNT];
175        weights[0] = W_SCORE;
176        weights[1] = 0.10 * W_SCORE;
177        Self {
178            weights,
179            bias: -LEGACY_MIN_SCORE * W_SCORE,
180            trained_on: 0,
181            trained_at: None,
182        }
183    }
184
185    /// True when this is the untrained prior.
186    pub fn is_prior(&self) -> bool {
187        self.trained_on == 0
188    }
189
190    /// Reject a policy whose shape does not match this build — a file written
191    /// by a version with a different feature set would otherwise be read as
192    /// nonsense weights.
193    pub fn is_valid(&self) -> bool {
194        self.weights.len() == FEATURE_COUNT
195            && self.bias.is_finite()
196            && self.weights.iter().all(|w| w.is_finite())
197    }
198
199    /// Probability that injecting here is worth it.
200    pub fn probability(&self, features: &Features) -> f32 {
201        let z: f32 = self
202            .weights
203            .iter()
204            .zip(features.to_vec())
205            .map(|(w, x)| w * x)
206            .sum::<f32>()
207            + self.bias;
208        sigmoid(z)
209    }
210
211    /// The decision. `p >= 0.5` speaks.
212    pub fn should_inject(&self, features: &Features) -> bool {
213        self.probability(features) >= 0.5
214    }
215}
216
217impl Default for Policy {
218    fn default() -> Self {
219        Self::prior()
220    }
221}
222
223fn sigmoid(z: f32) -> f32 {
224    if z >= 0.0 {
225        1.0 / (1.0 + (-z).exp())
226    } else {
227        // Numerically stable for very negative z: exp(z) rather than exp(-z).
228        let e = z.exp();
229        e / (1.0 + e)
230    }
231}
232
233/// One labelled injection: what the hook saw, and whether it paid off.
234#[derive(Debug, Clone, Copy)]
235pub struct Example {
236    pub features: Features,
237    /// True when the injected memory was cited in the same session.
238    pub useful: bool,
239}
240
241/// Fit a policy by gradient descent on the logistic loss.
242///
243/// Returns the prior unchanged when there is too little data
244/// ([`MIN_TRAINING_EXAMPLES`]) or when every label is the same — a fit on
245/// all-positive or all-negative examples has no boundary to find and would
246/// drive the weights off to infinity.
247///
248/// Starts from the prior rather than from zero, so a small dataset nudges the
249/// legacy rule rather than replacing it, and L2 pulls back towards the prior
250/// for the same reason.
251pub fn fit(examples: &[Example]) -> Policy {
252    const EPOCHS: usize = 400;
253    const LEARNING_RATE: f32 = 0.05;
254    /// Pull back towards the prior, not towards zero: with little data the
255    /// right answer is "close to what we already did".
256    const L2: f32 = 0.01;
257
258    if examples.len() < MIN_TRAINING_EXAMPLES {
259        return Policy::prior();
260    }
261    let positives = examples.iter().filter(|e| e.useful).count();
262    if positives == 0 || positives == examples.len() {
263        return Policy::prior();
264    }
265
266    let prior = Policy::prior();
267    let mut weights = prior.weights.clone();
268    let mut bias = prior.bias;
269    let n = examples.len() as f32;
270
271    for _ in 0..EPOCHS {
272        let mut grad_w = [0.0f32; FEATURE_COUNT];
273        let mut grad_b = 0.0f32;
274        for example in examples {
275            let x = example.features.to_vec();
276            let z: f32 = weights.iter().zip(x).map(|(w, xi)| w * xi).sum::<f32>() + bias;
277            let error = sigmoid(z) - if example.useful { 1.0 } else { 0.0 };
278            for (g, xi) in grad_w.iter_mut().zip(x) {
279                *g += error * xi;
280            }
281            grad_b += error;
282        }
283        for (i, g) in grad_w.iter().enumerate() {
284            // Regularise toward the prior weight, not toward zero.
285            let pull = L2 * (weights[i] - prior.weights[i]);
286            weights[i] -= LEARNING_RATE * (g / n + pull);
287        }
288        bias -= LEARNING_RATE * (grad_b / n + L2 * (bias - prior.bias));
289    }
290
291    let fitted = Policy {
292        weights,
293        bias,
294        trained_on: examples.len(),
295        trained_at: None,
296    };
297    // A fit that diverged (NaN from pathological data) is worse than no fit.
298    if fitted.is_valid() { fitted } else { prior }
299}
300
301/// Fraction of `examples` the policy labels correctly. Reported by
302/// `brain policy --status` so a user can see whether the fit beat the prior.
303pub fn accuracy(policy: &Policy, examples: &[Example]) -> f32 {
304    if examples.is_empty() {
305        return 0.0;
306    }
307    let correct = examples
308        .iter()
309        .filter(|e| policy.should_inject(&e.features) == e.useful)
310        .count();
311    correct as f32 / examples.len() as f32
312}
313
314// ── Persistence + labelling ─────────────────────────────────────────────────
315
316/// The event kind written on every proactive injection.
317pub const INJECTED_EVENT: &str = "proactive.injected";
318
319/// Where a fitted policy lives: beside `digest.md` in `.kimetsu/`, because it
320/// is learned per-project state that should travel with the project and be
321/// picked up by a backup of it.
322pub fn policy_path(kimetsu_dir: &std::path::Path) -> std::path::PathBuf {
323    kimetsu_dir.join("inject-policy.json")
324}
325
326/// Load the fitted policy, or the prior when there is none, the file is
327/// unreadable, or it was written by a build with a different feature set.
328///
329/// Never fails: a broken policy file degrades to the legacy threshold rule,
330/// which is the behaviour it replaced.
331pub fn load(kimetsu_dir: &std::path::Path) -> Policy {
332    std::fs::read_to_string(policy_path(kimetsu_dir))
333        .ok()
334        .and_then(|text| serde_json::from_str::<Policy>(&text).ok())
335        .filter(Policy::is_valid)
336        .unwrap_or_else(Policy::prior)
337}
338
339/// Persist a fitted policy (temp + rename, so a reader never sees a torn file).
340pub fn save(kimetsu_dir: &std::path::Path, policy: &Policy) -> KimetsuResult<()> {
341    let path = policy_path(kimetsu_dir);
342    if let Some(parent) = path.parent() {
343        std::fs::create_dir_all(parent)?;
344    }
345    let tmp = path.with_extension("json.tmp");
346    std::fs::write(&tmp, serde_json::to_string_pretty(policy)?)?;
347    std::fs::rename(&tmp, &path)?;
348    Ok(())
349}
350
351/// Remove a fitted policy, returning the brain to the prior.
352pub fn reset(kimetsu_dir: &std::path::Path) -> KimetsuResult<()> {
353    let path = policy_path(kimetsu_dir);
354    if path.exists() {
355        std::fs::remove_file(&path)?;
356    }
357    Ok(())
358}
359
360/// Record a proactive injection decision, with the features that drove it.
361///
362/// The event *is* the training data — without it there is nothing to fit on.
363/// Both outcomes are recorded: an injection that was suppressed is as
364/// informative as one that fired, provided it is labelled as suppressed.
365///
366/// Written through the same telemetry path as `context.served`, so a
367/// misconfigured `project.toml` cannot stop it, and best-effort by contract:
368/// losing a training sample must never break the agent's turn.
369pub fn record_injection(
370    start: &std::path::Path,
371    memory_id: &str,
372    features: &Features,
373    injected: bool,
374    surface: Surface,
375) {
376    let payload = serde_json::json!({
377        "memory_id": memory_id,
378        "features": features.to_vec().to_vec(),
379        "injected": injected,
380        "surface": surface.as_str(),
381    });
382    let _ = crate::feedback::log_telemetry_event(start, INJECTED_EVENT, payload);
383}
384
385/// Which hook surface a proactive injection came from.
386///
387/// Recorded so the surfaces can be judged separately. They are not equivalent
388/// bets: [`Surface::PostTool`] reacts to a command that *observably* failed,
389/// while [`Surface::PreToolPrefetch`] is a prediction from a file path alone —
390/// the weakest signal Kimetsu acts on, and the reason `broker.proactive_prefetch`
391/// has stayed default-off. Pooling them would let the strong surface's
392/// acceptance hide the weak one's noise, which is precisely the question
393/// graduating that flag has to answer.
394#[derive(Debug, Clone, Copy, PartialEq, Eq)]
395pub enum Surface {
396    /// `PreToolUse`, matching on the command about to run.
397    PreToolCommand,
398    /// `PreToolUse`, matching on the file about to be touched. Only reachable
399    /// when `broker.proactive_prefetch` is on.
400    PreToolPrefetch,
401    /// `PostToolUse`, reacting to a command that failed.
402    PostTool,
403}
404
405impl Surface {
406    pub fn as_str(self) -> &'static str {
407        match self {
408            Surface::PreToolCommand => "pretool_command",
409            Surface::PreToolPrefetch => "pretool_prefetch",
410            Surface::PostTool => "posttool",
411        }
412    }
413
414    fn from_str(s: &str) -> Option<Self> {
415        match s {
416            "pretool_command" => Some(Surface::PreToolCommand),
417            "pretool_prefetch" => Some(Surface::PreToolPrefetch),
418            "posttool" => Some(Surface::PostTool),
419            _ => None,
420        }
421    }
422}
423
424/// How one hook surface has actually performed.
425#[derive(Debug, Clone, PartialEq)]
426pub struct SurfaceStats {
427    pub surface: &'static str,
428    /// Injections that fired on this surface.
429    pub injected: usize,
430    /// Of those, how many were followed by a citation of the memory injected.
431    pub cited: usize,
432}
433
434impl SurfaceStats {
435    /// Share of injections the agent went on to lean on, in `[0, 1]`.
436    ///
437    /// The complement is the false-positive rate: an injection the agent never
438    /// cited is an interruption it did not need.
439    pub fn acceptance(&self) -> f32 {
440        if self.injected == 0 {
441            return 0.0;
442        }
443        self.cited as f32 / self.injected as f32
444    }
445}
446
447/// Acceptance per hook surface, over this brain's own history.
448///
449/// This exists to settle a specific question. `broker.proactive_prefetch` has
450/// been default-off since it shipped, with its own doc comment saying
451/// graduation "waits for regret data" — and nothing was recording which surface
452/// an injection came from, so that data could never accumulate and the flag
453/// could never graduate. Whatever the number turns out to be, it is now
454/// answerable on a real brain rather than argued about.
455///
456/// Surfaces with no injections are omitted: a zero denominator is not a
457/// measurement, and printing 0% for a surface nobody has exercised reads as a
458/// verdict.
459pub fn surface_acceptance(conn: &Connection) -> KimetsuResult<Vec<SurfaceStats>> {
460    let mut stmt = conn.prepare(
461        "SELECT e.payload_json, e.ts
462         FROM events AS e
463         WHERE e.kind = ?1
464         ORDER BY e.ts",
465    )?;
466    let rows = stmt
467        .query_map(rusqlite::params![INJECTED_EVENT], |row| {
468            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
469        })?
470        .collect::<Result<Vec<_>, _>>()?;
471
472    let mut tally: Vec<SurfaceStats> = [
473        Surface::PreToolCommand,
474        Surface::PreToolPrefetch,
475        Surface::PostTool,
476    ]
477    .iter()
478    .map(|s| SurfaceStats {
479        surface: s.as_str(),
480        injected: 0,
481        cited: 0,
482    })
483    .collect();
484
485    for (payload_json, ts) in rows {
486        let Ok(payload) = serde_json::from_str::<serde_json::Value>(&payload_json) else {
487            continue;
488        };
489        // A suppressed injection has no outcome to observe, so it cannot speak
490        // to whether the surface interrupts usefully.
491        if payload.get("injected").and_then(serde_json::Value::as_bool) != Some(true) {
492            continue;
493        }
494        // Events written before surfaces were recorded carry no surface. They
495        // are dropped rather than bucketed into a default, which would credit
496        // one surface with another's history.
497        let Some(surface) = payload
498            .get("surface")
499            .and_then(serde_json::Value::as_str)
500            .and_then(Surface::from_str)
501        else {
502            continue;
503        };
504        let Some(memory_id) = payload.get("memory_id").and_then(serde_json::Value::as_str) else {
505            continue;
506        };
507        let cited: bool = conn
508            .query_row(
509                "SELECT EXISTS(
510                     SELECT 1 FROM memory_citations
511                     WHERE memory_id = ?1 AND cited_at >= ?2
512                 )",
513                rusqlite::params![memory_id, ts],
514                |r| r.get(0),
515            )
516            .unwrap_or(false);
517        if let Some(stats) = tally.iter_mut().find(|s| s.surface == surface.as_str()) {
518            stats.injected += 1;
519            if cited {
520                stats.cited += 1;
521            }
522        }
523    }
524
525    tally.retain(|s| s.injected > 0);
526    Ok(tally)
527}
528
529/// Build the training set: every recorded injection, labelled by whether the
530/// injected memory was cited after it was surfaced.
531///
532/// "Cited after" is the honest signal available: the agent was handed the
533/// memory and then leaned on it. An injection that was never cited is one the
534/// agent did not need — which is exactly the interruption worth learning not
535/// to make.
536pub fn collect_examples(conn: &Connection) -> KimetsuResult<Vec<Example>> {
537    let mut stmt = conn.prepare(
538        "SELECT e.payload_json, e.ts
539         FROM events AS e
540         WHERE e.kind = ?1
541         ORDER BY e.ts",
542    )?;
543    let rows = stmt
544        .query_map(rusqlite::params![INJECTED_EVENT], |row| {
545            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
546        })?
547        .collect::<Result<Vec<_>, _>>()?;
548
549    let mut examples = Vec::new();
550    for (payload_json, ts) in rows {
551        let Ok(payload) = serde_json::from_str::<serde_json::Value>(&payload_json) else {
552            continue;
553        };
554        // Only injections that actually happened are labelled: a suppressed
555        // one has no outcome to observe.
556        if payload.get("injected").and_then(serde_json::Value::as_bool) == Some(false) {
557            continue;
558        }
559        let Some(memory_id) = payload.get("memory_id").and_then(serde_json::Value::as_str) else {
560            continue;
561        };
562        let Some(values) = payload
563            .get("features")
564            .and_then(serde_json::Value::as_array)
565        else {
566            continue;
567        };
568        let floats: Vec<f32> = values
569            .iter()
570            .filter_map(|v| v.as_f64().map(|f| f as f32))
571            .collect();
572        let Some(features) = Features::from_slice(&floats) else {
573            continue; // written by a build with a different feature set
574        };
575
576        let cited: bool = conn
577            .query_row(
578                "SELECT EXISTS(
579                     SELECT 1 FROM memory_citations
580                     WHERE memory_id = ?1 AND cited_at >= ?2
581                 )",
582                rusqlite::params![memory_id, ts],
583                |r| r.get(0),
584            )
585            .unwrap_or(false);
586        examples.push(Example {
587            features,
588            useful: cited,
589        });
590    }
591    Ok(examples)
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597
598    fn features(score: f32, loop_mode: bool) -> Features {
599        Features {
600            score,
601            loop_mode: if loop_mode { 1.0 } else { 0.0 },
602            is_failure_pattern: 0.0,
603            novelty: 1.0,
604            repeat_count: 0.0,
605            recovery: 1.0,
606            evidence: 0.5,
607        }
608    }
609
610    // ── The prior must be the old rule, exactly ──────────────────────────
611
612    /// The upgrade-safety property: a brain with no injection history must
613    /// behave precisely as it did before this module existed.
614    #[test]
615    fn the_prior_reproduces_the_legacy_threshold() {
616        let policy = Policy::prior();
617        assert!(policy.is_prior());
618
619        // Normal mode: the boundary is exactly LEGACY_MIN_SCORE.
620        assert!(!policy.should_inject(&features(LEGACY_MIN_SCORE - 0.01, false)));
621        assert!(policy.should_inject(&features(LEGACY_MIN_SCORE + 0.01, false)));
622        assert!(
623            (policy.probability(&features(LEGACY_MIN_SCORE, false)) - 0.5).abs() < 1e-4,
624            "p must be exactly 0.5 at the legacy threshold"
625        );
626
627        // Loop mode: the boundary drops to LEGACY_LOOP_MIN_SCORE.
628        assert!(!policy.should_inject(&features(LEGACY_LOOP_MIN_SCORE - 0.01, true)));
629        assert!(policy.should_inject(&features(LEGACY_LOOP_MIN_SCORE + 0.01, true)));
630        assert!(
631            (policy.probability(&features(LEGACY_LOOP_MIN_SCORE, true)) - 0.5).abs() < 1e-4,
632            "loop mode must cross at the legacy loop threshold"
633        );
634    }
635
636    /// No feature other than score and loop mode may influence an untrained
637    /// policy — otherwise the upgrade would silently change behaviour.
638    #[test]
639    fn the_prior_ignores_every_untrained_feature() {
640        let policy = Policy::prior();
641        let base = features(0.5, false);
642        let loud = Features {
643            is_failure_pattern: 1.0,
644            novelty: 0.0,
645            repeat_count: 1.0,
646            recovery: 0.0,
647            evidence: 1.0,
648            ..base
649        };
650        assert!(
651            (policy.probability(&base) - policy.probability(&loud)).abs() < 1e-6,
652            "untrained features must have zero weight"
653        );
654    }
655
656    // ── Training ─────────────────────────────────────────────────────────
657
658    fn dataset(n: usize, boundary: f32) -> Vec<Example> {
659        (0..n)
660            .map(|i| {
661                // Sweep score across [0, 1]; label by a boundary that differs
662                // from the prior's, so a successful fit has to move.
663                let score = i as f32 / n as f32;
664                Example {
665                    features: features(score, false),
666                    useful: score >= boundary,
667                }
668            })
669            .collect()
670    }
671
672    /// Too little data must leave the prior alone: a boundary fitted on a
673    /// handful of injections would swing with every new one.
674    #[test]
675    fn a_small_dataset_does_not_move_the_policy() {
676        let fitted = fit(&dataset(MIN_TRAINING_EXAMPLES - 1, 0.8));
677        assert_eq!(fitted, Policy::prior());
678        assert!(fitted.is_prior());
679    }
680
681    /// All-positive or all-negative data has no boundary to find; fitting it
682    /// would drive the weights off to infinity.
683    #[test]
684    fn single_class_data_does_not_move_the_policy() {
685        let all_useful: Vec<Example> = (0..100)
686            .map(|_| Example {
687                features: features(0.5, false),
688                useful: true,
689            })
690            .collect();
691        assert_eq!(fit(&all_useful), Policy::prior());
692
693        let none_useful: Vec<Example> = all_useful
694            .iter()
695            .map(|e| Example {
696                useful: false,
697                ..*e
698            })
699            .collect();
700        assert_eq!(fit(&none_useful), Policy::prior());
701    }
702
703    /// The point of the whole module: given evidence that injections below a
704    /// higher bar were not used, the policy gets quieter.
705    #[test]
706    fn training_moves_the_boundary_towards_the_evidence() {
707        // Injections only paid off above 0.8, well above the legacy 0.45.
708        let examples = dataset(200, 0.8);
709        let fitted = fit(&examples);
710
711        assert!(
712            !fitted.is_prior(),
713            "a large clean dataset must produce a fit"
714        );
715        assert_eq!(fitted.trained_on, 200);
716        assert!(fitted.is_valid());
717
718        // The old rule would speak at 0.5; the trained policy should not.
719        assert!(
720            Policy::prior().should_inject(&features(0.5, false)),
721            "sanity: the prior does speak here"
722        );
723        assert!(
724            !fitted.should_inject(&features(0.5, false)),
725            "the fit must learn to stay quiet where injections went unused"
726        );
727        assert!(
728            fitted.should_inject(&features(0.95, false)),
729            "and must still speak where they landed"
730        );
731        assert!(
732            accuracy(&fitted, &examples) > accuracy(&Policy::prior(), &examples),
733            "the fit must beat the prior on its own data"
734        );
735    }
736
737    /// And the other direction: evidence that low-scoring injections were
738    /// useful should make it talk more.
739    #[test]
740    fn training_can_also_make_the_policy_speak_sooner() {
741        let examples = dataset(200, 0.2);
742        let fitted = fit(&examples);
743        assert!(!fitted.is_prior());
744        assert!(
745            fitted.should_inject(&features(0.3, false)),
746            "injections that paid off below the legacy floor must raise the odds"
747        );
748    }
749
750    // ── Persistence shape ────────────────────────────────────────────────
751
752    #[test]
753    fn a_policy_round_trips_through_json() {
754        let fitted = fit(&dataset(200, 0.7));
755        let json = serde_json::to_string(&fitted).expect("serialize");
756        let back: Policy = serde_json::from_str(&json).expect("deserialize");
757        assert_eq!(fitted, back);
758    }
759
760    /// A policy file from a build with a different feature set must be
761    /// rejected, not read as nonsense weights.
762    #[test]
763    fn a_wrong_shaped_policy_is_invalid() {
764        let bad = Policy {
765            weights: vec![1.0; FEATURE_COUNT + 2],
766            bias: 0.0,
767            trained_on: 100,
768            trained_at: None,
769        };
770        assert!(!bad.is_valid());
771
772        let nan = Policy {
773            weights: vec![f32::NAN; FEATURE_COUNT],
774            bias: 0.0,
775            trained_on: 100,
776            trained_at: None,
777        };
778        assert!(!nan.is_valid());
779    }
780
781    #[test]
782    fn feature_names_line_up_with_the_vector() {
783        assert_eq!(Features::NAMES.len(), FEATURE_COUNT);
784        assert_eq!(features(0.5, false).to_vec().len(), FEATURE_COUNT);
785        let round = Features::from_slice(&features(0.42, true).to_vec()).expect("round trip");
786        assert_eq!(round, features(0.42, true));
787        assert!(Features::from_slice(&[0.1, 0.2]).is_none());
788    }
789
790    #[test]
791    fn sigmoid_is_stable_at_the_extremes() {
792        assert!(sigmoid(0.0) == 0.5);
793        assert!(sigmoid(200.0).is_finite() && sigmoid(200.0) > 0.999);
794        assert!(sigmoid(-200.0).is_finite() && sigmoid(-200.0) < 0.001);
795    }
796
797    // ── Surface acceptance (v2.6) ────────────────────────────────────────
798
799    fn surface_conn() -> Connection {
800        let conn = Connection::open_in_memory().expect("open");
801        crate::schema::initialize(&conn).expect("schema");
802        conn
803    }
804
805    /// Write an injection event directly, the way `record_injection` does
806    /// through the telemetry path.
807    fn log_injection(
808        conn: &Connection,
809        memory_id: &str,
810        surface: Option<&str>,
811        injected: bool,
812        ts: &str,
813    ) {
814        let mut payload = serde_json::json!({
815            "memory_id": memory_id,
816            "features": features(0.6, false).to_vec().to_vec(),
817            "injected": injected,
818        });
819        if let Some(surface) = surface {
820            payload["surface"] = serde_json::json!(surface);
821        }
822        conn.execute(
823            "INSERT INTO events (event_id, run_id, ts, kind, schema_version, payload_json)
824             VALUES (?1, 'test-run', ?2, ?3, 1, ?4)",
825            rusqlite::params![
826                kimetsu_core::ids::new_id().to_string(),
827                ts,
828                INJECTED_EVENT,
829                payload.to_string()
830            ],
831        )
832        .expect("insert event");
833    }
834
835    fn cite(conn: &Connection, memory_id: &str, ts: &str) {
836        conn.execute(
837            "INSERT INTO memory_citations (run_id, memory_id, turn, cited_at)
838             VALUES (?1, ?2, 1, ?3)",
839            rusqlite::params![format!("run-{memory_id}"), memory_id, ts],
840        )
841        .expect("insert citation");
842    }
843
844    /// The whole point: the surfaces are scored apart, so a strong one cannot
845    /// launder a weak one's noise.
846    #[test]
847    fn surfaces_are_scored_separately() {
848        let conn = surface_conn();
849        // Reactive: two injections, both cited.
850        for id in ["post-a", "post-b"] {
851            log_injection(&conn, id, Some("posttool"), true, "2026-01-01T00:00:00Z");
852            cite(&conn, id, "2026-01-01T00:01:00Z");
853        }
854        // Predictive: two injections, neither cited.
855        for id in ["pre-a", "pre-b"] {
856            log_injection(
857                &conn,
858                id,
859                Some("pretool_prefetch"),
860                true,
861                "2026-01-01T00:00:00Z",
862            );
863        }
864
865        let stats = surface_acceptance(&conn).expect("stats");
866        let post = stats
867            .iter()
868            .find(|s| s.surface == Surface::PostTool.as_str())
869            .expect("posttool");
870        let pre = stats
871            .iter()
872            .find(|s| s.surface == Surface::PreToolPrefetch.as_str())
873            .expect("prefetch");
874        assert_eq!((post.injected, post.cited), (2, 2));
875        assert_eq!((pre.injected, pre.cited), (2, 0));
876        assert!((post.acceptance() - 1.0).abs() < f32::EPSILON);
877        assert!(pre.acceptance() == 0.0);
878    }
879
880    /// A zero denominator is not a measurement, and printing 0% for a surface
881    /// nobody has exercised reads as a verdict on it.
882    #[test]
883    fn an_unexercised_surface_is_omitted_rather_than_scored_zero() {
884        let conn = surface_conn();
885        log_injection(
886            &conn,
887            "post-a",
888            Some("posttool"),
889            true,
890            "2026-01-01T00:00:00Z",
891        );
892        let stats = surface_acceptance(&conn).expect("stats");
893        assert_eq!(stats.len(), 1, "got: {stats:?}");
894        assert_eq!(stats[0].surface, Surface::PostTool.as_str());
895    }
896
897    /// A suppressed injection has no outcome to observe, so it cannot speak to
898    /// whether a surface interrupts usefully.
899    #[test]
900    fn suppressed_injections_do_not_count_against_a_surface() {
901        let conn = surface_conn();
902        log_injection(
903            &conn,
904            "post-a",
905            Some("posttool"),
906            false,
907            "2026-01-01T00:00:00Z",
908        );
909        assert!(surface_acceptance(&conn).expect("stats").is_empty());
910    }
911
912    /// Events written before surfaces were recorded belong to no surface.
913    /// Bucketing them into a default would credit one surface with another's
914    /// history, which is the error this whole split exists to avoid.
915    #[test]
916    fn history_from_before_surfaces_is_dropped_not_defaulted() {
917        let conn = surface_conn();
918        log_injection(&conn, "old-a", None, true, "2026-01-01T00:00:00Z");
919        cite(&conn, "old-a", "2026-01-01T00:01:00Z");
920        assert!(surface_acceptance(&conn).expect("stats").is_empty());
921    }
922
923    /// A citation that predates the injection is not evidence the injection
924    /// caused it.
925    #[test]
926    fn only_citations_after_the_injection_count() {
927        let conn = surface_conn();
928        cite(&conn, "post-a", "2025-12-01T00:00:00Z");
929        log_injection(
930            &conn,
931            "post-a",
932            Some("posttool"),
933            true,
934            "2026-01-01T00:00:00Z",
935        );
936        let stats = surface_acceptance(&conn).expect("stats");
937        assert_eq!(stats[0].cited, 0, "got: {stats:?}");
938    }
939
940    #[test]
941    fn surface_strings_round_trip() {
942        for surface in [
943            Surface::PreToolCommand,
944            Surface::PreToolPrefetch,
945            Surface::PostTool,
946        ] {
947            assert_eq!(Surface::from_str(surface.as_str()), Some(surface));
948        }
949        assert!(Surface::from_str("something_else").is_none());
950    }
951}