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}