Skip to main content

active_core/
heal.rs

1//! Self-healing engine (C-016).
2//!
3//! Phase 1 (syntactic repair) lives in the lexer (H001: unclosed strings)
4//! and parser (H002: trailing commas, H003: missing commas, H004: bracket
5//! recovery). This module hosts:
6//! - Phase 2 (semantic key repair): similarity scoring and best-candidate
7//!   selection over a set of legal keys, wired to schema-controlled
8//!   tables in validation (H005 suggestions in strict mode, guarded
9//!   auto-rename under resilient mode).
10//! - Phase 3 (type/constraint healing): safe coercion functions, wired
11//!   to validation mismatches (H007 under resilient mode) with
12//!   sentinel recognition in strict messages.
13
14use crate::diag::{thresholds, Diagnostic, Repair, RepairKind, Severity, Span};
15use crate::ir::Module;
16use crate::types::{ActiveType, Unit};
17
18// ---------------------------------------------------------------------------
19// Phase 2: similarity scoring for semantic key repair
20// ---------------------------------------------------------------------------
21
22/// Normalized string-similarity based on optimal string alignment (the
23/// restricted Damerau-Levenshtein variant): 1.0 for identical strings,
24/// 0.0 when every character differs. Case-insensitive, and a transposition
25/// (the most common typo, `prot`/`port`) counts as a single edit.
26pub fn normalized_levenshtein(a: &str, b: &str) -> f64 {
27    let a = a.to_lowercase();
28    let b = b.to_lowercase();
29    let (al, bl) = (a.len(), b.len());
30    if al == 0 && bl == 0 {
31        return 1.0;
32    }
33    if al == 0 || bl == 0 {
34        return 0.0;
35    }
36    let dist = damerau_levenshtein(&a, &b);
37    1.0 - (dist as f64 / al.max(bl) as f64)
38}
39
40/// Optimal string alignment (the restricted Damerau-Levenshtein variant):
41/// insertion, deletion, substitution, and adjacent transposition, each
42/// costing one edit. Unrestricted Damerau-Levenshtein differs only for
43/// substrings with repeated characters edited more than once; OSA is the
44/// standard choice for typo detection.
45/// The indexed loops mirror the classic DP table definition, where the
46/// transposition rule needs the i-1/i-2 and j-1/j-2 neighbourhood.
47#[allow(clippy::needless_range_loop)]
48fn damerau_levenshtein(a: &str, b: &str) -> usize {
49    let a: Vec<char> = a.chars().collect();
50    let b: Vec<char> = b.chars().collect();
51    let (al, bl) = (a.len(), b.len());
52    if al == 0 {
53        return bl;
54    }
55    if bl == 0 {
56        return al;
57    }
58    let mut d = vec![vec![0usize; bl + 1]; al + 1];
59    for i in 0..=al {
60        d[i][0] = i;
61    }
62    for j in 0..=bl {
63        d[0][j] = j;
64    }
65    for i in 1..=al {
66        for j in 1..=bl {
67            let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
68            let mut best = (d[i - 1][j] + 1)
69                .min(d[i][j - 1] + 1)
70                .min(d[i - 1][j - 1] + cost);
71            if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1] {
72                best = best.min(d[i - 2][j - 2] + 1);
73            }
74            d[i][j] = best;
75        }
76    }
77    d[al][bl]
78}
79
80/// Keyboard-layout similarity: how close two strings are on a QWERTY
81/// layout. Returns 0.0–1.0; 1.0 means every character is on the same or an
82/// adjacent key. Only meaningful for strings of equal length.
83pub fn keyboard_similarity(a: &str, b: &str) -> f64 {
84    if a.len() != b.len() {
85        return 0.0;
86    }
87    let a = a.to_lowercase();
88    let b = b.to_lowercase();
89    let total = a.len() as f64;
90    if total == 0.0 {
91        return 1.0;
92    }
93    let matches = a
94        .chars()
95        .zip(b.chars())
96        .filter(|&(ca, cb)| ca == cb || are_adjacent_keys(ca, cb))
97        .count();
98    matches as f64 / total
99}
100
101fn are_adjacent_keys(a: char, b: char) -> bool {
102    let pos = |c: char| -> Option<(i32, i32)> {
103        let layout = [
104            "`1234567890-=",
105            "\tqwertyuiop[]\\",
106            " asdfghjkl;'",
107            " zxcvbnm,./",
108        ];
109        for (row, line) in layout.iter().enumerate() {
110            if let Some(col) = line.find(c) {
111                return Some((row as i32, col as i32));
112            }
113        }
114        None
115    };
116    match (pos(a), pos(b)) {
117        (Some((r1, c1)), Some((r2, c2))) => (r1 - r2).abs().max((c1 - c2).abs()) <= 1,
118        _ => false,
119    }
120}
121
122/// Phonetic similarity via a simplified Soundex-style Metaphone. 1.0 when
123/// the encodings match, 0.0 otherwise.
124pub fn phonetic_similarity(a: &str, b: &str) -> f64 {
125    if metaphone(a) == metaphone(b) {
126        1.0
127    } else {
128        0.0
129    }
130}
131
132/// Simplified Soundex-style encoding: keeps the first letter, collapses
133/// consecutive letters with the same code, suppresses vowels after the
134/// first letter, then pads/truncates to 4 characters. Crude but sufficient
135/// for typo detection (`framwork` and `framework` encode alike).
136fn metaphone(s: &str) -> String {
137    let code = |c: char| -> Option<char> {
138        match c.to_ascii_lowercase() {
139            'b' | 'f' | 'p' | 'v' => Some('1'),
140            'c' | 'g' | 'j' | 'k' | 'q' | 's' | 'x' | 'z' => Some('2'),
141            'd' | 't' => Some('3'),
142            'l' => Some('4'),
143            'm' | 'n' => Some('5'),
144            'r' => Some('6'),
145            _ => None,
146        }
147    };
148    let mut out = String::new();
149    for (i, c) in s.chars().enumerate() {
150        if i == 0 {
151            out.push(c.to_ascii_lowercase());
152        } else if let Some(d) = code(c) {
153            if !out.ends_with(d) {
154                out.push(d);
155            }
156        }
157    }
158    while out.len() < 4 {
159        out.push('0');
160    }
161    out.truncate(4);
162    out
163}
164
165/// Composite similarity score blending three independent signals with a
166/// noisy-OR: edit distance (primary), keyboard adjacency, and phonetics.
167/// Any single strong signal can push the score high, while weak signals
168/// reinforce each other. 0.0–1.0.
169pub fn key_similarity(typo: &str, candidate: &str) -> f64 {
170    let lev = normalized_levenshtein(typo, candidate);
171    let kb = keyboard_similarity(typo, candidate);
172    let phon = phonetic_similarity(typo, candidate);
173    1.0 - (1.0 - lev) * (1.0 - 0.5 * kb) * (1.0 - 0.5 * phon)
174}
175
176/// Choose the best matching key from a legal set. Only candidates with
177/// similarity ≥ threshold are considered; on an exact tie the earliest
178/// candidate in argument order wins.
179pub fn best_key_match(typo: &str, legal: &[String], threshold: f64) -> Option<(String, f64)> {
180    let mut best: Option<(String, f64)> = None;
181    for k in legal {
182        let score = key_similarity(typo, k);
183        if score < threshold {
184            continue;
185        }
186        let better = match &best {
187            Some((_, best_score)) => score > *best_score,
188            None => true,
189        };
190        if better {
191            best = Some((k.clone(), score));
192        }
193    }
194    best
195}
196
197/// Build an H005 (key typo suggestion) diagnostic.
198///
199/// The composite score ranks candidates, but it overstates certainty
200/// (the three signals are correlated). The `safe` flag — i.e. permission
201/// to auto-apply — therefore requires a near-identical edit distance,
202/// not merely a high composite.
203pub fn make_key_typo_diagnostic(
204    typo: &str,
205    suggestion: &str,
206    confidence: f64,
207    span: Option<Span>,
208) -> Diagnostic {
209    let safe = normalized_levenshtein(typo, suggestion) >= thresholds::AUTO_APPLY;
210    Diagnostic {
211        severity: if safe {
212            Severity::Info
213        } else {
214            Severity::Warning
215        },
216        code: "H005".into(),
217        message: format!("unknown key '{}', did you mean '{}'?", typo, suggestion),
218        path: Some(typo.into()),
219        span,
220        repair: Some(Repair {
221            original: typo.into(),
222            suggested: suggestion.into(),
223            kind: RepairKind::KeyTypo,
224            safe,
225        }),
226        confidence,
227        // The caller stamps the file (same file as the typo); library
228        // default is unset for single-file use.
229        file: None,
230    }
231}
232
233// ---------------------------------------------------------------------------
234// Phase 3: type/constraint healing (safe coercion functions)
235// ---------------------------------------------------------------------------
236
237/// Try to coerce a string that looks like an integer. Rejects signs that
238/// are more likely formatting than numbers.
239pub fn try_coerce_int(s: &str) -> Option<i64> {
240    let trimmed = s.trim();
241    if trimmed.starts_with('$') || trimmed.starts_with('#') {
242        return None;
243    }
244    trimmed.parse::<i64>().ok()
245}
246
247/// Try to parse a string as a duration: `5s`, `300ms`, `1.5h`. Returns
248/// milliseconds on success.
249pub fn try_coerce_duration(s: &str) -> Option<u64> {
250    let trimmed = s.trim();
251    let split = trimmed.find(|c: char| !c.is_ascii_digit() && c != '.' && c != '-');
252    match split {
253        Some(pos) if pos > 0 => {
254            let num: f64 = trimmed[..pos].parse().ok()?;
255            let suffix = &trimmed[pos..];
256            Unit::from_suffix(suffix)?.normalize_time_to_ms(num)
257        }
258        _ => None,
259    }
260}
261
262/// Map a sentinel word to its conventional intent. Sentinels are not
263/// values — they signal intent. The schema layer resolves the concrete
264/// value (e.g. "unlimited" under Int(min=0) might become -1).
265pub fn sentinel_meaning(word: &str) -> Option<&'static str> {
266    match word.to_lowercase().as_str() {
267        "unlimited" | "no_limit" | "max" => Some("limit"),
268        "none" | "null" | "off" => Some("absent"),
269        "inf" | "infinity" | "forever" => Some("infinite"),
270        "any" | "all" | "wildcard" | "*" => Some("wildcard"),
271        _ => None,
272    }
273}
274
275/// Safe string → target-type coercion, applied only when a type error is
276/// detected but the value is a string that could reasonably become the
277/// expected type. Returns the coerced value or None.
278pub fn coerce_string_to(value: &str, target: &ActiveType) -> Option<crate::ir::Value> {
279    match target {
280        ActiveType::Int => try_coerce_int(value).map(crate::ir::Value::Int),
281        ActiveType::Float => value
282            .trim()
283            .parse::<f64>()
284            .ok()
285            .map(crate::ir::Value::Float),
286        ActiveType::Bool => match value.to_lowercase().as_str() {
287            "true" | "yes" | "1" | "on" => Some(crate::ir::Value::Bool(true)),
288            "false" | "no" | "0" | "off" => Some(crate::ir::Value::Bool(false)),
289            _ => None,
290        },
291        ActiveType::Duration => try_coerce_duration(value).map(crate::ir::Value::DurationMs),
292        _ => None,
293    }
294}
295
296// ---------------------------------------------------------------------------
297// Orchestrator
298// ---------------------------------------------------------------------------
299
300/// Run the self-healing passes over a module. Returns the number of repairs
301/// recorded. Phase 1 repairs are already in `module.healing`; phases 2 and
302/// 3 run schema-driven inside validation (H005/H007), which owns the
303/// schema tree and the evaluated values they need.
304pub fn heal(module: &mut Module) -> usize {
305    module.healing.len()
306}