Skip to main content

edda_postmortem/
sign_check.rs

1//! Sign-check: surface doctrine entries that share a family with a machine
2//! candidate — so at sign time the operator sees related existing scars
3//! *next to* the candidate, and can judge for themselves whether it duplicates
4//! or contradicts what's already in doctrine.
5//!
6//! Vocabulary alignment (Foundry q328 review verdict): reuses
7//! [`scars::normalize_prefix`] and [`scars::PREFIX_LEN`] verbatim. We do NOT
8//! introduce a rival "contradiction" concept — same `by_label`/family key,
9//! same rules. The machine only hints; the human judges.
10//!
11//! Contract:
12//! - Read failure-memory.md, layer-1-ideology.md, layer-6-heart-methods.md
13//!   from a doctrine dir; parse `### <heading>` entries.
14//! - Compute family key for each entry (first line after heading).
15//! - For a candidate maxim, compute its family key and find entries with the
16//!   same key.
17//! - Render a "Related in doctrine (machine hint, not judgment)" markdown
18//!   block, or empty string when no related entries exist.
19//!
20//! Opt-in: this module is only invoked when both `EDDA_INCUBATION_PATH` and
21//! `EDDA_DOCTRINE_PATH` are set. Unset ⇒ zero behavior change (candidate
22//! render stays exactly as SELECTOR3 shipped it).
23
24use crate::scars::normalize_prefix;
25use std::fs;
26use std::path::{Path, PathBuf};
27
28/// Doctrine files scanned for related entries. Ordered by scan priority.
29pub const DOCTRINE_FILES: &[&str] = &[
30    "failure-memory.md",
31    "layer-1-ideology.md",
32    "layer-6-heart-methods.md",
33];
34
35/// A single doctrine entry parsed from a doctrine markdown file.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct DoctrineEntry {
38    pub file: String,
39    pub heading: String,
40    pub first_line: String,
41    pub family_key: String,
42}
43
44/// Parse `### <heading>` entries from a doctrine file's markdown.
45/// Family key is computed from the **heading** (with any `FM-N:` / `6.N:` / `L1.N:`
46/// numeric prefix stripped) — humans identify a scar by its heading, not its body,
47/// and candidate maxims (from `Lesson.text`) are one-line summaries that align
48/// with headings, not with bullet bodies.
49/// `first_line` is stored for display context only; it does not drive matching.
50pub fn parse_entries(body: &str, file_label: &str) -> Vec<DoctrineEntry> {
51    let mut out = Vec::new();
52    let lines: Vec<&str> = body.lines().collect();
53    let mut i = 0;
54    while i < lines.len() {
55        let line = lines[i];
56        if let Some(heading) = line.strip_prefix("### ") {
57            // Find first non-empty non-heading line after the heading (for display).
58            let mut j = i + 1;
59            let mut first_line = String::new();
60            while j < lines.len() {
61                let candidate = lines[j].trim();
62                if candidate.is_empty() {
63                    j += 1;
64                    continue;
65                }
66                if candidate.starts_with('#') {
67                    break;
68                }
69                let cleaned = candidate
70                    .trim_start_matches(['-', '*', '>', ' '])
71                    .to_string();
72                first_line = cleaned;
73                break;
74            }
75            let heading_trimmed = heading.trim().to_string();
76            let family_key = normalize_prefix(&strip_entry_number(&heading_trimmed));
77            if !family_key.is_empty() {
78                out.push(DoctrineEntry {
79                    file: file_label.to_string(),
80                    heading: heading_trimmed,
81                    first_line,
82                    family_key,
83                });
84            }
85        }
86        i += 1;
87    }
88    out
89}
90
91/// Strip a leading entry number like `FM-5:`, `6.3:`, `L1.2:` from a heading,
92/// so the family key is derived from the maxim itself, not the numbering scheme.
93fn strip_entry_number(heading: &str) -> String {
94    let colon = match heading.find(':') {
95        Some(i) => i,
96        None => return heading.to_string(),
97    };
98    let prefix = &heading[..colon];
99    // Prefix is an entry number if it's short and consists only of alphanumerics,
100    // dots, and dashes (no whitespace) — matches FM-5, 6.3, L1.2, etc.
101    if prefix.len() <= 8
102        && !prefix.is_empty()
103        && prefix
104            .chars()
105            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.')
106    {
107        heading[colon + 1..].trim().to_string()
108    } else {
109        heading.to_string()
110    }
111}
112
113/// Load all parseable entries from a doctrine directory.
114/// Missing directory or missing files are silently skipped (best-effort).
115pub fn load_doctrine_entries(doctrine_dir: &Path) -> Vec<DoctrineEntry> {
116    let root = resolve_doctrine_root(doctrine_dir);
117    let mut out = Vec::new();
118    for name in DOCTRINE_FILES {
119        let path = root.join(name);
120        let Ok(body) = fs::read_to_string(&path) else {
121            continue;
122        };
123        out.extend(parse_entries(&body, name));
124    }
125    out
126}
127
128/// Mirror `havamal check`'s `resolveDoctrineDir`: prefer `<dir>/references`
129/// when a state-snapshot.md lives there (havamal convention), else `<dir>`.
130fn resolve_doctrine_root(doctrine_dir: &Path) -> PathBuf {
131    let refs = doctrine_dir.join("references");
132    if refs.join("state-snapshot.md").exists() {
133        refs
134    } else {
135        doctrine_dir.to_path_buf()
136    }
137}
138
139/// Minimum overlap length before two family keys are considered related.
140/// Guards against the `""` empty key matching everything and single-word noise.
141const MIN_FAMILY_OVERLAP: usize = 12;
142
143/// Find entries whose family key relates to the candidate's.
144/// Vocabulary alignment: family key = `normalize_prefix(...)`, same function
145/// scars.rs uses. Match rule: keys equal OR one is a prefix of the other
146/// (candidate maxims often carry trailing qualifiers like "(machine-surfaced)"
147/// that make them longer than the canonical heading; either direction is OK).
148pub fn related_entries<'a>(
149    candidate_maxim: &str,
150    entries: &'a [DoctrineEntry],
151) -> Vec<&'a DoctrineEntry> {
152    let key = normalize_prefix(candidate_maxim);
153    if key.is_empty() {
154        return Vec::new();
155    }
156    entries
157        .iter()
158        .filter(|e| family_keys_related(&key, &e.family_key))
159        .collect()
160}
161
162fn family_keys_related(a: &str, b: &str) -> bool {
163    if a.is_empty() || b.is_empty() {
164        return false;
165    }
166    if a == b {
167        return true;
168    }
169    let (shorter, longer) = if a.len() < b.len() { (a, b) } else { (b, a) };
170    // Prefix-relation match with minimum-overlap guard so "the" doesn't match everything.
171    shorter.len() >= MIN_FAMILY_OVERLAP && longer.starts_with(shorter)
172}
173
174/// Render a markdown hint block ("Related in doctrine (machine hint, not
175/// judgment)") for a candidate, or empty string when no related entries.
176pub fn render_related_hint(candidate_maxim: &str, entries: &[DoctrineEntry]) -> String {
177    let related = related_entries(candidate_maxim, entries);
178    if related.is_empty() {
179        return String::new();
180    }
181    let mut out = String::from(
182        "\n- **Related in doctrine (machine hint, not judgment)**: same family key as—\n",
183    );
184    for entry in related {
185        out.push_str(&format!("  - `{}` → {}\n", entry.file, entry.heading));
186    }
187    out.push_str("  (machine surfaces same-family entries; the operator judges duplicate/contradict/refine at sign time.)\n");
188    out
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use tempfile::tempdir;
195
196    #[test]
197    fn parse_entries_extracts_heading_and_first_line() {
198        let body = "\
199# Failure Memory\n\
200\n\
201### FM-1: watch out for silent walls\n\
202- **Temptation:** retrying without new signal.\n\
203- **How it failed:** three silent failures in a row.\n\
204\n\
205### FM-2: unrelated ticket\n\
206- Just a note.\n\
207";
208        let entries = parse_entries(body, "failure-memory.md");
209        assert_eq!(entries.len(), 2);
210        assert_eq!(entries[0].heading, "FM-1: watch out for silent walls");
211        assert!(entries[0]
212            .first_line
213            .contains("retrying without new signal"));
214    }
215
216    #[test]
217    fn related_entries_matches_family_key_of_candidate() {
218        // Family key comes from heading (FM-N: prefix stripped), matching how
219        // candidate maxims read (Lesson.text is a one-liner, not a bullet body).
220        let entries = parse_entries(
221            "### FM-5: Retrying into a silent wall\n**Temptation:** ...\n",
222            "failure-memory.md",
223        );
224        let related = related_entries("Retrying into a silent wall (machine-surfaced)", &entries);
225        assert_eq!(related.len(), 1);
226        assert!(related[0].heading.contains("Retrying"));
227    }
228
229    #[test]
230    fn unrelated_candidate_returns_empty_hint() {
231        let entries = parse_entries(
232            "### FM-5: Retrying into a silent wall\n**Temptation:** ...\n",
233            "failure-memory.md",
234        );
235        let hint = render_related_hint("A completely different maxim about caching", &entries);
236        assert_eq!(hint, "");
237    }
238
239    #[test]
240    fn related_candidate_renders_hint_block_naming_files() {
241        let entries = parse_entries(
242            "### FM-5: Retrying into a silent wall\n**Temptation:** ...\n",
243            "failure-memory.md",
244        );
245        let hint = render_related_hint("Retrying into a silent wall", &entries);
246        assert!(hint.contains("Related in doctrine"));
247        assert!(hint.contains("failure-memory.md"));
248        assert!(hint.contains("Retrying"));
249        // Machine-hint-not-judgment framing MUST be present (red-line phrasing).
250        assert!(hint.contains("not judgment"));
251        assert!(hint.contains("operator judges"));
252    }
253
254    #[test]
255    fn strip_entry_number_handles_common_forms() {
256        assert_eq!(strip_entry_number("FM-5: Retrying"), "Retrying");
257        assert_eq!(strip_entry_number("6.3: Paid lesson"), "Paid lesson");
258        assert_eq!(strip_entry_number("L1.2: The belief"), "The belief");
259        // Not a number: keep as-is (defensive — arbitrary long prefixes stay).
260        assert_eq!(
261            strip_entry_number("Something: with colon"),
262            "Something: with colon"
263        );
264        // No colon: keep as-is.
265        assert_eq!(strip_entry_number("No colon here"), "No colon here");
266    }
267
268    #[test]
269    fn load_doctrine_entries_handles_missing_dir_gracefully() {
270        let dir = tempdir().unwrap();
271        // No files — should return empty, not error.
272        let entries = load_doctrine_entries(dir.path());
273        assert!(entries.is_empty());
274    }
275
276    #[test]
277    fn load_doctrine_entries_reads_files_from_references_subdir() {
278        let dir = tempdir().unwrap();
279        let refs = dir.path().join("references");
280        fs::create_dir_all(&refs).unwrap();
281        fs::write(refs.join("state-snapshot.md"), "# State").unwrap();
282        fs::write(
283            refs.join("failure-memory.md"),
284            "### FM-1: sample\n- **Temptation:** the fix looked easy.\n",
285        )
286        .unwrap();
287        let entries = load_doctrine_entries(dir.path());
288        assert_eq!(entries.len(), 1);
289        assert_eq!(entries[0].file, "failure-memory.md");
290    }
291
292    #[test]
293    fn vocabulary_alignment_family_key_uses_normalize_prefix() {
294        // Two texts that scars.rs normalize_prefix collapses to the same key
295        // MUST be treated as the same family by sign_check — no rival concept.
296        // Case + whitespace variation (safe: no clause-break chars).
297        let a = "Retrying   Into a Silent WALL";
298        let b = "retrying into a silent wall";
299        assert_eq!(
300            normalize_prefix(a),
301            normalize_prefix(b),
302            "scars.rs normalize_prefix is the single family key vocabulary"
303        );
304        let entries = parse_entries(&format!("### FM-x: {a}\nbody\n"), "failure-memory.md");
305        let related = related_entries(b, &entries);
306        assert_eq!(
307            related.len(),
308            1,
309            "case/whitespace-normalized keys collide as scars.rs guarantees"
310        );
311    }
312}