Skip to main content

lean_ctx/core/skillify/
candidate.rs

1//! Skill candidates mined from the project's session diary + knowledge facts.
2//!
3//! A candidate is a *distilled, potentially-reusable* pattern. We never invent
4//! content — every candidate is backed by a real diary entry or knowledge fact.
5
6use std::collections::HashMap;
7
8use crate::core::agents::{AgentDiary, DiaryEntryType};
9use crate::core::knowledge::ProjectKnowledge;
10
11/// A distilled pattern mined from a project's memory, before the gate decides
12/// whether it is durable enough to become a rule.
13#[derive(Debug, Clone)]
14pub struct SkillCandidate {
15    /// Normalized, prefix-free slug (e.g. `stop-before-build`).
16    pub slug: String,
17    /// One-line human title (becomes the rule `description`).
18    pub title: String,
19    /// The actionable body of the rule.
20    pub body: String,
21    /// Origin category (decision / insight / convention / …).
22    pub category: String,
23    /// How many sources reinforced this pattern (confirmations + mentions).
24    pub recurrence: u32,
25    /// Highest source confidence (0.0..=1.0).
26    pub confidence: f32,
27    /// Provenance: source session ids / agent ids.
28    pub sources: Vec<String>,
29    /// The stored items the candidate was distilled from, for the source
30    /// binding of its rule file (`provenance`).
31    pub derivation: Derivation,
32}
33
34/// What a candidate was distilled from: identities only, no source is named
35/// here. The sources are those the knowledge store bound each fact to, asked
36/// when the rule is written (`provenance::prepare`).
37#[derive(Debug, Clone, Default, PartialEq, Eq)]
38pub struct Derivation {
39    /// Project root as the knowledge store names it (the key of its facts).
40    pub store: String,
41    /// `(category, key, value)` of every fact merged into the candidate.
42    pub facts: Vec<(String, String, String)>,
43    /// A part without a source on record was merged in (a diary entry): the
44    /// rule is originless, whatever its facts are bound to.
45    pub unbound: bool,
46}
47
48impl Derivation {
49    fn of_fact(store: &str, category: &str, key: &str, value: &str) -> Self {
50        Self {
51            store: store.to_owned(),
52            facts: vec![(category.to_owned(), key.to_owned(), value.to_owned())],
53            unbound: false,
54        }
55    }
56
57    fn unbound() -> Self {
58        Self {
59            unbound: true,
60            ..Self::default()
61        }
62    }
63
64    /// A merged candidate derives from every part: all of them must hold.
65    fn join(&mut self, other: Self) {
66        if self.store.is_empty() {
67            self.store = other.store;
68        }
69        self.unbound |= other.unbound;
70        for fact in other.facts {
71            if !self.facts.contains(&fact) {
72                self.facts.push(fact);
73            }
74        }
75    }
76}
77
78/// Knowledge categories that can become durable guidance. Progress and raw
79/// blockers are status, not reusable rules, so they are excluded.
80fn knowledge_category_is_skillable(cat: &str) -> bool {
81    matches!(
82        cat.to_ascii_lowercase().as_str(),
83        "decision" | "insight" | "gotcha" | "pattern" | "convention" | "preference"
84    )
85}
86
87/// Mine candidates from the project's knowledge facts and agent diaries,
88/// merged by slug (recurrence summed, strongest body + confidence kept).
89pub fn mine_candidates(project_root: &str) -> Vec<SkillCandidate> {
90    let mut by_slug: HashMap<String, SkillCandidate> = HashMap::new();
91
92    // Source 1 — curated knowledge facts (already deduped by the knowledge layer).
93    // A candidate's body is the fact's value: only facts that may be
94    // delivered now are mined (`ProjectKnowledge::delivery_filter`).
95    let knowledge = ProjectKnowledge::load_or_create(project_root);
96    let deliverable = knowledge.delivery_filter();
97    for fact in &knowledge.facts {
98        // A skill is an instruction to agents: other members' knowledge
99        // pulled from Hive is data (X5) and never becomes one.
100        if !knowledge_category_is_skillable(&fact.category)
101            || !deliverable(fact)
102            || fact.hive_origin().is_some()
103        {
104            continue;
105        }
106        let title = title_from(&fact.key, &fact.value);
107        let slug = slugify(&title);
108        if slug.is_empty() {
109            continue;
110        }
111        let sources: Vec<String> = if fact.source_session.is_empty() {
112            Vec::new()
113        } else {
114            vec![fact.source_session.clone()]
115        };
116        merge_candidate(
117            &mut by_slug,
118            SkillCandidate {
119                slug,
120                title,
121                body: fact.value.trim().to_string(),
122                category: fact.category.to_ascii_lowercase(),
123                // Confirmations are explicit reinforcement; floor at 1.
124                recurrence: fact.confirmation_count.max(1),
125                confidence: fact.confidence,
126                sources,
127                derivation: Derivation::of_fact(
128                    &knowledge.project_root,
129                    &fact.category,
130                    &fact.key,
131                    &fact.value,
132                ),
133            },
134        );
135    }
136
137    // Source 2 — diary decisions / insights / discoveries across the project's agents.
138    for diary in AgentDiary::load_all_for_project(project_root) {
139        for entry in &diary.entries {
140            let category = match entry.entry_type {
141                DiaryEntryType::Decision => "decision",
142                DiaryEntryType::Insight => "insight",
143                DiaryEntryType::Discovery => "discovery",
144                DiaryEntryType::Progress | DiaryEntryType::Blocker => continue,
145            };
146            let body = entry.content.trim().to_string();
147            if body.is_empty() {
148                continue;
149            }
150            let title = title_from("", &body);
151            let slug = slugify(&title);
152            if slug.is_empty() {
153                continue;
154            }
155            merge_candidate(
156                &mut by_slug,
157                SkillCandidate {
158                    slug,
159                    title,
160                    body,
161                    category: category.to_string(),
162                    recurrence: 1,
163                    confidence: 0.6,
164                    sources: vec![diary.agent_id.clone()],
165                    derivation: Derivation::unbound(),
166                },
167            );
168        }
169    }
170
171    let mut out: Vec<SkillCandidate> = by_slug.into_values().collect();
172    out.sort_by(|a, b| {
173        b.recurrence
174            .cmp(&a.recurrence)
175            .then(b.confidence.total_cmp(&a.confidence))
176            .then(a.slug.cmp(&b.slug))
177    });
178    out
179}
180
181fn merge_candidate(map: &mut HashMap<String, SkillCandidate>, cand: SkillCandidate) {
182    if let Some(existing) = map.get_mut(&cand.slug) {
183        existing.recurrence = existing.recurrence.saturating_add(cand.recurrence);
184        existing.derivation.join(cand.derivation);
185        if cand.confidence > existing.confidence {
186            existing.confidence = cand.confidence;
187        }
188        // Prefer the longer, more informative body.
189        if cand.body.len() > existing.body.len() {
190            existing.body = cand.body;
191        }
192        for s in cand.sources {
193            if !s.is_empty() && !existing.sources.contains(&s) {
194                existing.sources.push(s);
195            }
196        }
197    } else {
198        map.insert(cand.slug.clone(), cand);
199    }
200}
201
202/// A concise title: prefer a short fact key, else the first sentence of the body.
203fn title_from(key: &str, value: &str) -> String {
204    let k = key.trim();
205    if !k.is_empty() && k.chars().count() <= 80 {
206        return k.to_string();
207    }
208    first_sentence(value)
209}
210
211fn first_sentence(text: &str) -> String {
212    let t = text.trim();
213    let end = t.find(['.', '\n', '!', '?']).unwrap_or(t.len());
214    let candidate = t[..end].trim();
215    let s = if candidate.is_empty() { t } else { candidate };
216    truncate_chars(s, 80)
217}
218
219/// URL/file-safe slug: lowercase alphanumerics, single dashes, trimmed, capped.
220pub(crate) fn slugify(text: &str) -> String {
221    let mut slug = String::new();
222    let mut prev_dash = false;
223    for ch in text.trim().to_ascii_lowercase().chars() {
224        if ch.is_ascii_alphanumeric() {
225            slug.push(ch);
226            prev_dash = false;
227        } else if !slug.is_empty() && !prev_dash {
228            slug.push('-');
229            prev_dash = true;
230        }
231    }
232    let trimmed = slug.trim_matches('-');
233    truncate_chars(trimmed, 50).trim_matches('-').to_string()
234}
235
236fn truncate_chars(s: &str, max: usize) -> String {
237    if s.chars().count() <= max {
238        return s.to_string();
239    }
240    s.chars().take(max).collect()
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn slugify_normalizes() {
249        assert_eq!(slugify("Stop before Build!"), "stop-before-build");
250        assert_eq!(slugify("  multiple   spaces  "), "multiple-spaces");
251        assert_eq!(slugify("___weird@@@chars###"), "weird-chars");
252        assert_eq!(slugify(""), "");
253    }
254
255    #[test]
256    fn first_sentence_truncates() {
257        assert_eq!(
258            first_sentence("Use atomic writes. And more."),
259            "Use atomic writes"
260        );
261        assert!(first_sentence(&"x".repeat(200)).chars().count() <= 80);
262    }
263
264    #[test]
265    fn merge_sums_recurrence_and_keeps_strongest() {
266        let mut map = HashMap::new();
267        merge_candidate(
268            &mut map,
269            SkillCandidate {
270                slug: "s".into(),
271                title: "t".into(),
272                body: "short".into(),
273                category: "decision".into(),
274                recurrence: 1,
275                confidence: 0.5,
276                sources: vec!["a".into()],
277                derivation: Derivation::default(),
278            },
279        );
280        merge_candidate(
281            &mut map,
282            SkillCandidate {
283                slug: "s".into(),
284                title: "t".into(),
285                body: "a much longer body".into(),
286                category: "decision".into(),
287                recurrence: 2,
288                confidence: 0.9,
289                sources: vec!["b".into()],
290                derivation: Derivation::default(),
291            },
292        );
293        let c = &map["s"];
294        assert_eq!(c.recurrence, 3);
295        assert_eq!(c.confidence, 0.9);
296        assert_eq!(c.body, "a much longer body");
297        assert_eq!(c.sources, vec!["a".to_string(), "b".to_string()]);
298    }
299
300    /// Only facts that are served now are mined into skill candidates.
301    #[test]
302    fn a_fact_that_is_not_served_is_not_mined() {
303        use crate::core::context_admission::stored::lineage_support::{
304            BOUND_FACT, TYPED_FACT, governed, project, scoped, two_facts, withdraw_source,
305        };
306        let _data = governed();
307        let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
308        let (_directory, root) = project();
309        two_facts(&root, "decision");
310        let bodies = || -> Vec<String> {
311            scoped(&root, || mine_candidates(&root))
312                .into_iter()
313                .map(|candidate| candidate.body)
314                .collect()
315        };
316        let before = bodies();
317        assert!(before.contains(&BOUND_FACT.to_owned()), "{before:?}");
318        assert!(before.contains(&TYPED_FACT.to_owned()), "{before:?}");
319        withdraw_source(&root);
320        assert_eq!(bodies(), [TYPED_FACT.to_owned()]);
321    }
322}