Skip to main content

kimetsu_brain/
skill_synthesis.rs

1//! Flagship 2 — Memory → Skill synthesis: candidate detection.
2//!
3//! # Trigger (2.1)
4//!
5//! A memory becomes a "synthesis candidate" when either:
6//! - It has been explicitly cited via `memory.cited` events **≥ 3 times**
7//!   across distinct runs (`CITATION_THRESHOLD`), OR
8//! - It participates in a loose semantic cluster (`find_distill_clusters`)
9//!   of ≥ 3 related lessons sharing at least one domain tag — indicating a
10//!   repeated domain pattern worth promoting to a reusable skill.
11//!
12//! Detection is **pure query / counter** — no model cost.
13//!
14//! # Proposal store (2.3)
15//!
16//! Accepted candidates land in `skill_proposals` (v6 schema migration).
17//! The proposal carries the source memory ids as provenance.
18//!
19//! # Staleness (2.4)
20//!
21//! A skill is STALE when any source memory in its provenance list has been
22//! superseded or invalidated.  `staleness_check` returns which ids are stale.
23
24use std::collections::HashMap;
25
26use rusqlite::{Connection, OptionalExtension, params};
27use time::OffsetDateTime;
28use time::format_description::well_known::Rfc3339;
29
30use kimetsu_core::KimetsuResult;
31
32// ---------------------------------------------------------------------------
33// Constants
34// ---------------------------------------------------------------------------
35
36/// Minimum number of distinct-run citations for a memory to become a synthesis
37/// candidate via the citation path.
38pub const CITATION_THRESHOLD: i64 = 3;
39
40// ---------------------------------------------------------------------------
41// Public data types
42// ---------------------------------------------------------------------------
43
44/// A memory that qualifies for skill synthesis.
45#[derive(Debug, Clone)]
46pub struct SynthesisCandidate {
47    pub memory_id: String,
48    pub scope: String,
49    pub kind: String,
50    pub text: String,
51    /// `"citations"` — cited ≥ CITATION_THRESHOLD times across distinct runs.
52    /// `"cluster"` — member of a tight semantic cluster of ≥ 3 lessons.
53    pub trigger_kind: String,
54    /// Citation count (for `"citations"` trigger) or cluster size.
55    pub trigger_count: i64,
56}
57
58/// A persisted skill proposal (pending or decided).
59#[derive(Debug, Clone)]
60pub struct SkillProposalRow {
61    pub proposal_id: String,
62    pub skill_name: String,
63    pub description: String,
64    /// The drafted SKILL.md content, `None` in report-only (no-model) mode.
65    pub draft_content: Option<String>,
66    /// JSON-serialized list of source memory ids.
67    pub source_memory_ids: Vec<String>,
68    pub trigger_kind: String,
69    pub trigger_count: i64,
70    /// `"pending"` | `"accepted"` | `"rejected"`
71    pub status: String,
72    pub decided_at: Option<String>,
73    pub installed_path: Option<String>,
74    pub created_at: String,
75}
76
77/// Result of a staleness check for an installed skill.
78#[derive(Debug, Clone)]
79pub struct StalenessReport {
80    pub proposal_id: String,
81    pub skill_name: String,
82    pub installed_path: Option<String>,
83    /// Memory ids from the skill's provenance that are now stale
84    /// (superseded or invalidated).
85    pub stale_memory_ids: Vec<String>,
86    pub is_stale: bool,
87}
88
89// ---------------------------------------------------------------------------
90// 2.1 — Candidate detection (pure query, no model cost)
91// ---------------------------------------------------------------------------
92
93/// v2.6: what the skills loop is waiting on, as one line for the warm start.
94///
95/// Detection is a pure query and has run on a schedule since the maintenance
96/// daemon landed — but its result went into a log file nobody opens. So a
97/// memory could cross the citation threshold, sit there indefinitely, and never
98/// become a skill, because closing the loop needed a person who was never told
99/// there was anything to close. `find_synthesis_candidates` having exactly one
100/// caller (the `brain skills` CLI) was the same problem stated as a call graph.
101///
102/// Two things are worth a session's attention, and nothing else is:
103///
104/// - **Candidates with no proposal yet** — memories that have earned skill
105///   status and are waiting for `brain skills --detect` to draft them.
106/// - **Pending proposals** — drafts waiting for an accept or reject.
107///
108/// A candidate that already has a pending or accepted proposal is *not*
109/// reported as a candidate: the loop has moved on, and repeating it would
110/// double-count the same memory in two halves of the same line. That is the
111/// same rule `run_skill_synthesis` uses to stay idempotent.
112///
113/// Returns `None` when there is nothing to act on, which is the common case —
114/// the warm start pays for this block on every session, so it must be silent
115/// unless it has something to say.
116pub fn graduation_notice(conn: &Connection) -> Option<String> {
117    let pending = conn
118        .query_row(
119            "SELECT COUNT(*) FROM skill_proposals WHERE status = 'pending'",
120            [],
121            |row| row.get::<_, i64>(0),
122        )
123        .unwrap_or(0);
124
125    let undrafted = find_synthesis_candidates(conn)
126        .unwrap_or_default()
127        .into_iter()
128        .filter(|candidate| !has_open_proposal(conn, &candidate.memory_id))
129        .count();
130
131    let mut parts: Vec<String> = Vec::new();
132    if undrafted > 0 {
133        parts.push(format!(
134            "{undrafted} {} earned skill status (`kimetsu brain skills --detect`)",
135            plural(undrafted, "memory has", "memories have"),
136        ));
137    }
138    if pending > 0 {
139        parts.push(format!(
140            "{pending} skill {} awaiting review (`kimetsu brain skills --list`)",
141            plural(pending as usize, "proposal is", "proposals are"),
142        ));
143    }
144    if parts.is_empty() {
145        return None;
146    }
147    Some(parts.join("; "))
148}
149
150/// True when this memory already has a pending or accepted proposal.
151///
152/// The substring match on the JSON column mirrors `run_skill_synthesis`'s
153/// existing duplicate check; a ULID is long enough that a false match is not a
154/// practical concern, and the cost of one is a line that under-reports by one.
155fn has_open_proposal(conn: &Connection, memory_id: &str) -> bool {
156    conn.query_row(
157        "SELECT COUNT(*) FROM skill_proposals
158         WHERE status IN ('pending', 'accepted')
159           AND source_memory_ids_json LIKE ?1",
160        params![format!("%{memory_id}%")],
161        |row| row.get::<_, i64>(0),
162    )
163    .unwrap_or(0)
164        > 0
165}
166
167fn plural(n: usize, one: &'static str, many: &'static str) -> &'static str {
168    if n == 1 { one } else { many }
169}
170
171/// Find all synthesis candidates in `conn`.
172///
173/// Path 1 — citation count: memories cited ≥ `CITATION_THRESHOLD` times
174/// across distinct runs, not superseded or invalidated.
175///
176/// Path 2 — cluster trigger: for any tight semantic cluster of ≥ 3 memories
177/// sharing a domain tag (from `find_distill_clusters`), the representative
178/// memory of each cluster is returned as a cluster candidate, carrying all
179/// cluster member ids in a comma-separated `text` prefix. This path only
180/// fires when embeddings are present.
181///
182/// Results are deduplicated by `memory_id` (citation path wins on conflict).
183pub fn find_synthesis_candidates(conn: &Connection) -> KimetsuResult<Vec<SynthesisCandidate>> {
184    let mut candidates: HashMap<String, SynthesisCandidate> = HashMap::new();
185
186    // --- Path 1: citation count ≥ CITATION_THRESHOLD --------------------
187    let mut stmt = conn.prepare(
188        "SELECT mc.memory_id,
189                COUNT(DISTINCT mc.run_id) AS cite_count,
190                m.scope, m.kind, m.text
191         FROM memory_citations mc
192         JOIN memories m ON m.memory_id = mc.memory_id
193         WHERE m.invalidated_at IS NULL
194           AND m.superseded_by IS NULL
195         GROUP BY mc.memory_id
196         HAVING COUNT(DISTINCT mc.run_id) >= ?1
197         ORDER BY cite_count DESC",
198    )?;
199    let rows = stmt.query_map(params![CITATION_THRESHOLD], |row| {
200        Ok((
201            row.get::<_, String>(0)?,
202            row.get::<_, i64>(1)?,
203            row.get::<_, String>(2)?,
204            row.get::<_, String>(3)?,
205            row.get::<_, String>(4)?,
206        ))
207    })?;
208    for row in rows {
209        let (memory_id, cite_count, scope, kind, text) = row?;
210        candidates
211            .entry(memory_id.clone())
212            .or_insert(SynthesisCandidate {
213                memory_id,
214                scope,
215                kind,
216                text,
217                trigger_kind: "citations".to_string(),
218                trigger_count: cite_count,
219            });
220    }
221
222    // --- Path 2: tight semantic cluster (embeddings optional) -----------
223    // Load embeddable rows and run find_distill_clusters.  If no
224    // embeddings are present, by_model will be empty and this path is
225    // silently skipped — no hard failure.
226    if let Ok(by_model) = crate::consolidate::load_embeddable_rows(conn) {
227        let all_rows: Vec<crate::consolidate::ConsolidateRow> =
228            by_model.into_values().flatten().collect();
229        let opts = crate::consolidate::DistillOptions {
230            lo: 0.75,
231            hi: 0.92,
232            min_cluster_size: 3,
233        };
234        let clusters = crate::consolidate::find_distill_clusters(&all_rows, &opts);
235        for cluster in clusters {
236            // Use the first memory in the cluster as the representative.
237            if let Some(rep) = cluster.memories.first() {
238                if !candidates.contains_key(&rep.memory_id) {
239                    candidates.insert(
240                        rep.memory_id.clone(),
241                        SynthesisCandidate {
242                            memory_id: rep.memory_id.clone(),
243                            scope: rep.scope.clone(),
244                            kind: rep.kind.clone(),
245                            text: rep.text.clone(),
246                            trigger_kind: "cluster".to_string(),
247                            trigger_count: cluster.memories.len() as i64,
248                        },
249                    );
250                }
251            }
252        }
253    }
254
255    let mut result: Vec<SynthesisCandidate> = candidates.into_values().collect();
256    // Stable order: citations first, then cluster; within each group by count desc.
257    result.sort_by(|a, b| {
258        a.trigger_kind
259            .cmp(&b.trigger_kind)
260            .then_with(|| b.trigger_count.cmp(&a.trigger_count))
261    });
262    Ok(result)
263}
264
265// ---------------------------------------------------------------------------
266// 2.1 helper: fetch the cited memory texts for a set of memory ids
267// ---------------------------------------------------------------------------
268
269/// Return (memory_id, text) pairs for the given ids, excluding superseded /
270/// invalidated rows.  Used by the drafter to assemble the grounded prompt.
271pub fn load_memory_texts(
272    conn: &Connection,
273    memory_ids: &[String],
274) -> KimetsuResult<Vec<(String, String)>> {
275    if memory_ids.is_empty() {
276        return Ok(Vec::new());
277    }
278    let placeholders: Vec<String> = (1..=memory_ids.len()).map(|i| format!("?{i}")).collect();
279    let sql = format!(
280        "SELECT memory_id, text FROM memories
281         WHERE memory_id IN ({})
282           AND invalidated_at IS NULL
283           AND superseded_by IS NULL",
284        placeholders.join(", ")
285    );
286    let mut stmt = conn.prepare(&sql)?;
287    let params_iter: Vec<&dyn rusqlite::types::ToSql> = memory_ids
288        .iter()
289        .map(|s| s as &dyn rusqlite::types::ToSql)
290        .collect();
291    let rows = stmt.query_map(params_iter.as_slice(), |row| {
292        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
293    })?;
294    let mut out = Vec::new();
295    for row in rows {
296        out.push(row?);
297    }
298    Ok(out)
299}
300
301/// Load memory texts for all memories that are cited ≥ `CITATION_THRESHOLD`
302/// times for a given `memory_id` candidate. Also returns the distinct-run
303/// citation count for that memory.
304pub fn load_candidate_with_related(
305    conn: &Connection,
306    memory_id: &str,
307) -> KimetsuResult<(i64, Vec<(String, String)>)> {
308    // Citation count for this specific memory.
309    let cite_count: i64 = conn
310        .query_row(
311            "SELECT COUNT(DISTINCT run_id) FROM memory_citations WHERE memory_id = ?1",
312            params![memory_id],
313            |r| r.get(0),
314        )
315        .unwrap_or(0);
316
317    // The candidate memory + any memories in the same cluster (same tags
318    // and cosine band). For the simple path we just load the candidate itself.
319    let texts = load_memory_texts(conn, &[memory_id.to_string()])?;
320    Ok((cite_count, texts))
321}
322
323// ---------------------------------------------------------------------------
324// 2.3 — Proposal CRUD
325// ---------------------------------------------------------------------------
326
327/// Insert a new skill proposal into the database.  Returns the proposal_id.
328pub fn insert_skill_proposal(
329    conn: &Connection,
330    skill_name: &str,
331    description: &str,
332    draft_content: Option<&str>,
333    source_memory_ids: &[String],
334    trigger_kind: &str,
335    trigger_count: i64,
336) -> KimetsuResult<String> {
337    use ulid::Ulid;
338
339    let proposal_id = Ulid::new().to_string();
340    let source_ids_json =
341        serde_json::to_string(source_memory_ids).unwrap_or_else(|_| "[]".to_string());
342    let now = OffsetDateTime::now_utc()
343        .format(&Rfc3339)
344        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
345
346    conn.execute(
347        "INSERT INTO skill_proposals
348             (proposal_id, skill_name, description, draft_content,
349              source_memory_ids_json, trigger_kind, trigger_count,
350              status, created_at)
351         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'pending', ?8)",
352        params![
353            proposal_id,
354            skill_name,
355            description,
356            draft_content,
357            source_ids_json,
358            trigger_kind,
359            trigger_count,
360            now,
361        ],
362    )?;
363    Ok(proposal_id)
364}
365
366/// List skill proposals, optionally filtered by `status`.
367pub fn list_skill_proposals(
368    conn: &Connection,
369    status_filter: Option<&str>,
370) -> KimetsuResult<Vec<SkillProposalRow>> {
371    let sql = if status_filter.is_some() {
372        "SELECT proposal_id, skill_name, description, draft_content,
373                source_memory_ids_json, trigger_kind, trigger_count,
374                status, decided_at, installed_path, created_at
375         FROM skill_proposals
376         WHERE status = ?1
377         ORDER BY created_at DESC"
378    } else {
379        "SELECT proposal_id, skill_name, description, draft_content,
380                source_memory_ids_json, trigger_kind, trigger_count,
381                status, decided_at, installed_path, created_at
382         FROM skill_proposals
383         ORDER BY created_at DESC"
384    };
385
386    let mut stmt = conn.prepare(sql)?;
387    let rows = if let Some(sf) = status_filter {
388        stmt.query_map(params![sf], parse_proposal_row)?
389    } else {
390        stmt.query_map([], parse_proposal_row)?
391    };
392
393    let mut out = Vec::new();
394    for row in rows {
395        out.push(row?);
396    }
397    Ok(out)
398}
399
400/// Load one proposal by id.
401pub fn load_skill_proposal(
402    conn: &Connection,
403    proposal_id: &str,
404) -> KimetsuResult<Option<SkillProposalRow>> {
405    conn.query_row(
406        "SELECT proposal_id, skill_name, description, draft_content,
407                source_memory_ids_json, trigger_kind, trigger_count,
408                status, decided_at, installed_path, created_at
409         FROM skill_proposals
410         WHERE proposal_id = ?1",
411        params![proposal_id],
412        parse_proposal_row,
413    )
414    .optional()
415    .map_err(Into::into)
416}
417
418/// Mark a proposal as accepted and record where the skill was installed.
419pub fn accept_skill_proposal(
420    conn: &Connection,
421    proposal_id: &str,
422    installed_path: &str,
423) -> KimetsuResult<()> {
424    let now = OffsetDateTime::now_utc()
425        .format(&Rfc3339)
426        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
427    let updated = conn.execute(
428        "UPDATE skill_proposals
429         SET status = 'accepted', decided_at = ?1, installed_path = ?2
430         WHERE proposal_id = ?3 AND status = 'pending'",
431        params![now, installed_path, proposal_id],
432    )?;
433    if updated == 0 {
434        return Err(format!("proposal `{proposal_id}` not found or already decided").into());
435    }
436    Ok(())
437}
438
439/// Mark a proposal as rejected.
440pub fn reject_skill_proposal(conn: &Connection, proposal_id: &str) -> KimetsuResult<()> {
441    let now = OffsetDateTime::now_utc()
442        .format(&Rfc3339)
443        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string());
444    let updated = conn.execute(
445        "UPDATE skill_proposals
446         SET status = 'rejected', decided_at = ?1
447         WHERE proposal_id = ?2 AND status = 'pending'",
448        params![now, proposal_id],
449    )?;
450    if updated == 0 {
451        return Err(format!("proposal `{proposal_id}` not found or already decided").into());
452    }
453    Ok(())
454}
455
456// ---------------------------------------------------------------------------
457// 2.4 — Staleness check
458// ---------------------------------------------------------------------------
459
460/// Check all accepted skill proposals for staleness.
461///
462/// A skill is STALE when any source memory in its provenance is now
463/// superseded (`superseded_by IS NOT NULL`) or invalidated
464/// (`invalidated_at IS NOT NULL`).
465pub fn check_staleness(conn: &Connection) -> KimetsuResult<Vec<StalenessReport>> {
466    let accepted = list_skill_proposals(conn, Some("accepted"))?;
467    let mut reports = Vec::new();
468
469    for proposal in accepted {
470        if proposal.source_memory_ids.is_empty() {
471            reports.push(StalenessReport {
472                proposal_id: proposal.proposal_id.clone(),
473                skill_name: proposal.skill_name.clone(),
474                installed_path: proposal.installed_path.clone(),
475                stale_memory_ids: Vec::new(),
476                is_stale: false,
477            });
478            continue;
479        }
480
481        let mut stale_ids = Vec::new();
482        for mid in &proposal.source_memory_ids {
483            let is_stale: bool = conn
484                .query_row(
485                    "SELECT (superseded_by IS NOT NULL OR invalidated_at IS NOT NULL)
486                     FROM memories WHERE memory_id = ?1",
487                    params![mid],
488                    |r| r.get::<_, bool>(0),
489                )
490                .unwrap_or(false); // memory_id not found → not stale (may be user-brain memory)
491            if is_stale {
492                stale_ids.push(mid.clone());
493            }
494        }
495
496        let is_stale = !stale_ids.is_empty();
497        reports.push(StalenessReport {
498            proposal_id: proposal.proposal_id,
499            skill_name: proposal.skill_name,
500            installed_path: proposal.installed_path,
501            stale_memory_ids: stale_ids,
502            is_stale,
503        });
504    }
505
506    Ok(reports)
507}
508
509// ---------------------------------------------------------------------------
510// Internal helpers
511// ---------------------------------------------------------------------------
512
513fn parse_proposal_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<SkillProposalRow> {
514    let source_ids_json: String = row.get(4)?;
515    let source_memory_ids: Vec<String> = serde_json::from_str(&source_ids_json).unwrap_or_default();
516    Ok(SkillProposalRow {
517        proposal_id: row.get(0)?,
518        skill_name: row.get(1)?,
519        description: row.get(2)?,
520        draft_content: row.get(3)?,
521        source_memory_ids,
522        trigger_kind: row.get(5)?,
523        trigger_count: row.get(6)?,
524        status: row.get(7)?,
525        decided_at: row.get(8)?,
526        installed_path: row.get(9)?,
527        created_at: row.get(10)?,
528    })
529}
530
531// ---------------------------------------------------------------------------
532// Tests
533// ---------------------------------------------------------------------------
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538
539    fn init_conn() -> Connection {
540        let conn = Connection::open_in_memory().expect("open_in_memory");
541        crate::schema::initialize(&conn).expect("initialize");
542        conn
543    }
544
545    // -----------------------------------------------------------------------
546    // insert / list / load / accept / reject
547    // -----------------------------------------------------------------------
548
549    #[test]
550    fn insert_and_list_pending_proposals() {
551        let conn = init_conn();
552        let id = insert_skill_proposal(
553            &conn,
554            "my-skill",
555            "A test skill",
556            Some("# My Skill\nDo the thing."),
557            &["mem-1".to_string(), "mem-2".to_string()],
558            "citations",
559            4,
560        )
561        .expect("insert");
562        assert!(!id.is_empty());
563
564        let all = list_skill_proposals(&conn, None).expect("list all");
565        assert_eq!(all.len(), 1);
566        let row = &all[0];
567        assert_eq!(row.proposal_id, id);
568        assert_eq!(row.skill_name, "my-skill");
569        assert_eq!(row.description, "A test skill");
570        assert_eq!(
571            row.draft_content.as_deref(),
572            Some("# My Skill\nDo the thing.")
573        );
574        assert_eq!(row.source_memory_ids, vec!["mem-1", "mem-2"]);
575        assert_eq!(row.trigger_kind, "citations");
576        assert_eq!(row.trigger_count, 4);
577        assert_eq!(row.status, "pending");
578        assert!(row.installed_path.is_none());
579    }
580
581    #[test]
582    fn accept_proposal_records_installed_path() {
583        let conn = init_conn();
584        let id = insert_skill_proposal(
585            &conn,
586            "install-me",
587            "Install test",
588            Some("# Install Me"),
589            &["mem-a".to_string()],
590            "citations",
591            3,
592        )
593        .expect("insert");
594
595        accept_skill_proposal(&conn, &id, "/path/to/.kimetsu/skills/install-me").expect("accept");
596
597        let row = load_skill_proposal(&conn, &id)
598            .expect("load")
599            .expect("must exist");
600        assert_eq!(row.status, "accepted");
601        assert_eq!(
602            row.installed_path.as_deref(),
603            Some("/path/to/.kimetsu/skills/install-me")
604        );
605        assert!(row.decided_at.is_some());
606    }
607
608    #[test]
609    fn reject_proposal_marks_rejected() {
610        let conn = init_conn();
611        let id = insert_skill_proposal(&conn, "reject-me", "Reject test", None, &[], "cluster", 3)
612            .expect("insert");
613        reject_skill_proposal(&conn, &id).expect("reject");
614
615        let row = load_skill_proposal(&conn, &id)
616            .expect("load")
617            .expect("must exist");
618        assert_eq!(row.status, "rejected");
619    }
620
621    #[test]
622    fn accept_already_decided_proposal_errors() {
623        let conn = init_conn();
624        let id =
625            insert_skill_proposal(&conn, "dup", "dup", None, &[], "citations", 3).expect("insert");
626        accept_skill_proposal(&conn, &id, "/some/path").expect("first accept");
627        let err = accept_skill_proposal(&conn, &id, "/other").expect_err("double-accept");
628        assert!(
629            err.to_string().contains("already decided"),
630            "unexpected: {err}"
631        );
632    }
633
634    #[test]
635    fn report_only_proposal_has_no_draft_content() {
636        let conn = init_conn();
637        let id = insert_skill_proposal(
638            &conn,
639            "report-only",
640            "Report only",
641            None, // no draft
642            &["mem-x".to_string()],
643            "citations",
644            5,
645        )
646        .expect("insert");
647        let row = load_skill_proposal(&conn, &id)
648            .expect("load")
649            .expect("must exist");
650        assert!(
651            row.draft_content.is_none(),
652            "report-only must have no draft"
653        );
654    }
655
656    // -----------------------------------------------------------------------
657    // find_synthesis_candidates — citation path
658    // -----------------------------------------------------------------------
659
660    #[test]
661    fn candidate_detected_at_citation_threshold() {
662        let conn = init_conn();
663
664        // Insert one memory and cite it from 3 distinct runs.
665        conn.execute(
666            "INSERT INTO memories
667               (memory_id, scope, kind, text, normalized_text, confidence,
668                provenance_snapshot_json, created_at, use_count, usefulness_score)
669             VALUES ('hot-mem', 'project', 'convention', 'Always run fmt', 'always run fmt',
670                     0.9, '{}', '2026-01-01T00:00:00Z', 3, 3.0)",
671            [],
672        )
673        .expect("insert memory");
674
675        for run_id in ["run-1", "run-2", "run-3"] {
676            conn.execute(
677                "INSERT INTO memory_citations (run_id, memory_id, turn, cited_at)
678                 VALUES (?1, 'hot-mem', 1, '2026-01-01T00:00:00Z')",
679                params![run_id],
680            )
681            .expect("insert citation");
682        }
683
684        let candidates = find_synthesis_candidates(&conn).expect("find");
685        assert!(
686            candidates.iter().any(|c| c.memory_id == "hot-mem"),
687            "hot-mem must be a synthesis candidate"
688        );
689        let hot = candidates
690            .iter()
691            .find(|c| c.memory_id == "hot-mem")
692            .unwrap();
693        assert_eq!(hot.trigger_kind, "citations");
694        assert_eq!(hot.trigger_count, 3);
695    }
696
697    // -----------------------------------------------------------------------
698    // graduation_notice — v2.6, closing the skills loop
699    // -----------------------------------------------------------------------
700
701    /// Cite `memory_id` from `n` distinct runs, which is what earns skill status.
702    fn cite_from_distinct_runs(conn: &Connection, memory_id: &str, n: i64) {
703        conn.execute(
704            "INSERT INTO memories
705               (memory_id, scope, kind, text, normalized_text, confidence,
706                provenance_snapshot_json, created_at)
707             VALUES (?1, 'project', 'convention', 'Always run fmt', 'always run fmt',
708                     0.9, '{}', '2026-01-01T00:00:00Z')",
709            params![memory_id],
710        )
711        .expect("insert memory");
712        for run in 0..n {
713            conn.execute(
714                "INSERT INTO memory_citations (run_id, memory_id, turn, cited_at)
715                 VALUES (?1, ?2, 1, '2026-01-01T00:00:00Z')",
716                params![format!("run-{memory_id}-{run}"), memory_id],
717            )
718            .expect("insert citation");
719        }
720    }
721
722    /// The warm start pays for this block on every session, so a brain with
723    /// nothing to graduate must say nothing at all.
724    #[test]
725    fn a_quiet_brain_gets_no_nudge() {
726        let conn = init_conn();
727        assert!(graduation_notice(&conn).is_none());
728        cite_from_distinct_runs(&conn, "cold-mem", CITATION_THRESHOLD - 1);
729        assert!(
730            graduation_notice(&conn).is_none(),
731            "below the threshold is not a graduation"
732        );
733    }
734
735    /// The gap this closes: detection ran, found something, and told nobody.
736    #[test]
737    fn an_undrafted_candidate_is_surfaced_with_its_command() {
738        let conn = init_conn();
739        cite_from_distinct_runs(&conn, "hot-mem", CITATION_THRESHOLD);
740        let notice = graduation_notice(&conn).expect("surfaced");
741        assert!(notice.contains('1'), "got: {notice}");
742        assert!(
743            notice.contains("kimetsu brain skills --detect"),
744            "a nudge without the command is not actionable; got: {notice}"
745        );
746    }
747
748    /// Once a candidate has been drafted the loop has moved on, and reporting
749    /// it as still-undrafted would double-count the same memory across both
750    /// halves of the line.
751    #[test]
752    fn a_drafted_candidate_is_reported_as_pending_not_as_a_candidate() {
753        let conn = init_conn();
754        cite_from_distinct_runs(&conn, "hot-mem", CITATION_THRESHOLD);
755        insert_skill_proposal(
756            &conn,
757            "always-run-fmt",
758            "Run cargo fmt before committing",
759            None,
760            &["hot-mem".to_string()],
761            "citations",
762            CITATION_THRESHOLD,
763        )
764        .expect("insert proposal");
765
766        let notice = graduation_notice(&conn).expect("surfaced");
767        assert!(
768            !notice.contains("--detect"),
769            "nothing left to detect; got: {notice}"
770        );
771        assert!(
772            notice.contains("awaiting review"),
773            "the draft is what needs a decision now; got: {notice}"
774        );
775    }
776
777    /// An accepted proposal is a closed loop — nothing to nudge about.
778    #[test]
779    fn an_accepted_proposal_ends_the_nudge() {
780        let conn = init_conn();
781        cite_from_distinct_runs(&conn, "hot-mem", CITATION_THRESHOLD);
782        let proposal_id = insert_skill_proposal(
783            &conn,
784            "always-run-fmt",
785            "Run cargo fmt before committing",
786            None,
787            &["hot-mem".to_string()],
788            "citations",
789            CITATION_THRESHOLD,
790        )
791        .expect("insert proposal");
792        accept_skill_proposal(&conn, &proposal_id, "/skills/always-run-fmt").expect("accept");
793
794        assert!(
795            graduation_notice(&conn).is_none(),
796            "an installed skill is a closed loop"
797        );
798    }
799
800    #[test]
801    fn below_threshold_not_a_candidate() {
802        let conn = init_conn();
803
804        conn.execute(
805            "INSERT INTO memories
806               (memory_id, scope, kind, text, normalized_text, confidence,
807                provenance_snapshot_json, created_at, use_count, usefulness_score)
808             VALUES ('cold-mem', 'project', 'convention', 'Run tests', 'run tests',
809                     0.9, '{}', '2026-01-01T00:00:00Z', 2, 2.0)",
810            [],
811        )
812        .expect("insert memory");
813
814        // Only 2 distinct runs — below CITATION_THRESHOLD.
815        for run_id in ["run-a", "run-b"] {
816            conn.execute(
817                "INSERT INTO memory_citations (run_id, memory_id, turn, cited_at)
818                 VALUES (?1, 'cold-mem', 1, '2026-01-01T00:00:00Z')",
819                params![run_id],
820            )
821            .expect("insert citation");
822        }
823
824        let candidates = find_synthesis_candidates(&conn).expect("find");
825        assert!(
826            !candidates.iter().any(|c| c.memory_id == "cold-mem"),
827            "cold-mem must NOT be a candidate (only 2 citations, threshold=3)"
828        );
829    }
830
831    #[test]
832    fn superseded_memory_excluded_from_candidates() {
833        let conn = init_conn();
834
835        conn.execute(
836            "INSERT INTO memories
837               (memory_id, scope, kind, text, normalized_text, confidence,
838                provenance_snapshot_json, created_at, use_count, usefulness_score,
839                superseded_by)
840             VALUES ('super-mem', 'project', 'convention', 'Old lesson', 'old lesson',
841                     0.9, '{}', '2026-01-01T00:00:00Z', 3, 3.0, 'survivor-mem')",
842            [],
843        )
844        .expect("insert superseded memory");
845
846        for run_id in ["run-x", "run-y", "run-z"] {
847            conn.execute(
848                "INSERT INTO memory_citations (run_id, memory_id, turn, cited_at)
849                 VALUES (?1, 'super-mem', 1, '2026-01-01T00:00:00Z')",
850                params![run_id],
851            )
852            .expect("insert citation");
853        }
854
855        let candidates = find_synthesis_candidates(&conn).expect("find");
856        assert!(
857            !candidates.iter().any(|c| c.memory_id == "super-mem"),
858            "superseded memory must not be a candidate"
859        );
860    }
861
862    // -----------------------------------------------------------------------
863    // Staleness check (2.4)
864    // -----------------------------------------------------------------------
865
866    #[test]
867    fn staleness_check_flags_superseded_source() {
868        let conn = init_conn();
869
870        // Insert a live memory (source) and an accepted skill proposal that
871        // references it.
872        conn.execute(
873            "INSERT INTO memories
874               (memory_id, scope, kind, text, normalized_text, confidence,
875                provenance_snapshot_json, created_at, use_count, usefulness_score,
876                superseded_by)
877             VALUES ('stale-src', 'project', 'convention', 'Old',  'old',
878                     0.9, '{}', '2026-01-01T00:00:00Z', 1, 1.0, 'other-mem')",
879            [],
880        )
881        .expect("insert stale source");
882
883        let proposal_id = insert_skill_proposal(
884            &conn,
885            "stale-skill",
886            "Uses stale source",
887            Some("# Stale Skill"),
888            &["stale-src".to_string()],
889            "citations",
890            3,
891        )
892        .expect("insert proposal");
893        accept_skill_proposal(&conn, &proposal_id, "/tmp/stale-skill").expect("accept");
894
895        let reports = check_staleness(&conn).expect("staleness");
896        let stale = reports.iter().find(|r| r.proposal_id == proposal_id);
897        assert!(stale.is_some(), "proposal must appear in staleness report");
898        let stale = stale.unwrap();
899        assert!(
900            stale.is_stale,
901            "skill with superseded source must be flagged stale"
902        );
903        assert!(
904            stale.stale_memory_ids.contains(&"stale-src".to_string()),
905            "stale-src must be in stale_memory_ids"
906        );
907    }
908
909    #[test]
910    fn staleness_check_ok_for_live_source() {
911        let conn = init_conn();
912
913        conn.execute(
914            "INSERT INTO memories
915               (memory_id, scope, kind, text, normalized_text, confidence,
916                provenance_snapshot_json, created_at, use_count, usefulness_score)
917             VALUES ('live-src', 'project', 'convention', 'Current lesson', 'current lesson',
918                     0.9, '{}', '2026-01-01T00:00:00Z', 3, 3.0)",
919            [],
920        )
921        .expect("insert live source");
922
923        let proposal_id = insert_skill_proposal(
924            &conn,
925            "live-skill",
926            "Uses live source",
927            Some("# Live Skill"),
928            &["live-src".to_string()],
929            "citations",
930            3,
931        )
932        .expect("insert proposal");
933        accept_skill_proposal(&conn, &proposal_id, "/tmp/live-skill").expect("accept");
934
935        let reports = check_staleness(&conn).expect("staleness");
936        let report = reports.iter().find(|r| r.proposal_id == proposal_id);
937        assert!(report.is_some(), "proposal must appear in staleness report");
938        let report = report.unwrap();
939        assert!(
940            !report.is_stale,
941            "skill with live source must NOT be flagged stale"
942        );
943    }
944}