Skip to main content

lean_ctx/core/skillify/
gate.rs

1//! Precision-biased gate: decide whether a candidate is durable enough to codify.
2//!
3//! Bias is toward *precision over recall* (per the capture philosophy): a missed
4//! skill is invisible, but a wrong one erodes trust in the whole rule set. The
5//! MERGE-vs-CREATE distinction lives in `rule_file` (it depends on what is
6//! already on disk); this gate only decides KEEP vs SKIP.
7
8use super::candidate::SkillCandidate;
9
10/// Outcome of judging a single candidate.
11#[derive(Debug, Clone, PartialEq)]
12pub enum Verdict {
13    /// Worth codifying (create or merge handled by the writer).
14    Keep,
15    /// Rejected, with a human-readable reason.
16    Skip(String),
17}
18
19/// Bodies shorter than this carry too little to be a rule.
20const MIN_BODY_LEN: usize = 25;
21
22/// Generic, low-information phrasings that must never become a rule.
23const NOISE: [&str; 9] = [
24    "fixed bug",
25    "updated code",
26    "wip",
27    "work in progress",
28    "todo",
29    "refactored",
30    "cleanup",
31    "minor change",
32    "various changes",
33];
34
35fn is_generic(body: &str) -> bool {
36    let trimmed = body.trim().to_ascii_lowercase();
37    NOISE.iter().any(|n| {
38        trimmed == *n || (trimmed.starts_with(n) && trimmed.chars().count() < n.len() + 12)
39    })
40}
41
42/// Judge a candidate against the configured thresholds.
43pub fn judge(c: &SkillCandidate, min_confidence: f32, min_recurrence: u32) -> Verdict {
44    if c.body.chars().count() < MIN_BODY_LEN {
45        return Verdict::Skip("too short".to_string());
46    }
47    if is_generic(&c.body) {
48        return Verdict::Skip("generic / one-off phrasing".to_string());
49    }
50    // Codify when it recurs enough OR is high-confidence curated knowledge.
51    if c.recurrence >= min_recurrence || c.confidence >= min_confidence {
52        Verdict::Keep
53    } else {
54        Verdict::Skip(format!(
55            "one-off (recurrence {} < {min_recurrence}, confidence {:.2} < {min_confidence:.2})",
56            c.recurrence, c.confidence
57        ))
58    }
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64
65    fn cand(body: &str, recurrence: u32, confidence: f32) -> SkillCandidate {
66        SkillCandidate {
67            slug: "s".into(),
68            title: "t".into(),
69            body: body.into(),
70            category: "decision".into(),
71            recurrence,
72            confidence,
73            sources: vec![],
74        }
75    }
76
77    #[test]
78    fn rejects_too_short() {
79        assert!(matches!(
80            judge(&cand("short", 5, 0.9), 0.7, 2),
81            Verdict::Skip(_)
82        ));
83    }
84
85    #[test]
86    fn rejects_generic() {
87        assert!(matches!(
88            judge(&cand("fixed bug", 5, 0.9), 0.7, 2),
89            Verdict::Skip(_)
90        ));
91    }
92
93    #[test]
94    fn keeps_recurring_pattern() {
95        let c = cand(
96            "Always run lean-ctx stop before building the binary.",
97            2,
98            0.5,
99        );
100        assert_eq!(judge(&c, 0.7, 2), Verdict::Keep);
101    }
102
103    #[test]
104    fn keeps_high_confidence_single() {
105        let c = cand(
106            "Always run lean-ctx stop before building the binary.",
107            1,
108            0.85,
109        );
110        assert_eq!(judge(&c, 0.7, 2), Verdict::Keep);
111    }
112
113    #[test]
114    fn skips_low_signal_oneoff() {
115        let c = cand(
116            "Always run lean-ctx stop before building the binary.",
117            1,
118            0.5,
119        );
120        assert!(matches!(judge(&c, 0.7, 2), Verdict::Skip(_)));
121    }
122}