Skip to main content

edda_postmortem/
candidates.rs

1//! Doctrine candidates: export postmortem findings into a havamal incubation
2//! file for **human** promotion.
3//!
4//! Red line (tested, not commented): the machine never writes main doctrine.
5//! This module only ever opens the single configured incubation file, and only
6//! rewrites the managed block inside it — bytes outside the block are
7//! preserved exactly. Candidates carry provenance (session, trigger, evidence)
8//! and a dedup key so re-runs never duplicate. Promotion out of incubation is
9//! always a human edit (havamal maintenance sweep).
10//!
11//! Opt-in: wiring reads `EDDA_INCUBATION_PATH`; unset ⇒ this module is never
12//! called (zero behavior change — same shape as `EDDA_LLM_API_KEY`).
13
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16use std::fs;
17use std::io;
18use std::path::{Path, PathBuf};
19
20use crate::analyzer::{LessonSeverity, PostMortemResult};
21
22pub const CANDIDATES_SECTION_START: &str = "<!-- edda:candidates:start -->";
23pub const CANDIDATES_SECTION_END: &str = "<!-- edda:candidates:end -->";
24
25/// Minimum rule-proposal confidence to graduate into a doctrine candidate.
26const MIN_PROPOSAL_CONFIDENCE: f64 = 0.7;
27/// Hard cap of candidates appended per postmortem run (anti-flooding).
28pub const MAX_CANDIDATES_PER_RUN: usize = 3;
29
30/// A doctrine candidate bound for the incubation file.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct DoctrineCandidate {
33    /// Provenance/dedup key: sha256(trigger + maxim), first 12 hex chars.
34    pub key: String,
35    pub name: String,
36    pub maxim: String,
37    pub session_id: String,
38    pub trigger: String,
39    pub evidence: Vec<String>,
40    pub created_at: String,
41}
42
43/// Resolve the incubation path from the env value. `None`/empty ⇒ feature off.
44/// Relative paths resolve against `cwd` (the session's repo root).
45pub fn resolve_incubation_path(env_val: Option<&str>, cwd: &Path) -> Option<PathBuf> {
46    let raw = env_val?.trim();
47    if raw.is_empty() {
48        return None;
49    }
50    let p = Path::new(raw);
51    Some(if p.is_absolute() {
52        p.to_path_buf()
53    } else {
54        cwd.join(p)
55    })
56}
57
58/// Pick at most `max` candidates from a postmortem result:
59/// high-severity lessons first, then rule proposals with confidence ≥ 0.7.
60pub fn select_candidates(result: &PostMortemResult, max: usize) -> Vec<DoctrineCandidate> {
61    let mut out: Vec<DoctrineCandidate> = Vec::new();
62
63    for lesson in &result.lessons {
64        if out.len() >= max {
65            break;
66        }
67        if lesson.severity != LessonSeverity::High {
68            continue;
69        }
70        let key = provenance_key(&lesson.source_trigger, &lesson.text);
71        out.push(DoctrineCandidate {
72            name: format!("{} ({})", lesson.source_trigger, &key[..6]),
73            maxim: lesson.text.clone(),
74            session_id: result.session_id.clone(),
75            trigger: lesson.source_trigger.clone(),
76            evidence: if lesson.tags.is_empty() {
77                vec!["(stats-level; event-id refs are v2)".to_string()]
78            } else {
79                lesson.tags.clone()
80            },
81            created_at: result.analyzed_at.clone(),
82            key,
83        });
84    }
85
86    for proposal in &result.rule_proposals {
87        if out.len() >= max {
88            break;
89        }
90        if proposal.confidence < MIN_PROPOSAL_CONFIDENCE {
91            continue;
92        }
93        let maxim = format!("{} (when: {})", proposal.action, proposal.trigger);
94        let key = provenance_key(&proposal.trigger, &proposal.action);
95        out.push(DoctrineCandidate {
96            name: format!("{} ({})", proposal.trigger, &key[..6]),
97            maxim,
98            session_id: result.session_id.clone(),
99            trigger: proposal.trigger.clone(),
100            evidence: proposal.evidence.clone(),
101            created_at: result.analyzed_at.clone(),
102            key,
103        });
104    }
105
106    out
107}
108
109/// Append new candidates into the managed block of the incubation file.
110///
111/// - Creates the file (with a minimal header + block) when missing.
112/// - Dedup: a candidate whose key already appears anywhere in the file is
113///   skipped — re-runs are idempotent.
114/// - When nothing new survives dedup, the file is not rewritten at all.
115/// - Bytes outside the managed block are preserved exactly (red-line test).
116///
117/// Returns how many candidates were appended.
118pub fn sync_candidates_to_incubation(
119    path: &Path,
120    candidates: &[DoctrineCandidate],
121) -> io::Result<usize> {
122    sync_candidates_to_incubation_with_hints(path, candidates, &[])
123}
124
125/// Same as [`sync_candidates_to_incubation`] but appends per-candidate
126/// "Related in doctrine (machine hint, not judgment)" blocks derived from
127/// `doctrine_entries` (see [`crate::sign_check`]). Passing an empty slice
128/// reproduces the base behavior exactly — sign-check is opt-in, off by default.
129pub fn sync_candidates_to_incubation_with_hints(
130    path: &Path,
131    candidates: &[DoctrineCandidate],
132    doctrine_entries: &[crate::sign_check::DoctrineEntry],
133) -> io::Result<usize> {
134    let existing = match fs::read_to_string(path) {
135        Ok(content) => Some(content),
136        Err(err) if err.kind() == io::ErrorKind::NotFound => None,
137        Err(err) => return Err(err),
138    };
139
140    let fresh: Vec<&DoctrineCandidate> = candidates
141        .iter()
142        .filter(|c| {
143            existing
144                .as_deref()
145                .map(|content| !content.contains(&format!("key: {}", c.key)))
146                .unwrap_or(true)
147        })
148        .collect();
149
150    if fresh.is_empty() {
151        return Ok(0);
152    }
153
154    let rendered: String = fresh
155        .iter()
156        .map(|c| {
157            let base = render_candidate(c);
158            let hint = crate::sign_check::render_related_hint(&c.maxim, doctrine_entries);
159            if hint.is_empty() {
160                base
161            } else {
162                // Splice hint before the trailing newline of the base entry.
163                let mut merged = base.trim_end_matches('\n').to_string();
164                merged.push_str(&hint);
165                merged.push('\n');
166                merged
167            }
168        })
169        .collect();
170
171    let new_content = match existing {
172        None => format!(
173            "# Incubation (Candidates)\n\n\
174             > Machine candidates below await human review. Promote, archive, or delete —\n\
175             > edda never writes main doctrine; signing is always a human edit.\n\n\
176             {start}\n{rendered}{end}\n",
177            start = CANDIDATES_SECTION_START,
178            end = CANDIDATES_SECTION_END,
179            rendered = rendered,
180        ),
181        Some(content) => {
182            match (
183                content.find(CANDIDATES_SECTION_START),
184                content.find(CANDIDATES_SECTION_END),
185            ) {
186                (Some(_), Some(end_pos)) => {
187                    // Splice new entries right before the END marker; everything
188                    // else — including prior entries inside the block — stays.
189                    format!("{}{}{}", &content[..end_pos], rendered, &content[end_pos..])
190                }
191                _ => {
192                    // No block yet: append one at the end; existing bytes untouched.
193                    let sep = if content.ends_with('\n') { "" } else { "\n" };
194                    format!(
195                        "{content}{sep}\n{start}\n{rendered}{end}\n",
196                        content = content,
197                        sep = sep,
198                        start = CANDIDATES_SECTION_START,
199                        end = CANDIDATES_SECTION_END,
200                        rendered = rendered,
201                    )
202                }
203            }
204        }
205    };
206
207    fs::write(path, new_content)?;
208    Ok(fresh.len())
209}
210
211fn render_candidate(c: &DoctrineCandidate) -> String {
212    let evidence = if c.evidence.is_empty() {
213        "(stats-level; event-id refs are v2)".to_string()
214    } else {
215        c.evidence.join("; ")
216    };
217    format!(
218        "\n## Candidate: {name}\n\
219         <!-- key: {key} · session: {session} · trigger: {trigger} · at: {at} -->\n\n\
220         - **Source:** edda postmortem — session `{session}`, trigger `{trigger}`; evidence: {evidence}\n\
221         - **Why it may matter:** {maxim}\n\
222         - **Not promoted because:** machine-extracted (deterministic heuristics) — awaiting human sign-off\n\
223         - **Revisit after:** next maintenance sweep (30-day noise rule applies)\n",
224        name = c.name,
225        key = c.key,
226        session = c.session_id,
227        trigger = c.trigger,
228        at = c.created_at,
229        evidence = evidence,
230        maxim = c.maxim,
231    )
232}
233
234fn provenance_key(trigger: &str, text: &str) -> String {
235    let mut hasher = Sha256::new();
236    hasher.update(trigger.as_bytes());
237    hasher.update(b"|");
238    hasher.update(text.as_bytes());
239    hex::encode(hasher.finalize())[..12].to_string()
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use crate::analyzer::{Lesson, LessonSeverity, PostMortemResult, RuleProposal};
246    use crate::rules::RuleCategory;
247    use crate::trigger::TriggerReason;
248
249    fn result_with(lessons: Vec<Lesson>, proposals: Vec<RuleProposal>) -> PostMortemResult {
250        PostMortemResult {
251            session_id: "sess-1".into(),
252            triggers: vec![TriggerReason::SessionFailures],
253            lessons,
254            rule_proposals: proposals,
255            analyzed_at: "2026-07-08T00:00:00Z".into(),
256        }
257    }
258
259    fn lesson(text: &str, severity: LessonSeverity) -> Lesson {
260        Lesson {
261            id: "lesson_x".into(),
262            text: text.into(),
263            severity,
264            tags: vec!["failure".into()],
265            source_trigger: "session_failures".into(),
266        }
267    }
268
269    fn proposal(action: &str, confidence: f64) -> RuleProposal {
270        RuleProposal {
271            trigger: "command_failure:npm".into(),
272            action: action.into(),
273            anchor_file: None,
274            category: RuleCategory::Workflow,
275            confidence,
276            evidence: vec!["Failed command: npm test".into()],
277        }
278    }
279
280    #[test]
281    fn resolve_path_none_or_empty_is_off() {
282        let cwd = Path::new("/repo");
283        assert_eq!(resolve_incubation_path(None, cwd), None);
284        assert_eq!(resolve_incubation_path(Some(""), cwd), None);
285        assert_eq!(resolve_incubation_path(Some("   "), cwd), None);
286    }
287
288    #[test]
289    fn resolve_path_relative_joins_cwd_absolute_kept() {
290        let cwd = Path::new("/repo");
291        assert_eq!(
292            resolve_incubation_path(Some("docs/incubation.md"), cwd),
293            Some(PathBuf::from("/repo").join("docs/incubation.md"))
294        );
295        let abs = if cfg!(windows) {
296            "C:\\x\\inc.md"
297        } else {
298            "/x/inc.md"
299        };
300        assert_eq!(
301            resolve_incubation_path(Some(abs), cwd),
302            Some(PathBuf::from(abs))
303        );
304    }
305
306    #[test]
307    fn select_takes_high_lessons_and_confident_proposals_capped() {
308        let result = result_with(
309            vec![
310                lesson("high one", LessonSeverity::High),
311                lesson("low one", LessonSeverity::Low),
312                lesson("high two", LessonSeverity::High),
313            ],
314            vec![proposal("confident", 0.8), proposal("weak", 0.5)],
315        );
316        let picked = select_candidates(&result, MAX_CANDIDATES_PER_RUN);
317        assert_eq!(picked.len(), 3);
318        assert!(picked[0].maxim.contains("high one"));
319        assert!(picked[1].maxim.contains("high two"));
320        assert!(picked[2].maxim.contains("confident"));
321        // deterministic keys: same input, same key
322        let again = select_candidates(&result, MAX_CANDIDATES_PER_RUN);
323        assert_eq!(picked[0].key, again[0].key);
324    }
325
326    #[test]
327    fn select_respects_hard_cap() {
328        let result = result_with(
329            (0..5)
330                .map(|i| lesson(&format!("high {i}"), LessonSeverity::High))
331                .collect(),
332            vec![],
333        );
334        assert_eq!(select_candidates(&result, 3).len(), 3);
335    }
336
337    #[test]
338    fn sync_creates_file_with_block_and_four_fields() {
339        let dir = tempfile::tempdir().unwrap();
340        let path = dir.path().join("incubation.md");
341        let result = result_with(
342            vec![lesson("watch the baseline", LessonSeverity::High)],
343            vec![],
344        );
345        let cands = select_candidates(&result, 3);
346
347        let n = sync_candidates_to_incubation(&path, &cands).unwrap();
348        assert_eq!(n, 1);
349        let content = fs::read_to_string(&path).unwrap();
350        assert!(content.contains(CANDIDATES_SECTION_START));
351        assert!(content.contains(CANDIDATES_SECTION_END));
352        assert!(content.contains("## Candidate:"));
353        assert!(content.contains("**Source:**"));
354        assert!(content.contains("**Why it may matter:** watch the baseline"));
355        assert!(content.contains("**Not promoted because:**"));
356        assert!(content.contains("**Revisit after:**"));
357        assert!(content.contains("session `sess-1`"));
358    }
359
360    #[test]
361    fn sync_is_idempotent_no_rewrite_on_second_run() {
362        let dir = tempfile::tempdir().unwrap();
363        let path = dir.path().join("incubation.md");
364        let result = result_with(vec![lesson("only once", LessonSeverity::High)], vec![]);
365        let cands = select_candidates(&result, 3);
366
367        assert_eq!(sync_candidates_to_incubation(&path, &cands).unwrap(), 1);
368        let first = fs::read_to_string(&path).unwrap();
369        assert_eq!(sync_candidates_to_incubation(&path, &cands).unwrap(), 0);
370        let second = fs::read_to_string(&path).unwrap();
371        assert_eq!(first, second, "no-op run must not rewrite the file");
372    }
373
374    #[test]
375    fn sync_preserves_bytes_outside_managed_block() {
376        let dir = tempfile::tempdir().unwrap();
377        let path = dir.path().join("incubation.md");
378        let prefix = "# My Incubation\n\nhand-written intro — do not touch\n\n";
379        let block = format!("{}\n{}\n", CANDIDATES_SECTION_START, CANDIDATES_SECTION_END);
380        let suffix = "\n## Recently promoted\n\n- 2026-07-01 — old note stays\n";
381        fs::write(&path, format!("{prefix}{block}{suffix}")).unwrap();
382
383        let result = result_with(vec![lesson("spliced in", LessonSeverity::High)], vec![]);
384        let n = sync_candidates_to_incubation(&path, &select_candidates(&result, 3)).unwrap();
385        assert_eq!(n, 1);
386
387        let content = fs::read_to_string(&path).unwrap();
388        assert!(content.starts_with(prefix), "bytes before block unchanged");
389        assert!(content.ends_with(suffix), "bytes after block unchanged");
390        assert!(content.contains("spliced in"));
391        let start = content.find(CANDIDATES_SECTION_START).unwrap();
392        let end = content.find(CANDIDATES_SECTION_END).unwrap();
393        let inside = &content[start..end];
394        assert!(
395            inside.contains("## Candidate:"),
396            "entry landed inside block"
397        );
398    }
399
400    #[test]
401    fn sync_with_empty_hints_matches_base_behavior_byte_for_byte() {
402        use tempfile::tempdir;
403        let dir_a = tempdir().unwrap();
404        let dir_b = tempdir().unwrap();
405        let result = result_with(vec![lesson("shared maxim", LessonSeverity::High)], vec![]);
406        let cands = select_candidates(&result, 3);
407
408        // Path 1: base API.
409        sync_candidates_to_incubation(&dir_a.path().join("i.md"), &cands).unwrap();
410        // Path 2: hints API with empty slice.
411        sync_candidates_to_incubation_with_hints(&dir_b.path().join("i.md"), &cands, &[]).unwrap();
412
413        let a = fs::read_to_string(dir_a.path().join("i.md")).unwrap();
414        let b = fs::read_to_string(dir_b.path().join("i.md")).unwrap();
415        assert_eq!(
416            a, b,
417            "empty hint slice = zero behavior change (opt-in guard)"
418        );
419    }
420
421    #[test]
422    fn sync_with_matching_hint_appends_related_block_inside_candidate() {
423        use crate::sign_check::parse_entries;
424        use tempfile::tempdir;
425        let dir = tempdir().unwrap();
426        let path = dir.path().join("incubation.md");
427        // Candidate maxim contains "shared family" — align a doctrine entry to it.
428        let entries = parse_entries("### FM-9: shared family topic\nbody\n", "failure-memory.md");
429        let result = result_with(
430            vec![lesson(
431                "shared family topic (surfaced)",
432                LessonSeverity::High,
433            )],
434            vec![],
435        );
436        let cands = select_candidates(&result, 3);
437        sync_candidates_to_incubation_with_hints(&path, &cands, &entries).unwrap();
438        let content = fs::read_to_string(&path).unwrap();
439        assert!(
440            content.contains("Related in doctrine"),
441            "hint block present"
442        );
443        assert!(content.contains("FM-9"), "specific matching heading named");
444        assert!(
445            content.contains("not judgment"),
446            "machine-hint red-line phrasing kept"
447        );
448    }
449
450    #[test]
451    fn sync_appends_block_when_file_has_none() {
452        let dir = tempfile::tempdir().unwrap();
453        let path = dir.path().join("incubation.md");
454        let original = "# Existing incubation without block\n";
455        fs::write(&path, original).unwrap();
456
457        let result = result_with(vec![lesson("new block", LessonSeverity::High)], vec![]);
458        sync_candidates_to_incubation(&path, &select_candidates(&result, 3)).unwrap();
459
460        let content = fs::read_to_string(&path).unwrap();
461        assert!(content.starts_with(original), "existing bytes untouched");
462        assert!(content.contains(CANDIDATES_SECTION_START));
463    }
464}